#!/usr/bin/env python3 """T44 Cron Health Check - detects silent cron failures. Per v4.0 §T44. Runs every 5 minutes, checks that all crons have logged success in their expected window. Reports failures to: 1. /var/log/cron-health.log (local) 2. rmi-backend /api/v1/admin/alerts/critical (for paging) 3. rmi-backend /api/v1/_test/glitchtip (when GlitchTip is up) Run as a cron itself: */5 * * * * /root/scripts/cron_health_check.py """ from __future__ import annotations import json import os import re import subprocess import sys from datetime import UTC, datetime from pathlib import Path from urllib.parse import urljoin # ── Config ───────────────────────────────────────────────────────── BACKEND_URL = os.getenv("RMI_BACKEND_URL", "http://127.0.0.1:8000") LOG_FILE = "/var/log/cron-health.log" CRONTAB_FILE = "/tmp/cron-inventory.txt" # Each cron: (name, schedule, expected_window_minutes, log_file_pattern) # expected_window = how often it should fire (e.g. */15 → 15 min window) CRONS = [ # (name, schedule, window_min, log_file) ("security-state", "* * * * *", 5, "/var/log/security-state.log"), ("vault-sync", "*/30 * * * *", 35, "/root/.hermes/logs/vault-sync.log"), ("cache-warmer", "*/4 * * * *", 8, "/tmp/cache-warmer.log"), ("ingest-runner", "*/15 * * * *", 20, "/var/log/ingest_cron.log"), ("wallet-ingest", "*/30 * * * *", 35, "/var/log/wallet_ingest.log"), ("master-ingest", "0 * * * *", 65, "/var/log/master_ingest.log"), ("neo4j-sync", "0 */6 * * *", 370, "/var/log/neo4j_sync.log"), ("reembed-rag", "0 3 * * *", 1500, "/var/log/reembed_cron.log"), ("rag-rss", "*/15 * * * *", 20, "/var/log/rag_ingest_rss.log"), ("rag-market", "0 * * * *", 65, "/var/log/rag_ingest_market.log"), ("rag-threat", "0 */6 * * *", 370, "/var/log/rag_ingest_threat.log"), ("rag-clickhouse", "*/30 * * * *", 35, "/var/log/rag_ingest_ch.log"), ("mbal-labels", "0 2 * * *", 1500, "/var/log/rag_mbal.log"), ("daily-brief", "30 6 * * *", 1500, "/var/log/daily_brief.log"), ] def log(msg: str) -> None: """Append to local log file.""" ts = datetime.now(UTC).isoformat() with open(LOG_FILE, "a") as f: f.write(f"{ts} {msg}\n") print(f"{ts} {msg}") def check_log_for_errors(log_file: str) -> tuple[bool, str]: """Check a cron log for known error patterns. Returns (healthy, last_error). """ if not Path(log_file).exists(): return False, "log file missing" # Read last 100 lines try: result = subprocess.run( ["tail", "-n", "100", log_file], capture_output=True, text=True, timeout=5, ) except Exception as e: return False, f"log read fail: {e}" lines = result.stdout.splitlines() if not lines: return False, "empty log" # Look for known error patterns error_patterns = [ r"file not found", r"No such file", r"command not found", r"ImportError", r"ModuleNotFoundError", r"connection refused", r"Connection refused", r"Permission denied", r"No module named", ] last_error = "" for line in lines[-20:]: # check last 20 lines for pat in error_patterns: if re.search(pat, line, re.IGNORECASE): last_error = line[:200] return False, last_error # Check for recent activity (log has content from last N hours) if "ImportError" in result.stdout or "ModuleNotFoundError" in result.stdout: return False, "Python import error" return True, "" def check_all_crons() -> list[dict]: """Run all checks. Return list of (name, healthy, error).""" results = [] for name, sched, window, log_file in CRONS: healthy, error = check_log_for_errors(log_file) results.append({ "name": name, "schedule": sched, "window_min": window, "log_file": log_file, "healthy": healthy, "error": error, "checked_at": datetime.now(UTC).isoformat(), }) return results def report_to_backend(stale: list[dict]) -> bool: """Post stale crons to rmi-backend alerts.""" if not stale: return True try: import httpx url = urljoin(BACKEND_URL, "/api/v1/admin/alerts/critical") payload = { "title": f"{len(stale)} cron failures", "description": f"Failed crons: {', '.join(c['name'] for c in stale)}", "severity": "warning", "evidence": {"stale_crons": stale}, } with httpx.Client(timeout=10) as c: r = c.post(url, json=payload) if r.status_code < 300: log(f"reported_to_backend status={r.status_code}") return True log(f"report_failed status={r.status_code} body={r.text[:200]}") return False except Exception as e: log(f"report_exception err={e}") return False def main(): log("=== cron_health_check start ===") results = check_all_crons() stale = [r for r in results if not r["healthy"]] summary = { "total": len(results), "healthy": len(results) - len(stale), "stale": len(stale), "stale_names": [r["name"] for r in stale], } log(f"summary: {json.dumps(summary)}") for r in stale: log(f" STALE: {r['name']} - {r['error'][:120]}") if stale: report_to_backend(stale) log("=== cron_health_check end ===") return 0 if not stale else 1 if __name__ == "__main__": sys.exit(main())