205 lines
12 KiB
Python
205 lines
12 KiB
Python
#!/usr/bin/env python3
|
||
"""Read-only health/reboot observer with durable heartbeats; never restarts the box."""
|
||
import argparse
|
||
from datetime import datetime
|
||
import hashlib
|
||
import json
|
||
import os
|
||
from pathlib import Path
|
||
import re
|
||
import signal
|
||
import subprocess
|
||
import tempfile
|
||
import threading
|
||
|
||
ORDERLY = re.compile(r"systemd-shutdown|Reached target.*(?:System Reboot|System Power Off)|systemd\[1\]: (?:Rebooting|Powering off)|reboot: Restarting system|reboot: Power down", re.I)
|
||
FAILURE = re.compile(r"Kernel panic - not syncing|Oops:|BUG: unable to handle|watchdog:.*(?:hard LOCKUP|soft lockup)|Out of memory: Killed process|oom-kill:|thermal.*critical", re.I)
|
||
EVIDENCE = re.compile(r"panic|oops|watchdog|oom|out of memory|thermal|overheat|shutdown|reboot|crash|segfault|I/O error|ext4.*error|mmc.*(?:error|timeout)|voltage|hung task", re.I)
|
||
|
||
|
||
def atomic_write(path, text):
|
||
path.parent.mkdir(parents=True, exist_ok=True)
|
||
name = None
|
||
try:
|
||
with tempfile.NamedTemporaryFile(mode="w", encoding="utf-8", dir=path.parent, delete=False) as f:
|
||
name = f.name
|
||
os.chmod(name, 0o600)
|
||
f.write(text); f.flush(); os.fsync(f.fileno())
|
||
os.replace(name, path); name = None
|
||
if os.name == "posix":
|
||
fd = os.open(path.parent, os.O_RDONLY | os.O_DIRECTORY)
|
||
try: os.fsync(fd)
|
||
finally: os.close(fd)
|
||
finally:
|
||
if name: os.unlink(name)
|
||
|
||
|
||
def command(args, timeout=4, limit=24576):
|
||
try:
|
||
r = subprocess.run(args, capture_output=True, text=True, errors="replace", timeout=timeout,
|
||
env={**os.environ, "LC_ALL": "C", "SYSTEMD_COLORS": "0"})
|
||
return {"code": r.returncode, "text": r.stdout[-limit:], "error": r.stderr[-1024:]}
|
||
except (OSError, subprocess.TimeoutExpired) as e:
|
||
return {"code": -1, "text": "", "error": str(e)[:1024]}
|
||
|
||
|
||
def read(path, default=""):
|
||
try: return Path(path).read_text().strip()
|
||
except (OSError, ValueError): return default
|
||
|
||
|
||
def classify(evidence):
|
||
text = "\n".join(evidence.get(k, {}).get("text", "") for k in ("tail", "warnings", "pstore_new"))
|
||
if FAILURE.search(text): return "发现内核异常/OOM等线索,不能单独认定为重启原因"
|
||
if ORDERLY.search(text): return "发现正常关机或重启流程记录,发起者需结合原始日志核对"
|
||
return "重启原因未确认:无充分关机/故障证据,不能区分断电、硬复位等情况"
|
||
|
||
|
||
def transition(previous, boot_id, now, evidence=None):
|
||
if previous is not None and (previous.get("version") != 1 or type(previous.get("reboots")) is not int):
|
||
raise ValueError("Unsupported/corrupt saved state; preserve it for diagnosis")
|
||
state = dict(previous or {"version": 1, "reboots": 0, "started_at": now, "history": []})
|
||
old = state.get("boot_id")
|
||
event = "boot_baseline" if not old else "observer_restart_same_boot"
|
||
if old and old != boot_id:
|
||
event = "boot_change"; state["reboots"] += 1
|
||
state["history"] = (state.get("history", []) + [{"previous_boot_id": old, "boot_id": boot_id,
|
||
"previous_last_seen": state.get("last_seen"), "previous_uptime_seconds": state.get("uptime_seconds"),
|
||
"detected_at": now, "assessment": classify(evidence or {})}])[-32:]
|
||
state.update(boot_id=boot_id, observer_started_at=now)
|
||
return state, event
|
||
|
||
|
||
class Observer:
|
||
def __init__(self, logs, state_dir):
|
||
self.logs, self.state_dir = logs, state_dir
|
||
logs.mkdir(parents=True, exist_ok=True); state_dir.mkdir(parents=True, exist_ok=True)
|
||
self.state_file = state_dir / "overnight-state.json"
|
||
self.state = None
|
||
|
||
def log(self, event, **values):
|
||
now = datetime.now().astimezone()
|
||
path = self.logs / ("overnight-health-" + now.strftime("%Y%m%d") + ".log")
|
||
if path.exists() and path.stat().st_size > 8*1024*1024:
|
||
for i in (2, 1):
|
||
source = path.with_suffix(path.suffix + "." + str(i))
|
||
if source.exists(): os.replace(source, path.with_suffix(path.suffix + "." + str(i+1)))
|
||
os.replace(path, path.with_suffix(path.suffix + ".1"))
|
||
record = dict(time=now.isoformat(timespec="seconds"), event=event, **values)
|
||
with path.open("a", encoding="utf-8") as f:
|
||
f.write(json.dumps(record, ensure_ascii=False) + "\n"); f.flush(); os.fsync(f.fileno())
|
||
# Bound only our own logs; never touch kiosk/stream/application logs.
|
||
days = sorted(self.logs.glob("overnight-health-????????.log"))
|
||
for old in days[:-7]:
|
||
if old == path: continue
|
||
old.unlink(missing_ok=True)
|
||
for i in (1, 2, 3): old.with_suffix(old.suffix + "." + str(i)).unlink(missing_ok=True)
|
||
|
||
def prior_evidence(self, boot_id):
|
||
# Boot IDs remain meaningful even when RTC/NTP changes wall-clock time.
|
||
if not re.fullmatch(r"[0-9a-f-]{32,36}", boot_id):
|
||
return {"tail": {"text": "", "error": "invalid saved boot ID"}}
|
||
base = ["journalctl", "--no-pager", "-b", boot_id, "-o", "short-monotonic"]
|
||
return {"tail": command(base+["-n", "100"]), "warnings": command(base+["-p", "warning", "-n", "120"]),
|
||
"last": command(["last", "-x", "-n", "10"])}
|
||
|
||
def start(self):
|
||
boot_id = read("/proc/sys/kernel/random/boot_id")
|
||
if not re.fullmatch(r"[0-9a-f-]{36}", boot_id): raise ValueError("Cannot read kernel boot ID")
|
||
previous = json.loads(self.state_file.read_text()) if self.state_file.exists() else None
|
||
try:
|
||
with Path('/var/lib/vdi-observer/pstore.json').open('r', encoding='utf-8') as f:
|
||
pstore = json.loads(f.read(1024*1024))
|
||
except (OSError, ValueError) as e: pstore = {"read_error": str(e)}
|
||
# Compare contents, not capture time; an old ramoops archive is not a new crash.
|
||
texts = {root+"/"+name: entry["text"] for root, files in pstore.get("sources", {}).items()
|
||
for name, entry in files.items() if isinstance(entry, dict) and "text" in entry}
|
||
digest = hashlib.sha256(json.dumps(texts, sort_keys=True).encode()).hexdigest()
|
||
evidence = self.prior_evidence(previous["boot_id"]) if previous and previous.get("boot_id") != boot_id else None
|
||
if evidence is not None and previous.get("pstore_digest") and previous["pstore_digest"] != digest:
|
||
evidence["pstore_new"] = {"text": "\n".join(texts.values()), "note": "pstore contents changed since previous heartbeat; cause still needs review"}
|
||
self.state, event = transition(previous, boot_id, datetime.now().astimezone().isoformat(timespec="seconds"), evidence)
|
||
self.state["pstore_digest"] = digest
|
||
self.log(event, boot_id=boot_id, reboots=self.state["reboots"], previous_last_seen=(previous or {}).get("last_seen"), evidence=evidence)
|
||
if evidence: self.log("restart_assessment", assessment=self.state["history"][-1]["assessment"])
|
||
self.log("boot_evidence", kernel=os.uname().release,
|
||
journal=command(["journalctl", "--list-boots", "--no-pager", "-n", "8"]), pstore=pstore,
|
||
watchdog_bootstatus={str(p): read(p) for p in Path("/sys/class/watchdog").glob("watchdog*/bootstatus")})
|
||
|
||
def sample(self):
|
||
uptime = float(read("/proc/uptime").split()[0]); now = datetime.now().astimezone()
|
||
processes = []
|
||
for path in Path("/proc").glob("[0-9]*/cmdline"):
|
||
try:
|
||
args = path.read_bytes().decode(errors="replace").strip("\0").split("\0"); exe = Path(args[0]).name
|
||
if exe not in ("moonlight", "kiosk-shell", "vdi-kiosk-monitor"): continue
|
||
item = {"pid": int(path.parent.name), "name": exe}
|
||
if exe == "moonlight" and "stream" in args:
|
||
i = args.index("stream"); item["host"] = args[i+1] if i+1 < len(args) else ""
|
||
for flag in ("--resolution", "--fps"):
|
||
if flag in args and args.index(flag)+1 < len(args): item[flag[2:]] = args[args.index(flag)+1]
|
||
processes.append(item)
|
||
except (OSError, ValueError, IndexError): continue
|
||
temps = {p.parent.name: int(read(p, "0"))/1000 for p in Path("/sys/class/thermal").glob("thermal_zone*/temp")}
|
||
memory = {s.split(":")[0]: s.split(":")[1].strip() for s in read("/proc/meminfo").splitlines() if s.startswith(("MemAvailable:", "MemTotal:", "SwapFree:"))}
|
||
display = command(["env", "DISPLAY=:0", "XAUTHORITY="+str(Path.home()/".Xauthority"), "xrandr", "--current"], timeout=3)
|
||
display["text"] = "\n".join(s for s in display["text"].splitlines() if s.startswith("Screen ") or " connected" in s or " disconnected" in s)
|
||
network = {p.name: {"operstate": read(p/"operstate"), "rx_errors": read(p/"statistics/rx_errors"), "tx_errors": read(p/"statistics/tx_errors")}
|
||
for p in Path("/sys/class/net").iterdir() if p.name != "lo"}
|
||
if self.state.get("last_seen") and self.state.get("sample_boot_id") == self.state["boot_id"]:
|
||
wall = now.timestamp()-self.state.get("last_wall_seconds", now.timestamp())
|
||
mono = uptime-self.state.get("uptime_seconds", uptime)
|
||
if abs(wall-mono) > 120: self.log("clock_adjustment", wall_delta=wall, uptime_delta=mono)
|
||
fs = os.statvfs("/")
|
||
values = dict(boot_id=self.state["boot_id"], reboots=self.state["reboots"], uptime_seconds=uptime,
|
||
load=read("/proc/loadavg").split()[:3], memory=memory, temperatures_c=temps,
|
||
processes=processes, display=display, network=network, root_available_mb=fs.f_bavail*fs.f_frsize//(1024*1024))
|
||
self.log("sample", **values)
|
||
self.state.update(last_seen=now.isoformat(timespec="seconds"), last_wall_seconds=now.timestamp(), uptime_seconds=uptime,
|
||
sample_boot_id=self.state["boot_id"], last_sample=values)
|
||
self.save()
|
||
|
||
def save(self):
|
||
atomic_write(self.state_file, json.dumps(self.state, ensure_ascii=False, indent=2)+"\n")
|
||
history = self.state.get("history", [])
|
||
lines = ["盒子过夜观察汇总", "记录启用时间:"+self.state["started_at"], "最近记录时间:"+self.state.get("last_seen", ""),
|
||
"记录启用后重启次数:"+str(self.state["reboots"]), "当前启动编号:"+self.state["boot_id"],
|
||
"当前开机时长(秒):"+str(self.state.get("uptime_seconds", "")),
|
||
"最近一次重启判断:"+(history[-1]["assessment"] if history else "观察期间尚未发现重启"),
|
||
"详细数据见同目录 overnight-health-YYYYMMDD.log;每分钟更新。",
|
||
"服务重启不计为盒子重启;无故障证据时不推断具体断电原因。"]
|
||
atomic_write(self.logs/"overnight-summary.txt", "\n".join(lines)+"\n")
|
||
|
||
|
||
def main():
|
||
parser = argparse.ArgumentParser(description=__doc__)
|
||
parser.add_argument("--once", action="store_true"); parser.add_argument("--report", action="store_true")
|
||
args = parser.parse_args()
|
||
logs = Path(os.environ.get("VDI_OBSERVER_LOG_DIR", str(Path.home()/"vdi-terminal/logs")))
|
||
state_dir = Path(os.environ.get("VDI_OBSERVER_STATE_DIR", str(Path.home()/"vdi-terminal/state")))
|
||
if args.report: print((logs/"overnight-summary.txt").read_text()); return
|
||
interval = int(os.environ.get("VDI_OBSERVER_INTERVAL", "60"))
|
||
if not 10 <= interval <= 3600: parser.error("Interval must be 10..3600 seconds")
|
||
os.umask(0o077); observer = Observer(logs, state_dir)
|
||
import fcntl
|
||
with (state_dir/"overnight-observer.lock").open("w") as lock:
|
||
fcntl.flock(lock, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
||
stopped = threading.Event(); stop_signal = []
|
||
def on_signal(signum, frame): stop_signal.append(signum); stopped.set()
|
||
signal.signal(signal.SIGTERM, on_signal); signal.signal(signal.SIGINT, on_signal)
|
||
observer.start(); observer.sample()
|
||
if args.once: return
|
||
ticks = 0
|
||
while not stopped.wait(interval):
|
||
observer.sample(); ticks += 1
|
||
if ticks % max(1, 600//interval) == 0:
|
||
r = command(["journalctl", "-k", "-b", "0", "-p", "warning", "-n", "50", "--no-pager", "-o", "short-monotonic"])
|
||
r["text"] = "\n".join(s for s in r["text"].splitlines() if EVIDENCE.search(s))
|
||
observer.log("kernel_warning_clues", **r)
|
||
# SIGTERM may just be a service stop. Never call it an OS reboot.
|
||
observer.log("observer_stop", signal=stop_signal, boot_id=observer.state["boot_id"])
|
||
observer.state["observer_stop_signal"] = stop_signal; observer.save()
|
||
|
||
|
||
if __name__ == "__main__": main()
|