Initial commit: server-monitor v1 (Python)

Kompletter Neustart als Python-Projekt.
Portiert von zfs-monitor Bash v3, saubere Architektur:
- config.py: TOML-Config mit dataclasses
- state.py: zentrales State-Management (JSON statt Einzeldateien)
- notify.py: Telegram + Persistent Alerting als wiederverwendbare Klasse
- checks/zfs.py: ZFS Pool-Health, I/O-Fehler, Kapazität
- checks/smart.py: SATA + NVMe SMART-Monitoring
- checks/nut.py: NUT/USV-Monitoring (APC Back-UPS RS 1500G)
This commit is contained in:
2026-04-12 04:15:02 +02:00
commit c756195530
14 changed files with 1096 additions and 0 deletions
View File
+38
View File
@@ -0,0 +1,38 @@
from __future__ import annotations
import logging
import subprocess
from abc import ABC, abstractmethod
from config import Config
from notify import Alerter
from state import State
class Check(ABC):
def __init__(self, config: Config, state: State, alerter: Alerter) -> None:
self.config = config
self.state = state
self.alerter = alerter
self.log = logging.getLogger(self.__class__.__name__)
@abstractmethod
def run(self) -> None:
pass
def _run(self, cmd: list[str], timeout: int) -> str | None:
"""Run command, return stdout (even on non-zero exit) or None on error."""
try:
result = subprocess.run(
cmd,
capture_output=True,
text=True,
timeout=timeout,
)
return result.stdout
except subprocess.TimeoutExpired:
self.log.warning("Timeout: %s", " ".join(cmd))
return None
except (FileNotFoundError, OSError) as e:
self.log.warning("Fehler bei '%s': %s", cmd[0], e)
return None
+169
View File
@@ -0,0 +1,169 @@
from __future__ import annotations
from checks.base import Check
from state import make_key
class NutCheck(Check):
def run(self) -> None:
for ups in self.config.nut.ups:
self.log.info("--- USV: %s ---", ups)
self._check_ups(ups)
def _check_ups(self, ups_name: str) -> None:
key_conn = make_key("nut_conn", ups_name)
key_ob = make_key("nut_ob", ups_name)
key_rb = make_key("nut_rb", ups_name)
key_batt = make_key("nut_batt", ups_name)
key_load = make_key("nut_load", ups_name)
key_runtime = make_key("nut_rt", ups_name)
raw = self._run(["upsc", ups_name], timeout=self.config.nut.timeout)
if not raw or raw.strip() == "":
if self.alerter.should_alert(key_conn, "WARNING"):
self.alerter.send_alert(
"WARNING",
f"USV nicht erreichbar: {ups_name}",
f"Keine Verbindung zu NUT-USV <code>{ups_name}</code>!\n\n"
f"Läuft der Dienst? → <code>systemctl status nut-server</code>\n"
f"<code>upsc {ups_name}</code>",
key_conn,
)
return
else:
if self.alerter.clear_alert(key_conn):
self.alerter.send_alert(
"INFO",
f"USV wieder erreichbar: {ups_name}",
f"NUT-USV <code>{ups_name}</code> ist wieder erreichbar ✅",
)
# Werte parsen
values: dict[str, str] = {}
for line in raw.splitlines():
if ": " in line:
k, _, v = line.partition(": ")
values[k.strip()] = v.strip()
status = values.get("ups.status", "")
batt_charge = _to_int(values.get("battery.charge", "100"))
batt_runtime = _to_int(values.get("battery.runtime", "9999"))
ups_load = _to_int(values.get("ups.load", "0"))
# ── On Battery ───────────────────────────────────────
if "OB" in status:
runtime_min = batt_runtime // 60
lb_hint = "\n⚠️ <b>LOW BATTERY Shutdown droht!</b>" if "LB" in status else ""
if self.alerter.should_alert(key_ob, "CRITICAL"):
self.alerter.send_alert(
"CRITICAL",
f"USV {ups_name}: STROMAUSFALL!",
f"<b>Netzstrom ausgefallen!</b> USV läuft auf Batterie.{lb_hint}\n\n"
f"🔋 Akku: <b>{batt_charge}%</b>\n"
f"⏱️ Restlaufzeit: <b>{runtime_min} min</b>\n"
f"⚡ Last: <b>{ups_load}%</b>\n\n"
f"<code>upsc {ups_name}</code>",
key_ob,
)
# Restlaufzeit kritisch
if batt_runtime < self.config.nut.runtime_crit_sec:
if self.alerter.should_alert(key_runtime, "CRITICAL"):
self.alerter.send_alert(
"CRITICAL",
f"USV {ups_name}: Restlaufzeit kritisch!",
f"USV <code>{ups_name}</code>: Nur noch <b>{runtime_min} min</b> Restlaufzeit!\n\n"
f"System sofort herunterfahren:\n"
f"<code>upsmon -c fsd</code>",
key_runtime,
)
else:
if self.alerter.clear_alert(key_ob):
self.alerter.send_alert(
"INFO",
f"USV {ups_name}: Netzstrom wiederhergestellt ✅",
f"Netzstrom wieder vorhanden. USV <code>{ups_name}</code> "
f"zurück im Normalbetrieb.",
)
self.alerter.clear_alert(key_runtime)
# ── Replace Battery ───────────────────────────────────
if "RB" in status:
if self.alerter.should_alert(key_rb, "WARNING"):
self.alerter.send_alert(
"WARNING",
f"USV {ups_name}: Batterie ersetzen!",
f"USV <code>{ups_name}</code> meldet: <b>Batterie muss ersetzt werden!</b>\n\n"
f"Aktueller Ladestand: {batt_charge}%\n"
f"<code>upsc {ups_name}</code>",
key_rb,
)
else:
if self.alerter.clear_alert(key_rb):
self.alerter.send_alert(
"INFO",
f"USV {ups_name}: Batterie-Status OK ✅",
f"USV <code>{ups_name}</code>: Replace-Battery Flag wurde aufgehoben.",
)
# ── Akkustand (nur im Netzbetrieb) ────────────────────
if "OB" not in status:
cfg = self.config.nut
if batt_charge <= cfg.batt_crit_pct:
if self.alerter.should_alert(key_batt, "CRITICAL"):
self.alerter.send_alert(
"CRITICAL",
f"USV {ups_name}: Akkustand kritisch!",
f"USV <code>{ups_name}</code>: Akkustand <b>{batt_charge}%</b> "
f"{cfg.batt_crit_pct}%!\n\nAkku lädt nicht korrekt?",
key_batt,
)
elif batt_charge <= cfg.batt_warn_pct:
if self.alerter.should_alert(key_batt, "WARNING"):
self.alerter.send_alert(
"WARNING",
f"USV {ups_name}: Akkustand niedrig",
f"USV <code>{ups_name}</code>: Akkustand <b>{batt_charge}%</b> "
f"{cfg.batt_warn_pct}%",
key_batt,
)
else:
if self.alerter.clear_alert(key_batt):
self.alerter.send_alert(
"INFO",
f"USV {ups_name}: Akkustand OK ✅",
f"USV <code>{ups_name}</code>: Akkustand wieder bei {batt_charge}% ✅",
)
# ── Last ──────────────────────────────────────────────
if ups_load >= self.config.nut.load_warn_pct:
if self.alerter.should_alert(key_load, "WARNING"):
self.alerter.send_alert(
"WARNING",
f"USV {ups_name}: Hohe Last",
f"USV <code>{ups_name}</code>: Last <b>{ups_load}%</b> "
f"{self.config.nut.load_warn_pct}%",
key_load,
)
else:
if self.alerter.clear_alert(key_load):
self.alerter.send_alert(
"INFO",
f"USV {ups_name}: Last normal ✅",
f"USV <code>{ups_name}</code>: Last wieder unter "
f"{self.config.nut.load_warn_pct}% ({ups_load}%) ✅",
)
self.log.info(
"USV %s: status=%s batt=%d%% runtime=%ds load=%d%%",
ups_name, status, batt_charge, batt_runtime, ups_load,
)
def _to_int(val: str) -> int:
"""Parse int from NUT value, stripping decimals and units."""
try:
return int(float(val.split()[0]))
except (ValueError, IndexError):
return 0
+256
View File
@@ -0,0 +1,256 @@
from __future__ import annotations
import json
import os
import re
from checks.base import Check
from checks.zfs import ZFSCheck, _normalize_device
from state import make_key
class SmartCheck(Check):
def run(self) -> None:
disks = self._collect_disks()
self.log.info("--- SMART: %d Disks ---", len(disks))
for disk in disks:
self._check_disk(disk)
def _collect_disks(self) -> list[str]:
"""Collect all unique disks: pool members + extra_disks from config."""
seen: set[str] = set()
result: list[str] = []
zfs = ZFSCheck(self.config, self.state, self.alerter)
for pool in self.config.zfs.pools:
for disk in zfs.get_pool_disks(pool):
if disk not in seen:
seen.add(disk)
result.append(disk)
for extra in self.config.smart.extra_disks:
dev = _normalize_device(extra)
if os.path.exists(dev) and dev not in seen:
seen.add(dev)
result.append(dev)
return result
def _check_disk(self, disk: str) -> None:
if not os.path.exists(disk):
self.log.warning("SMART: %s nicht vorhanden", disk)
return
if "nvme" in disk:
self._check_nvme(disk)
else:
self._check_sata(disk)
# ── SATA ──────────────────────────────────────────────────
def _check_sata(self, disk: str) -> None:
key_fail = make_key("sm_fail", disk)
key_issues = make_key("sm_iss", disk)
key_delta = make_key("sm_dlt", disk)
raw = self._run(
["smartctl", "-j", "-A", "-H", disk],
timeout=self.config.smart.timeout,
)
if not raw:
self.log.warning("SMART: %s nicht lesbar", disk)
return
try:
d = json.loads(raw)
except json.JSONDecodeError:
self.log.warning("SMART: JSON-Parsing fehlgeschlagen für %s", disk)
return
passed = d.get("smart_status", {}).get("passed")
attrs = {a["id"]: a for a in d.get("ata_smart_attributes", {}).get("table", [])}
def attr_raw(aid: int) -> int:
return attrs.get(aid, {}).get("raw", {}).get("value", 0)
reallocated = attr_raw(5)
pending = attr_raw(197)
uncorr = attr_raw(198)
temp_val = d.get("temperature", {}).get("current")
if temp_val is None:
temp_val = attrs.get(194, attrs.get(190, {})).get("raw", {}).get("value", 0)
temp = int(temp_val) if temp_val else 0
# Overall SMART status
if passed is False:
if self.alerter.should_alert(key_fail, "CRITICAL"):
self.alerter.send_alert(
"CRITICAL",
f"SMART FAILED: {disk}",
f"<b>Disk <code>{disk}</code> meldet SMART FAILED!</b>\n\n"
f"Disk ist kurz vor dem Ausfall sofort ersetzen!\n"
f"<code>smartctl -a {disk}</code>",
key_fail,
)
return
else:
if self.alerter.clear_alert(key_fail):
self.alerter.send_alert(
"INFO",
f"SMART wieder OK: {disk}",
f"Disk <code>{disk}</code> meldet wieder PASSED ✅",
)
# Schwellwert-Checks
cfg = self.config.smart
issues: list[str] = []
if reallocated > cfg.reallocated_max:
issues.append(f"⚠️ Reallocated Sectors: <b>{reallocated}</b> (max {cfg.reallocated_max})")
if pending > cfg.pending_max:
issues.append(f"⚠️ Pending Sectors: <b>{pending}</b>")
if uncorr > cfg.uncorrectable_max:
issues.append(f"⚠️ Uncorrectable Errors: <b>{uncorr}</b>")
if 0 < temp > cfg.temp_max:
issues.append(f"🌡️ Temperatur: <b>{temp}°C</b> (max {cfg.temp_max}°C)")
if issues:
if self.alerter.should_alert(key_issues, "WARNING"):
self.alerter.send_alert(
"WARNING",
f"SMART Warnung: {disk}",
f"Disk <code>{disk}</code>:\n\n"
+ "\n".join(issues)
+ f"\n\n<code>smartctl -a {disk}</code>",
key_issues,
)
else:
if self.alerter.clear_alert(key_issues):
self.alerter.send_alert(
"INFO",
f"SMART OK: {disk}",
f"Disk <code>{disk}</code>: alle Werte wieder im grünen Bereich ✅",
)
# Delta-Erkennung
prev_r: int = self.state.get(f"{key_delta}_r", -1)
prev_p: int = self.state.get(f"{key_delta}_p", -1)
if prev_r >= 0 and reallocated > prev_r:
self.alerter.send_alert(
"WARNING",
f"SMART verschlechtert: {disk}",
f"Disk <code>{disk}</code>: Reallocated Sectors gestiegen!\n"
f"<b>{prev_r}{reallocated}</b>",
)
if prev_p >= 0 and pending > prev_p:
self.alerter.send_alert(
"CRITICAL",
f"SMART verschlechtert: {disk}",
f"Disk <code>{disk}</code>: Pending Sectors gestiegen!\n"
f"<b>{prev_p}{pending}</b>\n\n"
f"⚠️ Scrub + Backup sofort prüfen!",
)
self.state.set(f"{key_delta}_r", reallocated)
self.state.set(f"{key_delta}_p", pending)
self.log.info(
"SATA %s: reallocated=%d pending=%d uncorr=%d temp=%d°C",
disk, reallocated, pending, uncorr, temp,
)
# ── NVMe ──────────────────────────────────────────────────
def _check_nvme(self, disk: str) -> None:
key_crit = make_key("nv_crit", disk)
key_spare = make_key("nv_spare", disk)
key_temp = make_key("nv_temp", disk)
key_media = make_key("nv_med", disk)
raw = self._run(
["smartctl", "-j", "-a", disk],
timeout=self.config.smart.timeout,
)
if not raw:
self.log.warning("SMART NVMe: %s nicht lesbar", disk)
return
try:
d = json.loads(raw)
except json.JSONDecodeError:
self.log.warning("SMART: JSON-Parsing fehlgeschlagen für %s", disk)
return
log_page = d.get("nvme_smart_health_information_log", {})
crit_warn = log_page.get("critical_warning", 0)
media_errors = log_page.get("media_errors", 0)
spare = log_page.get("available_spare", 100)
spare_thresh = log_page.get("available_spare_threshold", 10)
pct_used = log_page.get("percentage_used", 0)
temp = int(d.get("temperature", {}).get("current", 0) or 0)
# Critical Warning Bits
if crit_warn != 0:
if self.alerter.should_alert(key_crit, "CRITICAL"):
self.alerter.send_alert(
"CRITICAL",
f"NVMe Critical Warning: {disk}",
f"NVMe <code>{disk}</code>: Warning Flag <b>0x{crit_warn:02x}</b>\n\n"
f"Bit 0: Spare under threshold\n"
f"Bit 1: Temperatur over limit\n"
f"Bit 2: Reliability degraded\n"
f"Bit 3: Read-Only gesetzt\n"
f"Bit 4: Backup Device Failed\n\n"
f"<code>smartctl -a {disk}</code>",
key_crit,
)
else:
if self.alerter.clear_alert(key_crit):
self.alerter.send_alert(
"INFO",
f"NVMe Warning behoben: {disk}",
f"NVMe <code>{disk}</code>: Critical Warning Flag wieder 0 ✅",
)
# Available Spare
if spare <= spare_thresh:
if self.alerter.should_alert(key_spare, "WARNING"):
self.alerter.send_alert(
"WARNING",
f"NVMe Spare niedrig: {disk}",
f"NVMe <code>{disk}</code>: Available Spare <b>{spare}%</b> "
f"(Threshold: {spare_thresh}%)\n"
f"Percentage Used: {pct_used}%\n\n"
f"NVMe nähert sich dem Lebensende.",
key_spare,
)
else:
self.alerter.clear_alert(key_spare)
# Temperatur
if 0 < temp > self.config.smart.temp_max:
if self.alerter.should_alert(key_temp, "WARNING"):
self.alerter.send_alert(
"WARNING",
f"NVMe Temperatur: {disk}",
f"NVMe <code>{disk}</code>: <b>{temp}°C</b> "
f"(max {self.config.smart.temp_max}°C)",
key_temp,
)
else:
self.alerter.clear_alert(key_temp)
# Media Errors (Delta)
prev_media: int = self.state.get(f"{key_media}_prev", -1)
if prev_media >= 0 and media_errors > prev_media:
self.alerter.send_alert(
"WARNING",
f"NVMe Media Errors gestiegen: {disk}",
f"NVMe <code>{disk}</code>: Media Errors "
f"<b>{prev_media}{media_errors}</b>",
)
self.state.set(f"{key_media}_prev", media_errors)
self.log.info(
"NVMe %s: crit=0x%02x media=%d spare=%d%% pct_used=%d%% temp=%d°C",
disk, crit_warn, media_errors, spare, pct_used, temp,
)
+182
View File
@@ -0,0 +1,182 @@
from __future__ import annotations
import re
from checks.base import Check
from state import make_key
class ZFSCheck(Check):
def run(self) -> None:
for pool in self.config.zfs.pools:
self.log.info("--- Pool: %s ---", pool)
self._check_pool(pool)
def get_pool_disks(self, pool: str) -> list[str]:
"""Return normalized block device paths for all disks in a pool."""
out = self._run(
["zpool", "status", "-P", pool],
timeout=self.config.zfs.timeout,
)
if not out:
return []
disks: list[str] = []
for line in out.splitlines():
m = re.match(r"\s+(/dev/\S+)", line)
if m:
dev = _normalize_device(m.group(1))
if dev not in disks:
disks.append(dev)
return disks
# ── Pool checks ───────────────────────────────────────────
def _check_pool(self, pool: str) -> None:
key_missing = make_key("pool_miss", pool)
key_health = make_key("pool_hlth", pool)
key_errors = make_key("pool_err", pool)
key_cap = make_key("pool_cap", pool)
# Erreichbarkeit
out = self._run(
["zpool", "list", pool],
timeout=self.config.zfs.timeout,
)
if out is None:
if self.alerter.should_alert(key_missing, "CRITICAL"):
self.alerter.send_alert(
"CRITICAL",
f"Pool verschwunden: {pool}",
f"Pool <code>{pool}</code> ist nicht mehr importiert!\n\n"
f"<code>zpool import\nzpool status</code>",
key_missing,
)
return
else:
if self.alerter.clear_alert(key_missing):
self.alerter.send_alert(
"INFO",
f"Pool wieder erreichbar: {pool}",
f"Pool <code>{pool}</code> ist wieder da ✅",
)
# Health
health = (
self._run(
["zpool", "list", "-H", "-o", "health", pool],
timeout=self.config.zfs.timeout,
) or ""
).strip()
if health != "ONLINE":
self.state.set(f"{key_health}_last", health)
if self.alerter.should_alert(key_health, "CRITICAL"):
details = self._pool_status_summary(pool)
self.alerter.send_alert(
"CRITICAL",
f"Pool {pool}: {health}!",
f"Pool <code>{pool}</code> ist <b>{health}</b>!\n\n"
f"<pre>{details}</pre>\n"
f"<code>zpool status -v {pool}</code>",
key_health,
)
else:
self.state.set(f"{key_health}_last", "ONLINE")
if self.alerter.clear_alert(key_health):
self.alerter.send_alert(
"INFO",
f"Pool {pool}: wieder ONLINE ✅",
f"Pool <code>{pool}</code> ist wieder gesund.",
)
# I/O Errors
err_lines = self._parse_io_errors(pool)
if err_lines:
if self.alerter.should_alert(key_errors, "WARNING"):
self.alerter.send_alert(
"WARNING",
f"Pool {pool}: I/O Fehler!",
f"Pool <code>{pool}</code> hat Fehler auf Disks:\n\n"
f"<pre>{err_lines}</pre>\n"
f"→ <code>zpool scrub {pool}</code>",
key_errors,
)
else:
if self.alerter.clear_alert(key_errors):
self.alerter.send_alert(
"INFO",
f"Pool {pool}: I/O Fehler behoben ✅",
f"Keine I/O Fehler mehr in Pool <code>{pool}</code>.",
)
# Kapazität
cap_str = (
self._run(
["zpool", "list", "-H", "-o", "cap", pool],
timeout=self.config.zfs.timeout,
) or "0"
).strip().rstrip("%")
cap = int(cap_str) if cap_str.isdigit() else 0
if cap >= self.config.zfs.crit_pct:
if self.alerter.should_alert(key_cap, "CRITICAL"):
self.alerter.send_alert(
"CRITICAL",
f"Pool {pool}: {cap}% voll!",
f"Pool <code>{pool}</code> ist zu <b>{cap}%</b> voll!\n"
f"Sofort Platz schaffen ZFS braucht freien Raum!",
key_cap,
)
elif cap >= self.config.zfs.warn_pct:
if self.alerter.should_alert(key_cap, "WARNING"):
self.alerter.send_alert(
"WARNING",
f"Pool {pool}: {cap}% voll",
f"Pool <code>{pool}</code> ist zu <b>{cap}%</b> voll.",
key_cap,
)
else:
if self.alerter.clear_alert(key_cap):
self.alerter.send_alert(
"INFO",
f"Pool {pool}: Füllstand OK",
f"Pool <code>{pool}</code> ist wieder unter "
f"{self.config.zfs.warn_pct}% ✅",
)
self.log.info("Pool %s: health=%s cap=%d%%", pool, health, cap)
def _pool_status_summary(self, pool: str) -> str:
out = self._run(["zpool", "status", pool], timeout=self.config.zfs.timeout) or ""
lines = [
l for l in out.splitlines()
if re.search(r"state:|status:|action:|DEGRADED|FAULTED|REMOVED|UNAVAIL", l)
]
return "\n".join(lines[:10])
def _parse_io_errors(self, pool: str) -> str:
out = self._run(
["zpool", "status", "-P", pool],
timeout=self.config.zfs.timeout,
) or ""
lines = []
for line in out.splitlines():
m = re.match(r"\s+(/dev/\S+)\s+\S+\s+(\d+)\s+(\d+)\s+(\d+)", line)
if m:
dev, r, w, c = m.group(1), int(m.group(2)), int(m.group(3)), int(m.group(4))
if r or w or c:
lines.append(f"{dev} R:{r} W:{w} C:{c}")
return "\n".join(lines)
# ── Device Normalisierung ─────────────────────────────────────
def _normalize_device(dev: str) -> str:
import os
resolved = os.path.realpath(dev)
# nvme0n1p1 → nvme0n1
resolved = re.sub(r"(nvme\d+n\d+)p\d+$", r"\1", resolved)
# sda1 → sda
resolved = re.sub(r"(sd[a-z]+)\d+$", r"\1", resolved)
return resolved