feat: add udev serial detection, comprehensive logging, and SAT fallback for SMART health checks on RAID disks

Install udev package and mount /run/udev:/host-run-udev:ro for udev by-id symlink access. Add raid_log_state global to track RAID status changes and hardware_log_once set to prevent duplicate hardware warnings. Update scan_datastore_storage() with info/error logging for scan lifecycle and disk usage. Add _read_command() debug/warning/error logging for all subprocess calls with command text
This commit is contained in:
2026-08-16 12:18:40 +02:00
parent 10eb3a4191
commit 02a5db61be
3 changed files with 51 additions and 3 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
FROM python:3.13-slim FROM python:3.13-slim
RUN apt-get update \ RUN apt-get update \
&& apt-get install -y --no-install-recommends ffmpeg mdadm util-linux smartmontools \ && apt-get install -y --no-install-recommends ffmpeg mdadm util-linux smartmontools udev \
&& rm -rf /var/lib/apt/lists/* && rm -rf /var/lib/apt/lists/*
WORKDIR /app WORKDIR /app
COPY requirements.txt . COPY requirements.txt .
+49 -2
View File
@@ -58,6 +58,8 @@ service_status_lock = threading.Lock()
service_check_executor = ThreadPoolExecutor(max_workers=16) service_check_executor = ThreadPoolExecutor(max_workers=16)
datastore_cache_lock = threading.Lock() datastore_cache_lock = threading.Lock()
datastore_cache = {"stored_at": 0.0, "snapshot": None} datastore_cache = {"stored_at": 0.0, "snapshot": None}
raid_log_state = None
hardware_log_once = set()
LANG_NAMES = {"de":"Deutsch", "deu":"Deutsch", "ger":"Deutsch", "en":"Englisch", "eng":"Englisch", "ja":"Japanisch", "jpn":"Japanisch", "ko":"Koreanisch", "kor":"Koreanisch", "fr":"Französisch", "fra":"Französisch", "fre":"Französisch", "es":"Spanisch", "spa":"Spanisch", "it":"Italienisch", "ita":"Italienisch", "ru":"Russisch", "rus":"Russisch", "zh":"Chinesisch", "zho":"Chinesisch", "chi":"Chinesisch", "und":"Unbekannt", "":"Unbekannt"} LANG_NAMES = {"de":"Deutsch", "deu":"Deutsch", "ger":"Deutsch", "en":"Englisch", "eng":"Englisch", "ja":"Japanisch", "jpn":"Japanisch", "ko":"Koreanisch", "kor":"Koreanisch", "fr":"Französisch", "fra":"Französisch", "fre":"Französisch", "es":"Spanisch", "spa":"Spanisch", "it":"Italienisch", "ita":"Italienisch", "ru":"Russisch", "rus":"Russisch", "zh":"Chinesisch", "zho":"Chinesisch", "chi":"Chinesisch", "und":"Unbekannt", "":"Unbekannt"}
FLAGS = {"Deutsch":"🇩🇪", "Englisch":"🇬🇧", "Japanisch":"🇯🇵", "Koreanisch":"🇰🇷", "Französisch":"🇫🇷", "Spanisch":"🇪🇸", "Italienisch":"🇮🇹", "Russisch":"🇷🇺", "Chinesisch":"🇨🇳", "Unbekannt":""} FLAGS = {"Deutsch":"🇩🇪", "Englisch":"🇬🇧", "Japanisch":"🇯🇵", "Koreanisch":"🇰🇷", "Französisch":"🇫🇷", "Spanisch":"🇪🇸", "Italienisch":"🇮🇹", "Russisch":"🇷🇺", "Chinesisch":"🇨🇳", "Unbekannt":""}
@@ -188,11 +190,13 @@ def _datastore_category(relative_path, category_roots):
def scan_datastore_storage(): def scan_datastore_storage():
root = Path(DATASTORE_PATH) root = Path(DATASTORE_PATH)
log.info("Datastore-Scan gestartet: %s", DATASTORE_PATH)
categories = {key: {"key": key, "label": label, "bytes": 0, "files": 0} for key, label in ( categories = {key: {"key": key, "label": label, "bytes": 0, "files": 0} for key, label in (
("movies", "Filme"), ("series", "Serien"), ("anime", "Anime"), ("movies", "Filme"), ("series", "Serien"), ("anime", "Anime"),
("kdrama", "K-Dramen"), ("downloads", "Downloads"), ("other", "Sonstiges") ("kdrama", "K-Dramen"), ("downloads", "Downloads"), ("other", "Sonstiges")
)} )}
if not root.exists() or not root.is_dir(): if not root.exists() or not root.is_dir():
log.error("Datastore-Pfad nicht verfügbar: %s", DATASTORE_PATH)
return {"error": f"Datastore {DATASTORE_PATH} ist nicht verfügbar.", "path": DATASTORE_PATH, "categories": list(categories.values())} return {"error": f"Datastore {DATASTORE_PATH} ist nicht verfügbar.", "path": DATASTORE_PATH, "categories": list(categories.values())}
category_roots = { category_roots = {
"movies": {"filme", "film", "movies", "movie"}, "movies": {"filme", "film", "movies", "movie"},
@@ -215,6 +219,7 @@ def scan_datastore_storage():
category["files"] += 1 category["files"] += 1
usage = shutil.disk_usage(root) usage = shutil.disk_usage(root)
except OSError as exc: except OSError as exc:
log.exception("Datastore konnte nicht gelesen werden: %s", DATASTORE_PATH)
return {"error": f"Datastore konnte nicht gelesen werden: {exc}", "path": DATASTORE_PATH, "categories": list(categories.values())} return {"error": f"Datastore konnte nicht gelesen werden: {exc}", "path": DATASTORE_PATH, "categories": list(categories.values())}
used = usage.used used = usage.used
values = [] values = []
@@ -222,16 +227,22 @@ def scan_datastore_storage():
category["size"] = format_bytes(category["bytes"]) category["size"] = format_bytes(category["bytes"])
category["percent"] = round(category["bytes"] / used * 100, 1) if used else 0 category["percent"] = round(category["bytes"] / used * 100, 1) if used else 0
values.append(category) values.append(category)
log.info("Datastore-Scan abgeschlossen: %s belegt, %s frei, %.1f%% genutzt", format_bytes(usage.used), format_bytes(usage.free), usage.used / usage.total * 100 if usage.total else 0)
return {"error": None, "path": DATASTORE_PATH, "total_bytes": usage.total, "used_bytes": usage.used, return {"error": None, "path": DATASTORE_PATH, "total_bytes": usage.total, "used_bytes": usage.used,
"free_bytes": usage.free, "total": format_bytes(usage.total), "used": format_bytes(usage.used), "free_bytes": usage.free, "total": format_bytes(usage.total), "used": format_bytes(usage.used),
"free": format_bytes(usage.free), "percent": round(usage.used / usage.total * 100, 1) if usage.total else 0, "free": format_bytes(usage.free), "percent": round(usage.used / usage.total * 100, 1) if usage.total else 0,
"categories": values, "scanned_at": time.time()} "categories": values, "scanned_at": time.time()}
def _read_command(command, timeout=4): def _read_command(command, timeout=4):
command_text = " ".join(str(part) for part in command)
log.debug("Systembefehl gestartet: %s", command_text)
try: try:
result = subprocess.run(command, capture_output=True, text=True, timeout=timeout, check=False) result = subprocess.run(command, capture_output=True, text=True, timeout=timeout, check=False)
if result.returncode != 0:
log.warning("Systembefehl beendet mit Code %s: %s%s", result.returncode, command_text, f" · {result.stderr.strip()[:180]}" if result.stderr.strip() else "")
return result.stdout, result.stderr return result.stdout, result.stderr
except (OSError, subprocess.SubprocessError) as exc: except (OSError, subprocess.SubprocessError) as exc:
log.error("Systembefehl fehlgeschlagen: %s · %s", command_text, exc)
return "", str(exc) return "", str(exc)
def _flatten_lsblk(nodes): def _flatten_lsblk(nodes):
@@ -251,6 +262,16 @@ def _device_serial(device):
return value return value
except OSError: except OSError:
continue continue
# Some controllers expose the serial only through udev's by-id links.
for by_id in (Path(HOST_DEVICE_PATH) / "disk/by-id", Path("/dev/disk/by-id")):
try:
for link in by_id.iterdir():
if link.name.startswith(("wwn-", "scsi-", "ata-", "nvme-")) and name in str(link.resolve()):
serial = re.sub(r"^(?:ata|scsi|nvme)-", "", link.name)
if serial and not serial.startswith("part"):
return serial
except OSError:
continue
return "" return ""
def format_transfer_rate(value): def format_transfer_rate(value):
@@ -293,13 +314,17 @@ def format_remaining_time(value):
return " ".join(parts) or "< 1 Min." return " ".join(parts) or "< 1 Min."
def raid_status(): def raid_status():
global raid_log_state
mdstat, mdstat_error = "", "" mdstat, mdstat_error = "", ""
try: try:
mdstat = Path("/proc/mdstat").read_text() mdstat = Path("/proc/mdstat").read_text()
except OSError as exc: except OSError as exc:
mdstat_error = str(exc) mdstat_error = str(exc)
log.error("/proc/mdstat konnte nicht gelesen werden: %s", exc)
array_name = Path(RAID_DEVICE).name array_name = Path(RAID_DEVICE).name
array_line = next((line for line in mdstat.splitlines() if line.startswith(f"{array_name} :")), "") array_line = next((line for line in mdstat.splitlines() if line.startswith(f"{array_name} :")), "")
if not array_line:
log.warning("RAID-Array %s wurde in /proc/mdstat nicht gefunden", RAID_DEVICE)
profile_match = re.search(r"(raid\d+)", array_line) profile_match = re.search(r"(raid\d+)", array_line)
# Match the member count on this RAID's own line. Searching all of # Match the member count on this RAID's own line. Searching all of
# /proc/mdstat can accidentally pick a different array's [x/y] value. # /proc/mdstat can accidentally pick a different array's [x/y] value.
@@ -355,11 +380,29 @@ def raid_status():
info = {} info = {}
serial = info.get("serial") or _device_serial(disk["device"]) serial = info.get("serial") or _device_serial(disk["device"])
disk.update({"size": format_bytes(info.get("size") or 0) if info.get("size") else "", "model": info.get("model") or "", "serial": serial or ""}) disk.update({"size": format_bytes(info.get("size") or 0) if info.get("size") else "", "model": info.get("model") or "", "serial": serial or ""})
serial_log_key = "serial:" + disk["device"]
if not serial and serial_log_key not in hardware_log_once:
log.warning("Keine Seriennummer für %s gefunden; Host-Controller stellt möglicherweise keine bereit", disk["device"])
hardware_log_once.add(serial_log_key)
if SMART_ENABLED and shutil.which("smartctl"): if SMART_ENABLED and shutil.which("smartctl"):
smart_device = disk["device"].replace("/dev/", f"{HOST_DEVICE_PATH.rstrip('/')}/", 1) smart_device = disk["device"].replace("/dev/", f"{HOST_DEVICE_PATH.rstrip('/')}/", 1)
smart_output, _ = _read_command(["smartctl", "-H", smart_device], timeout=6) smart_output, smart_error = _read_command(["smartctl", "-H", smart_device], timeout=6)
if not re.search(r"(?:overall-health|Health Status|SMART support is)", smart_output, re.IGNORECASE):
sat_output, sat_error = _read_command(["smartctl", "-H", "-d", "sat", smart_device], timeout=6)
if sat_output:
smart_output, smart_error = sat_output, sat_error
health = re.search(r"(?:SMART overall-health self-assessment test result|SMART Health Status):\s*(.+)", smart_output, re.IGNORECASE) health = re.search(r"(?:SMART overall-health self-assessment test result|SMART Health Status):\s*(.+)", smart_output, re.IGNORECASE)
disk["smart"] = health.group(1).strip() if health else "Nicht verfügbar" disk["smart"] = health.group(1).strip() if health else ("Fehler: " + smart_error.strip()[:80] if smart_error.strip() else "Nicht unterstützt")
smart_log_key = "smart:" + disk["device"]
if health and "smart-info:" + disk["device"] not in hardware_log_once:
log.info("SMART-Status %s: %s", disk["device"], disk["smart"])
hardware_log_once.add("smart-info:" + disk["device"])
if not health and smart_log_key not in hardware_log_once:
log.warning("SMART-Status für %s nicht verfügbar: %s", disk["device"], disk["smart"])
hardware_log_once.add(smart_log_key)
elif SMART_ENABLED and "smartctl" not in hardware_log_once:
log.error("smartctl ist im Container nicht verfügbar; SMART kann nicht geprüft werden")
hardware_log_once.add("smartctl")
missing = members_match and members_match.group(1) != members_match.group(2) missing = members_match and members_match.group(1) != members_match.group(2)
failed = detail.get("failed_devices", 0) > 0 or any(disk["status"] == "failed" for disk in devices) failed = detail.get("failed_devices", 0) > 0 or any(disk["status"] == "failed" for disk in devices)
rebuilding = bool(progress_match) rebuilding = bool(progress_match)
@@ -378,6 +421,10 @@ def raid_status():
active_devices = mdstat_active or detail.get("active_devices", 0) active_devices = mdstat_active or detail.get("active_devices", 0)
raid_devices = mdstat_total or detail.get("raid_devices", 0) or len(devices) raid_devices = mdstat_total or detail.get("raid_devices", 0) or len(devices)
failed_devices = detail.get("failed_devices", 0) or max(0, raid_devices - active_devices) failed_devices = detail.get("failed_devices", 0) or max(0, raid_devices - active_devices)
signature = (status, active_devices, raid_devices, failed_devices, rebuilding)
if signature != raid_log_state:
log.info("RAID-Status %s: %s (%s/%s aktiv, %s fehlend%s)", RAID_DEVICE, status.upper(), active_devices, raid_devices, failed_devices, ", Rebuild läuft" if rebuilding else "")
raid_log_state = signature
return {"device": RAID_DEVICE, "status": status, "label": {"ok": "OK", "degraded": "DEGRADED", "failed": "FAILED", "rebuilding": "REBUILDING"}.get(status, "UNKNOWN"), return {"device": RAID_DEVICE, "status": status, "label": {"ok": "OK", "degraded": "DEGRADED", "failed": "FAILED", "rebuilding": "REBUILDING"}.get(status, "UNKNOWN"),
"level": detail.get("level", ""), "raid_devices": raid_devices, "level": detail.get("level", ""), "raid_devices": raid_devices,
"active_devices": active_devices, "failed_devices": failed_devices, "active_devices": active_devices, "failed_devices": failed_devices,
+1
View File
@@ -27,6 +27,7 @@ services:
- /dev:/host-dev:ro - /dev:/host-dev:ro
# Read-only Hardware-Metadaten für Seriennummern und SMART-Zuordnung. # Read-only Hardware-Metadaten für Seriennummern und SMART-Zuordnung.
- /sys:/host-sys:ro - /sys:/host-sys:ro
- /run/udev:/host-run-udev:ro
- ./data:/data - ./data:/data
networks: networks:
- media - media