Später · 18 Plattengesundheit: Proxmox-Host, NAS über SNMP, NVMe der KI-Box
- kern/platten.py: SMART aus pvesh, smartctl und SNMP in einer Form; rot bei SMART FAILED, kritischer Warnung, aufgebrauchter Reserve, ≥ 90 % verbraucht; gelb bei ≥ 80 %, Hitze, gewachsenen Fehlerzählern (Ausgangslage je Platte, „Zur Kenntnis genommen“ setzt sie neu) - Ausführer: stündlich disks/list + disks/smart im Bericht (nur lesend) - services/homelab/nas_snmp.py: kleiner SNMPv2c-GetBulk-Leser ohne neue Abhängigkeit, QNAP hdTable/diskTable, Zugang 0600, Einstellungen „Verbindung testen und speichern“ - services/platten.py: NVMe der KI-Box über sudo smartctl (smartmontools am 26.09. nachinstalliert) - Wächter beider Rollen, Plattenfeld in beiden Cockpits, Doku Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
5325544d89
commit
904598031a
@@ -97,6 +97,7 @@ def test_stand_von_anfang_an(daten, monkeypatch):
|
||||
"arcane": {"url": "http://192.168.178.28:3552", "schluessel": "fehlt", "echt": False,
|
||||
"echt_quelle": "standard"},
|
||||
"adguard": {"url": "http://192.168.178.100:8080", "zugang": "fehlt", "benutzer": None},
|
||||
"nas_snmp": {"zugang": "fehlt", "adresse": None},
|
||||
"karenz_h": 48,
|
||||
"ausfuehrer": {"verbunden": True, "zuletzt": stand["ausfuehrer"]["zuletzt"], "nur_lesen": False,
|
||||
"sicherung_speicher": None}}
|
||||
|
||||
@@ -222,7 +222,8 @@ print(json.dumps({"pruefungen": [p.__name__ for p in waechter.PRUEFUNGEN], "date
|
||||
""")
|
||||
assert ergebnis["pruefungen"] == ["pruefe_platte", "pruefe_partner", "pruefe_ausfuehrer", "pruefe_gaeste",
|
||||
"pruefe_gast_platten", "pruefe_paketlisten", "pruefe_host", "pruefe_speicher",
|
||||
"pruefe_zertifikate", "pruefe_docker"]
|
||||
"pruefe_zertifikate", "pruefe_docker", "pruefe_platten_gesundheit",
|
||||
"pruefe_netzgeraete"]
|
||||
|
||||
|
||||
def test_homelab_rolle_hat_die_homelab_schnittstellen(tmp_path):
|
||||
|
||||
@@ -0,0 +1,188 @@
|
||||
"""Plattengesundheit (Ausbauplan „Später“, Punkt 18, 26.09.2026): Proxmox-Host (pvesh), KI-Box (smartctl) und NAS
|
||||
(SNMP) in einer Form, gemeldet wird nur, was schlecht ist oder schlechter wird. Die Werte stammen von den echten
|
||||
Platten am 26.09. (PELADN 512GB im Host, YMTC PC41Q-2TB in der Box)."""
|
||||
|
||||
import socket
|
||||
import threading
|
||||
|
||||
import pytest
|
||||
from kern import einstellungen as einstellungen_mod
|
||||
from kern import platten as kp
|
||||
from services.homelab import nas_snmp
|
||||
|
||||
NVME_TEXT = """
|
||||
SMART/Health Information (NVMe Log 0x02, NSID 0xffffffff)
|
||||
Critical Warning: 0x00
|
||||
Temperature: 41 Celsius
|
||||
Available Spare: 100%
|
||||
Available Spare Threshold: 1%
|
||||
Percentage Used: 2%
|
||||
Power On Hours: 9,992
|
||||
Unsafe Shutdowns: 6
|
||||
Media and Data Integrity Errors: 0
|
||||
Error Information Log Entries: 0
|
||||
"""
|
||||
HOST_DISK = {"devpath": "/dev/nvme0n1", "model": "PELADN 512GB", "serial": "2024110610000123", "type": "nvme",
|
||||
"size": 512110190592, "health": "PASSED", "wearout": 98}
|
||||
BOX_JSON = {"model_name": "YMTC PC41Q-2TB-B", "serial_number": "S1", "smart_status": {"passed": True},
|
||||
"user_capacity": {"bytes": 2048408248320}, "temperature": {"current": 35},
|
||||
"power_on_time": {"hours": 2380},
|
||||
"nvme_smart_health_information_log": {"critical_warning": 0, "temperature": 35, "available_spare": 100,
|
||||
"available_spare_threshold": 10, "percentage_used": 0,
|
||||
"media_errors": 0, "power_on_hours": 2380}}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def daten(tmp_path, monkeypatch):
|
||||
monkeypatch.setenv("MC_DATEN_DIR", str(tmp_path))
|
||||
einstellungen_mod.einstellungen.cache_clear()
|
||||
yield tmp_path
|
||||
einstellungen_mod.einstellungen.cache_clear()
|
||||
|
||||
|
||||
def test_host_platte_aus_proxmox():
|
||||
p = kp.aus_proxmox(HOST_DISK, {"health": "PASSED", "type": "text", "text": NVME_TEXT})
|
||||
assert (p["id"], p["name"], p["wo"], p["typ"]) == ("pve:2024110610000123", "PELADN 512GB", "Proxmox-Host /dev/nvme0n1",
|
||||
"nvme")
|
||||
assert (p["gesund"], p["verbraucht"], p["temperatur"], p["reserve"], p["reserve_grenze"], p["stunden"]) == (
|
||||
True, 2, 41, 100, 1, 9992)
|
||||
assert p["fehler"] == {"medien": 0} and kp.bewerten(p, None) == ("ok", [])
|
||||
|
||||
|
||||
def test_sata_attribute():
|
||||
werte = kp.aus_ata_attributen([{"id": 5, "raw": "8"}, {"id": 197, "raw": "0"}, {"id": 194, "raw": "36 (Min/Max 20/45)"},
|
||||
{"id": "x", "raw": "1"}])
|
||||
assert werte == {"fehler": {"umgelagert": 8, "ausstehend": 0}, "temperatur": 36}
|
||||
|
||||
|
||||
def test_box_platte_aus_smartctl():
|
||||
p = kp.aus_smartctl(BOX_JSON, "/dev/nvme0n1", "KI-Box")
|
||||
assert (p["id"], p["wo"], p["typ"], p["verbraucht"], p["temperatur"], p["stunden"]) == (
|
||||
"KI-Box:S1", "KI-Box /dev/nvme0n1", "nvme", 0, 35, 2380)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("aenderung, stufe, satz", [
|
||||
({"gesund": False}, "rot", "meldet sich selbst als defekt (SMART)"),
|
||||
({"warnung": 4}, "rot", "meldet eine kritische Warnung (0x04)"),
|
||||
({"reserve": 9, "reserve_grenze": 10}, "rot", "Ersatzblöcke fast aufgebraucht (9 %)"),
|
||||
({"verbraucht": 85}, "gelb", "zu 85 % verbraucht"),
|
||||
({"verbraucht": 93}, "rot", "zu 93 % verbraucht — Ersatz einplanen"),
|
||||
({"temperatur": 72}, "gelb", "72 °C warm (ab 70 °C zu heiß)"),
|
||||
({"fehler": {"medien": 3}}, "gelb", "Medienfehler: 0 → 3"),
|
||||
({"smart_warnung": True}, "gelb", "SMART meldet eine Warnung"),
|
||||
])
|
||||
def test_bewerten(aenderung, stufe, satz):
|
||||
p = {**kp.aus_smartctl(BOX_JSON, "/dev/nvme0n1", "KI-Box"), **aenderung}
|
||||
assert kp.bewerten(p, {"medien": 0}) == (stufe, [satz])
|
||||
|
||||
|
||||
def test_nur_was_schlechter_wird(daten):
|
||||
alt = {**kp.aus_smartctl(BOX_JSON, "/dev/nvme0n1", "KI-Box"), "fehler": {"medien": 5}}
|
||||
assert kp.bewertet([alt])[0]["stufe"] == "ok" # beim ersten Blick schon da: Ausgangslage
|
||||
schlechter = {**alt, "fehler": {"medien": 7}}
|
||||
(p,) = kp.bewertet([schlechter])
|
||||
assert (p["stufe"], p["saetze"]) == ("gelb", ["Medienfehler: 5 → 7"])
|
||||
assert kp.befund_titel(p) == "Platte YMTC PC41Q-2TB-B (KI-Box /dev/nvme0n1): Medienfehler: 5 → 7"
|
||||
assert kp.hinnehmen(p["id"], [schlechter])["ok"]
|
||||
assert kp.bewertet([schlechter])[0]["stufe"] == "ok"
|
||||
assert not kp.hinnehmen("gibt-es-nicht", [schlechter])["ok"]
|
||||
|
||||
|
||||
# --- NAS über SNMP ----------------------------------------------------------------------------------------------
|
||||
|
||||
def _antwort_pdu(anfrage: bytes, zeilen: list[tuple[str, object]], community: str) -> bytes:
|
||||
"""Eine Response-PDU mit den Werten ab der erfragten OID (wie ein NAS, das GetBulk beantwortet)."""
|
||||
_, nachricht, _ = nas_snmp._lies(anfrage, 0)
|
||||
_, _, pos = nas_snmp._lies(nachricht, 0)
|
||||
_, gemeinschaft, pos = nas_snmp._lies(nachricht, pos)
|
||||
if gemeinschaft.decode() != community:
|
||||
return b"" # falscher Zugangsname: SNMP schweigt
|
||||
_, pdu, _ = nas_snmp._lies(nachricht, pos)
|
||||
_, nr, p = nas_snmp._lies(pdu, 0)
|
||||
for _ in range(2):
|
||||
_, _, p = nas_snmp._lies(pdu, p)
|
||||
_, bindungen, _ = nas_snmp._lies(pdu, p)
|
||||
_, bindung, _ = nas_snmp._lies(bindungen, 0)
|
||||
_, oid, _ = nas_snmp._lies(bindung, 0)
|
||||
ab = nas_snmp._oid_text(oid)
|
||||
|
||||
def schluessel(o: str) -> tuple:
|
||||
return tuple(int(t) for t in o.split("."))
|
||||
folgende = sorted((z for z in zeilen if schluessel(z[0]) > schluessel(ab)), key=lambda z: schluessel(z[0]))[:25]
|
||||
teile = b""
|
||||
for o, w in folgende:
|
||||
wert = nas_snmp._tlv(0x04, w.encode()) if isinstance(w, str) else nas_snmp._int(int(w))
|
||||
teile += nas_snmp._tlv(0x30, nas_snmp._oid(o) + wert)
|
||||
if len(folgende) < 25:
|
||||
teile += nas_snmp._tlv(0x30, nas_snmp._oid(ab if not folgende else folgende[-1][0]) + b"\x82\x00")
|
||||
antwort = nas_snmp._tlv(0xA2, nas_snmp._tlv(0x02, nr) + nas_snmp._int(0) + nas_snmp._int(0)
|
||||
+ nas_snmp._tlv(0x30, teile))
|
||||
return nas_snmp._tlv(0x30, nas_snmp._int(1) + nas_snmp._tlv(0x04, gemeinschaft) + antwort)
|
||||
|
||||
|
||||
HD = nas_snmp.HD_TABELLE
|
||||
QNAP = [(f"{nas_snmp.SYS_DESCR}.0", "Linux TS-453D 5.10.60-qnap"),
|
||||
(f"{HD}.1.1", 1), (f"{HD}.1.2", 2), (f"{HD}.1.3", 3),
|
||||
(f"{HD}.2.1", "HDD1"), (f"{HD}.2.2", "HDD2"), (f"{HD}.2.3", "HDD3"),
|
||||
(f"{HD}.3.1", "34 C/93 F"), (f"{HD}.3.2", "36 C/96 F"), (f"{HD}.3.3", "--"),
|
||||
(f"{HD}.4.1", 0), (f"{HD}.4.2", 0), (f"{HD}.4.3", -5),
|
||||
(f"{HD}.5.1", "WDC WD40EFRX"), (f"{HD}.5.2", "ST4000VN008"), (f"{HD}.5.3", "--"),
|
||||
(f"{HD}.7.1", "GOOD"), (f"{HD}.7.2", "Abnormal"), (f"{HD}.7.3", "--")]
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def nas(monkeypatch):
|
||||
"""Ein nachgestelltes NAS auf einem freien UDP-Port von 127.0.0.1."""
|
||||
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
||||
sock.bind(("127.0.0.1", 0))
|
||||
sock.settimeout(0.2)
|
||||
laeuft = threading.Event()
|
||||
laeuft.set()
|
||||
|
||||
def dienen():
|
||||
while laeuft.is_set():
|
||||
try:
|
||||
anfrage, absender = sock.recvfrom(65535)
|
||||
except (TimeoutError, OSError):
|
||||
continue
|
||||
if antwort := _antwort_pdu(anfrage, QNAP, "geheim-lesen"):
|
||||
sock.sendto(antwort, absender)
|
||||
|
||||
faden = threading.Thread(target=dienen, daemon=True)
|
||||
faden.start()
|
||||
monkeypatch.setattr(nas_snmp, "PORT", sock.getsockname()[1])
|
||||
monkeypatch.setattr(nas_snmp, "ZEITLIMIT_S", 0.5)
|
||||
yield
|
||||
laeuft.clear()
|
||||
faden.join(timeout=2)
|
||||
sock.close()
|
||||
|
||||
|
||||
def test_nas_platten_ueber_snmp(nas):
|
||||
ergebnis = nas_snmp.lesen("127.0.0.1", "geheim-lesen")
|
||||
assert ergebnis["ok"] and ergebnis["system"] == "Linux TS-453D 5.10.60-qnap"
|
||||
platten = ergebnis["platten"]
|
||||
assert [(p["name"], p["wo"], p["gesund"], p["temperatur"]) for p in platten] == [
|
||||
("WDC WD40EFRX", "NAS HDD1", True, 34), ("ST4000VN008", "NAS HDD2", False, 36)] # leerer Schacht fällt weg
|
||||
assert kp.bewerten(platten[1], None)[0] == "rot"
|
||||
|
||||
|
||||
def test_nas_falscher_zugangsname_und_speichern(nas, daten, monkeypatch):
|
||||
monkeypatch.setattr(nas_snmp, "nas_adresse", lambda: "127.0.0.1")
|
||||
falsch = nas_snmp.setzen("public", "")
|
||||
assert not falsch["ok"] and "antwortet nicht über SNMP" in falsch["detail"] and "Nichts gespeichert" in falsch["detail"]
|
||||
assert nas_snmp.zugang() is None
|
||||
richtig = nas_snmp.setzen("geheim-lesen", "")
|
||||
assert richtig == {"ok": True, "text": "Das NAS antwortet (Linux TS-453D 5.10.60-qnap): 2 Platten, davon 1 gesund."}
|
||||
assert nas_snmp.zugang() == {"community": "geheim-lesen", "adresse": None}
|
||||
assert "geheim-lesen" not in str(nas_snmp.stand())
|
||||
assert nas_snmp.zugang_loeschen() and nas_snmp.stand() == {"eingerichtet": False, "ok": False, "platten": [],
|
||||
"detail": None}
|
||||
|
||||
|
||||
def test_disk_tabelle_der_neueren_firmware():
|
||||
d = nas_snmp.DISK_TABELLE
|
||||
werte = [(f"{d}.1.1", 1), (f"{d}.2.1", "Disk 1"), (f"{d}.5.1", "GOOD"), (f"{d}.6.1", 33),
|
||||
(f"{d}.8.1", "WDC WD40EFRX-68N32N0")]
|
||||
(p,) = nas_snmp.platten_aus_disk(werte)
|
||||
assert (p["name"], p["gesund"], p["temperatur"]) == ("WDC WD40EFRX-68N32N0", True, 33)
|
||||
Reference in New Issue
Block a user