#!/bin/sh # 主机侧监控检查:输出一行 JSON 状态,任一项 fail 则退出非零。 # # 用法(主机上,codexsvc 可读权限即可): # sh /opt/codex-app-server/deploy/monitor.sh # sh /opt/codex-app-server/deploy/monitor.sh --self-test # # 检查项(全部不需要密码、不读 env 文件): # server:systemctl is-active codex-cloud-server 与 # codex-cloud-server-2 均为 active(HA 阶段 2 双机) # worker:codex-autoscaler 或 codex-cloud-worker 任一为 active # (autoscaler 接管后静态 worker 故意停用,只查静态会误报: # 2026-10-26 07:02:55 静态 worker 停用后每 5 分钟 worker fail, # 实际 worker 正常、health 的 worker_ok 也一直 pass) # health:curl health,要求 ok=true、backend=postgres、db_ok=true、 # worker_ok=true(worker 心跳 180 秒内,见 server.py) # postgres:pg_isready -h 127.0.0.1 -p 5432(只测就绪,不连库查数据, # 所以不需要密码;需要密码的 SELECT 1 那一步直接跳过,不做) # docker_postgres:docker ps 里 codex-postgres 在运行 # disk:磁盘使用率 < 90%(默认看 /var/lib/codex-app-server,不存在则看 /) # app_dir:/opt/codex-app-server 目录存在 # backup:最近的 cloud-backup-*.sql 在 36 小时内(每天一次定时备份, # HA 阶段 0;目录默认 /var/lib/codex-app-server/backups,可用 # MONITOR_BACKUP_DIR 覆盖) # # 输出示例(单行 JSON,值只会是 pass/fail,不含任何密钥): # {"overall": "pass", "checks": {"server": "pass", ...}} # # --self-test:只验证脚本本身的机制(health JSON 解析 + 输出是合法 JSON), # 不验证主机健康。它会起一个本地 canned health server(只返回固定健康 # JSON),断言 health 那一项为 pass;其它项在非主机机器上 fail 是正常的, # self-test 不因此失败。不要把 --self-test 通过误读成“主机健康”。 # # 告警边界:本脚本只做“检查 + journal 日志”(配合 monitor.timer 每 5 分钟 # 跑一次),它本身不会发消息给用户。推送到 Muse 聊天的告警仍需用户另行 # 决定通道(SSH key 常驻通道或 webhook URL),见 deploy/README.md。 set -eu HEALTH_URL=${MONITOR_HEALTH_URL:-http://127.0.0.1:8765/health} APP_DIR=${MONITOR_APP_DIR:-/opt/codex-app-server} DISK_PATH=${MONITOR_DISK_PATH:-/var/lib/codex-app-server} if [ ! -d "$DISK_PATH" ]; then DISK_PATH="/"; fi check_server() { # HA 阶段 2:两台 server 都必须 active,任一挂了都算 fail # (单台 active 时 API 还能用,但冗余已丢,必须可见)。 if systemctl is-active codex-cloud-server >/dev/null 2>&1 \ && systemctl is-active codex-cloud-server-2 >/dev/null 2>&1; then echo pass else echo fail fi } check_worker() { if systemctl is-active codex-autoscaler >/dev/null 2>&1 \ || systemctl is-active codex-cloud-worker >/dev/null 2>&1; then echo pass else echo fail fi } check_health() { OUT=$(curl -s --max-time 5 "$HEALTH_URL" || true) if [ -z "$OUT" ]; then echo fail; return; fi if printf '%s' "$OUT" | HEALTH_JSON="$OUT" python3 -c ' import json, os, sys try: d = json.loads(os.environ["HEALTH_JSON"]) except Exception: sys.exit(1) sys.exit(0 if (d.get("ok") is True and d.get("backend") == "postgres" and d.get("db_ok") is True and d.get("worker_ok") is True) else 1) ' 2>/dev/null; then echo pass else echo fail fi } check_postgres() { if command -v pg_isready >/dev/null 2>&1 \ && pg_isready -h 127.0.0.1 -p 5432 >/dev/null 2>&1; then echo pass else echo fail fi } check_docker_postgres() { if command -v docker >/dev/null 2>&1 \ && docker ps --format '{{.Names}}' 2>/dev/null \ | grep -qx 'codex-postgres'; then echo pass else echo fail fi } check_disk() { USE=$(df -P "$DISK_PATH" 2>/dev/null | awk 'NR==2 {print $5}' \ | tr -d '% ' || true) case "$USE" in ''|*[!0-9]*) echo fail ;; *) if [ "$USE" -lt 90 ]; then echo pass; else echo fail; fi ;; esac } check_app_dir() { if [ -d "$APP_DIR" ]; then echo pass; else echo fail; fi } check_backup() { # 备份新鲜度:BACKUP_DIR 里最近的 cloud-backup-*.sql 在 36 小时内 # (每天一次,36 小时容忍一次失败 + 时区/夏令时抖动)。目录或文件 # 缺失一律 fail——备份制度断了必须在 monitor 里可见,不能静默。 BACKUP_DIR=${MONITOR_BACKUP_DIR:-/var/lib/codex-app-server/backups} NEWEST=$(ls -t "$BACKUP_DIR"/cloud-backup-*.sql 2>/dev/null | head -1 || true) if [ -z "$NEWEST" ]; then echo fail; return; fi MTIME=$(stat -c %Y "$NEWEST" 2>/dev/null || true) case "$MTIME" in ''|*[!0-9]*) echo fail; return ;; esac NOW=$(date +%s) AGE=$((NOW - MTIME)) if [ "$AGE" -lt 129600 ]; then echo pass; else echo fail; fi } run_checks() { S=$(check_server); W=$(check_worker); H=$(check_health) P=$(check_postgres); D=$(check_docker_postgres) K=$(check_disk); A=$(check_app_dir); B=$(check_backup) OVERALL=pass for v in "$S" "$W" "$H" "$P" "$D" "$K" "$A" "$B"; do if [ "$v" != "pass" ]; then OVERALL=fail; break; fi done printf '{"overall": "%s", "checks": {"server": "%s", "worker": "%s", "health": "%s", "postgres": "%s", "docker_postgres": "%s", "disk": "%s", "app_dir": "%s", "backup": "%s"}}\n' \ "$OVERALL" "$S" "$W" "$H" "$P" "$D" "$K" "$A" "$B" if [ "$OVERALL" = "pass" ]; then return 0; else return 1; fi } if [ "${1:-}" = "--self-test" ]; then # 起一个 canned health server:只返回固定健康 JSON,不读任何真实服务。 SELF_PORT=18999 python3 - "$SELF_PORT" <<'PY' & import http.server import json import sys class H(http.server.BaseHTTPRequestHandler): def do_GET(self): body = json.dumps({ "ok": True, "service": "cloud-control-plane", "backend": "postgres", "db_ok": True, "worker_last_seen_seconds_ago": 1.0, "worker_ok": True, }).encode() self.send_response(200) self.send_header("Content-Type", "application/json") self.send_header("Content-Length", str(len(body))) self.end_headers() self.wfile.write(body) def log_message(self, *a): pass http.server.HTTPServer(("127.0.0.1", int(sys.argv[1])), H).serve_forever() PY SERVER_PID=$! trap 'kill $SERVER_PID 2>/dev/null || true' EXIT sleep 1 MONITOR_HEALTH_URL="http://127.0.0.1:$SELF_PORT/health" \ HEALTH_URL="http://127.0.0.1:$SELF_PORT/health" \ sh "$0" > /tmp/monitor-self-test.json 2>/dev/null || true # 注意:上面那行是跑完整检查,health 以外项在非主机上 fail 是正常的; # self-test 只断言两件事:输出是合法 JSON,且 health 解析为 pass。 if python3 -c ' import json import sys with open("/tmp/monitor-self-test.json") as f: d = json.load(f) sys.exit(0 if d["checks"]["health"] == "pass" else 1) '; then echo "SELF-TEST PASS:输出是合法 JSON 且 health 解析为 pass(其它项在非主机上 fail 属正常,不影响 self-test)" cat /tmp/monitor-self-test.json exit 0 else echo "SELF-TEST FAIL:health 解析未通过或输出不是合法 JSON" >&2 cat /tmp/monitor-self-test.json >&2 || true exit 1 fi fi run_checks