Files
host 64995fafab P2-ops: migration alignment, deploy test list, monitor alerts/single-host, backup/rollback closure, Caddy template
- migrations: MIGRATION_TABLES now equals POSTGRES_SCHEMA tables
  (uploads, task_attachments, task_followups, environments added);
  SEQUENCE_TABLES matches IDENTITY tables; task_followups
  sender_task_id column ensured before insert; UNMIGRATED_TABLES
  and migration_coverage() report tables with no PG schema instead
  of silently skipping them; CLI prints the coverage report.
- deploy: DEFAULT_TEST_SUITES gains deploy/tests (discover -s only,
  never package path); regression tests pin the default list.
- monitor: MONITOR_SINGLE_HOST=1 makes the server check single-host;
  default stays dual to avoid silent downgrade; server_mode is in
  the JSON. Optional fail-only alert outlet (webhook or mail,
  default off, alert body is pass/fail JSON only, no secrets).
- backup/rollback: README and restore-drill document what is
  recoverable (DB, code) vs not (worktrees, traces, uploads, host
  configs); rollback.sh shares the server-unit list and dual-port
  health success standard with host_deploy.sh (sync test guards it).
- public entry: Caddyfile uses native {$DOMAIN}/{$ACME_EMAIL}
  variables plus a caddy.service template; README documents the
  steps and the current BT-nginx production status.
2026-10-27 16:37:28 +00:00

259 lines
9.9 KiB
Bash
Executable File
Raw Permalink 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.
#!/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 通过误读成“主机健康”。
#
# 单机模式(P2-ops):默认 server 检查要求 server-1 与 server-2
# 双 active(HA 阶段 2),单机主机上 server-2 不存在会恒 fail,
# 恒 fail 会掩盖真故障。确认是单机部署时设 MONITOR_SINGLE_HOST=1,
# server 检查只查 server-1;双机主机上绝不要设它(设了就是
# 静默降级,冗余丢了也看不见)。输出 JSON 里 server_mode 字段
# 写明本次用的是 single 还是 dual,降级可见。
#
# 告警出口(P2-ops,默认关闭):只在 overall=fail 时发一次,
# pass 时不发;故障持续期间 timer 每 5 分钟跑一次就会重发,
# 接收端需自行去重(本机不做去重状态文件,免得状态文件本身
# 成为新的故障点)。二选一,同时配了只用 webhook:
# MONITOR_ALERT_URL=https://example.invalid/hook # webhook POST JSON
# MONITOR_ALERT_MAILTO=ops@example.invalid # mail 命令发邮件
# 告警内容只有 JSON 检查结果(pass/fail 与 server_mode),
# 不含 health 正文、不含任何环境变量、不含密钥;URL 与收件人
# 只走环境变量,不写进脚本、不进日志。
set -eu
SERVER_MODE=dual
if [ "${MONITOR_SINGLE_HOST:-0}" = "1" ]; then SERVER_MODE=single; fi
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() {
# 默认双机:两台 server 都必须 active,任一挂了都算 fail
# (单台 active 时 API 还能用,但冗余已丢,必须可见)。
# MONITOR_SINGLE_HOST=1 时只查 server-1(单机部署专用,
# 双机主机上设它等于静默降级,见文件头注释)。
if [ "$SERVER_MODE" = "single" ]; then
if systemctl is-active codex-cloud-server >/dev/null 2>&1; then
echo pass
else
echo fail
fi
return
fi
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
LAST_JSON=$(printf '{"overall": "%s", "server_mode": "%s", "checks": {"server": "%s", "worker": "%s", "health": "%s", "postgres": "%s", "docker_postgres": "%s", "disk": "%s", "app_dir": "%s", "backup": "%s"}}' \
"$OVERALL" "$SERVER_MODE" "$S" "$W" "$H" "$P" "$D" "$K" "$A" "$B")
printf '%s\n' "$LAST_JSON"
if [ "$OVERALL" = "pass" ]; then return 0; else return 1; fi
}
send_alert() {
# 只在 fail 时由主流程调用。内容就是 LAST_JSON(pass/fail
# 与 server_mode),绝不拼 health 正文、环境变量或密钥。
if [ -n "${MONITOR_ALERT_URL:-}" ]; then
if [ -n "${MONITOR_ALERT_MAILTO:-}" ]; then
echo "monitor: webhook 与邮件同时配置,只用 webhook" >&2
fi
curl -s --max-time 10 -X POST -H 'Content-Type: application/json' \
-d "$LAST_JSON" "$MONITOR_ALERT_URL" >/dev/null 2>&1 \
|| echo "monitor: webhook 告警发送失败" >&2
return 0
fi
if [ -n "${MONITOR_ALERT_MAILTO:-}" ]; then
if command -v mail >/dev/null 2>&1; then
printf '%s\n' "$LAST_JSON" \
| mail -s "codex monitor FAIL" "$MONITOR_ALERT_MAILTO" \
2>/dev/null \
|| echo "monitor: 邮件告警发送失败" >&2
else
echo "monitor: 已配 MAILTO 但本机无 mail 命令,告警未发出" >&2
fi
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
# 内层跑完整检查时清掉告警出口:self-test 在非主机上
# 其它项必然 fail,不清会在配了告警的环境里误发一条。
MONITOR_HEALTH_URL="http://127.0.0.1:$SELF_PORT/health" \
HEALTH_URL="http://127.0.0.1:$SELF_PORT/health" \
MONITOR_ALERT_URL= MONITOR_ALERT_MAILTO= \
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
if run_checks; then
exit 0
fi
# 只有 fail 走到这里:默认不配出口时 send_alert 是空操作,
# 不会因为没配告警就多报错;配了出口则发一条 fail 告警。
send_alert
exit 1