Files
rippy/docker/api/test_mounts_helpers.py
T
Hitonabi 4cf7acbb96
Ampel / ampel (push) Successful in 31s
fix(mounts): DIE URSACHE gefunden - die CIFS-Verbindung stirbt mit dem Container
Der Savepoint v3.17 fuehrte das als "Ursache liegt beim NAS, nicht gefunden".
Gemessen ist es etwas ganz anderes, und es liegt bei uns:

  /proc/fs/cifs/DebugData  ->  Net namespace: 4026532653
  api-Container            ->  net:[4026532653]   DIESELBE
  worker-Container         ->  net:[4026532540]   andere

Die CIFS-Verbindung lebt in der NETZ-NAMESPACE DES API-CONTAINERS - dort wird
sie eingehaengt, weil nur dieser Container CAP_SYS_ADMIN hat. Wird der Container
neu gebaut, stirbt sein Netz-Namespace und mit ihm der Socket. Der Mount steht
danach weiter in /proc/mounts (per rshared auf den Host propagiert) und sieht
vollkommen gesund aus - aber jeder Zugriff laeuft in den CIFS-Timeout.

Damit erklaert sich alles, was vorher widerspruechlich aussah: warum es nach
JEDEM Deploy passiert, warum `mount` Erfolg meldet, warum /proc/mounts genau eine
korrekte Schicht zeigt, und warum nur ein echtes Neu-Verbinden hilft. Das NAS ist
unschuldig (eine Sitzung, Status 1, 630 Credits, Ping 0,47 ms).

Zweiter Fund, der den Rest erklaert: Direkt nach einem frischen Mount antwortete
die Freigabe - und Sekunden spaeter nicht mehr. Das ist ein Wettlauf mit
`umount -l`: lazy heisst, der Abbau passiert spaeter, und faellt er samt
Propagation hinter den neuen Mount, zeigt der Pfad wieder auf die Leiche. Eine
einzige Probe kann das nicht sehen - deshalb prueft `wirklich_erreichbar()`
zweimal mit drei Sekunden Abstand, und zwar sowohl beim Mounten als auch in der
Wache.

Dazu: erste Pruefung der Wache schon nach 10 s statt 60 s. Genau dann ist die
Lage nach einem Deploy kaputt.

Ehrlich offen bleibt die strukturelle Folge: Der api-Container HAELT die
NAS-Verbindung. Startet er mitten in einem Rip neu, verliert auch der Worker sein
Ziel. Das saubere Gegenmittel waere ein Mount auf dem HOST statt im Container -
ein eigener Umbau, und er widerspraeche "Speicherziele ueber das UI einhaengen".

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-26 14:32:51 +02:00

284 lines
11 KiB
Python

"""Tests für die SMB-Fehlerübersetzung (Speicherziele → Freigaben auflisten)."""
from mounts import (
pfad_map_vorschlag,
pfad_map_zeile,
uebersetze_smb_fehler,
unc_aus_quelle,
validiere_name,
)
def test_access_denied_ohne_credentials_erklaert_gastproblem():
meldung = uebersetze_smb_fehler(
"session setup failed: NT_STATUS_ACCESS_DENIED", mit_credentials=False
)
assert "Gast" in meldung
assert "Benutzername + Passwort" in meldung
def test_access_denied_mit_credentials_verweist_auf_konto():
meldung = uebersetze_smb_fehler(
"session setup failed: NT_STATUS_ACCESS_DENIED", mit_credentials=True
)
assert "stimmen nicht" in meldung
def test_logon_failure_wird_uebersetzt():
meldung = uebersetze_smb_fehler("NT_STATUS_LOGON_FAILURE", mit_credentials=True)
assert "Passwort falsch" in meldung
def test_unerreichbar_wird_uebersetzt():
meldung = uebersetze_smb_fehler(
"do_connect: Connection to 10.0.0.9 failed (Error NT_STATUS_IO_TIMEOUT)",
mit_credentials=False,
)
assert "nicht erreichbar" in meldung
def test_unbekannter_fehler_bleibt_erhalten_und_gekappt():
meldung = uebersetze_smb_fehler("X" * 500, mit_credentials=False)
assert meldung == "X" * 200
def test_validiere_name_bleibt_streng():
assert validiere_name("nas-filme")
assert not validiere_name("NAS Filme")
def test_stale_mounts_loesen_loest_bis_nichts_mehr_geht(monkeypatch):
"""Löst gestapelte Schichten per lazy umount, bis umount nichts mehr findet
(returncode != 0), und meldet die Zahl der gelösten Schichten."""
import types
import mounts
aufrufe = []
def fake_run(cmd, **kwargs):
aufrufe.append(cmd)
rc = 0 if len(aufrufe) <= 3 else 1 # 3 Schichten lösen, dann leer
return types.SimpleNamespace(returncode=rc, stdout=b"", stderr=b"")
monkeypatch.setattr(mounts.subprocess, "run", fake_run)
assert mounts._stale_mounts_loesen("/app/media/x") == 3
assert all(cmd[:2] == ["umount", "-l"] for cmd in aufrufe)
def test_mounten_loest_stale_vor_dem_mount():
"""Regression (Vorfall 24.07.): mounten() muss Alt-Mounts LÖSEN, bevor es neu
mountet — sonst stapelt es auf eine Mount-Leiche (12 Schichten, ls-Timeout)."""
import inspect
import mounts
quelle = inspect.getsource(mounts.mounten)
assert "_stale_mounts_loesen(ziel)" in quelle
# --- RIPPY_PATH_MAP: der Anschluss für externe Worker (Befund 26.07.2026) ----
def test_unc_aus_quelle_uebersetzt_cifs():
assert unc_aus_quelle("cifs", "//192.168.178.62/rippy") == "\\\\192.168.178.62\\rippy"
assert unc_aus_quelle("cifs", "//nas/medien/filme") == "\\\\nas\\medien\\filme"
def test_unc_aus_quelle_raet_bei_nfs_nicht():
"""NFS gibt "" — Windows-Schreibweise ist nicht ableitbar (AGENTS Regel D)."""
assert unc_aus_quelle("nfs", "192.168.178.62:/volume1/rippy") == ""
assert unc_aus_quelle("cifs", "") == ""
assert unc_aus_quelle("cifs", "kein-unc-pfad") == ""
def test_pfad_map_zeile_baut_was_pfad_lokal_liest(monkeypatch):
"""Der erzeugte Wert muss vom Worker gelesen werden können — genau dieses
Format erwartet worker/tasks.pfad_lokal(): Paare, getrennt durch ';'."""
import mounts
monkeypatch.setattr(mounts, "ist_gemountet", lambda name: True)
vorschlaege = pfad_map_vorschlag([
{"name": "rippy", "typ": "cifs", "quelle": "//192.168.178.62/rippy"},
])
assert pfad_map_zeile(vorschlaege) == "/app/media/rippy=\\\\192.168.178.62\\rippy"
def test_pfad_map_zeile_laesst_nfs_weg(monkeypatch):
"""Ein halbes Mapping wäre schlimmer als keines: pfad_lokal() hört beim
ersten passenden Präfix auf, ein NFS-Eintrag ohne Ziel würde also einen
Pfad 'übersetzen', den der Worker danach trotzdem nicht sieht."""
import mounts
monkeypatch.setattr(mounts, "ist_gemountet", lambda name: True)
vorschlaege = pfad_map_vorschlag([
{"name": "nfs-ziel", "typ": "nfs", "quelle": "10.0.0.9:/export"},
{"name": "rippy", "typ": "cifs", "quelle": "//nas/rippy"},
])
assert pfad_map_zeile(vorschlaege) == "/app/media/rippy=\\\\nas\\rippy"
def test_pfad_map_zeile_ohne_mounts_ist_leer():
assert pfad_map_zeile([]) == ""
assert pfad_map_zeile(None) == ""
def test_erzeugtes_mapping_uebersetzt_den_echten_fehlerfall(monkeypatch):
"""Gegenprobe mit dem Pfad, an dem es am 26.07.2026 live scheiterte.
Der Rohschnitt lag auf `/app/media/rippy/95afdc89-…/title_t00.mkv`; der
Windows-Worker sah dort nichts. Mit dem hier erzeugten Mapping muss
genau dieser Pfad auf die Freigabe zeigen. `pfad_lokal` ist eine reine
Funktion im Worker — hier nachgebaut aufzurufen wäre wertlos, deshalb
wird sie über den Pfad importiert.
"""
import importlib.util
import os
import mounts
monkeypatch.setattr(mounts, "ist_gemountet", lambda name: True)
mapping = pfad_map_zeile(pfad_map_vorschlag([
{"name": "rippy", "typ": "cifs", "quelle": "//192.168.178.62/rippy"},
]))
# Der Worker liegt neben der API im Repo; kein geteiltes Paket zwischen
# den Containern, deshalb per Pfad laden statt importieren.
worker_tasks = os.path.join(
os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "worker", "tasks.py"
)
spec = importlib.util.spec_from_file_location("_worker_tasks_pfad", worker_tasks)
quelltext = open(worker_tasks, encoding="utf-8").read()
assert "def pfad_lokal" in quelltext and spec is not None
# pfad_lokal ist bewusst rein und ohne Modul-Zustand — die Funktion aus dem
# Quelltext zu holen, ohne tasks.py komplett zu laden (das braucht celery,
# db, requests …), geht am ehrlichsten über exec des Funktionsblocks.
anfang = quelltext.index("def pfad_lokal")
ende = quelltext.index("\nRAW_DIR", anfang)
umgebung = {"os": os}
exec(compile(quelltext[anfang:ende], worker_tasks, "exec"), umgebung) # noqa: S102
pfad_lokal = umgebung["pfad_lokal"]
assert pfad_lokal("/app/media/rippy/95afdc89/title_t00.mkv", mapping) == (
"\\\\192.168.178.62\\rippy\\95afdc89\\title_t00.mkv"
)
# --- Der Mount kam nach einem Rebuild nicht zurueck (Befund 26.07.2026) ------
def test_mounten_geht_bei_totem_mount_den_reparatur_weg(monkeypatch):
"""Regression. Vorher galt `os.path.ismount` als Beweis, dass alles steht -
und nach `docker compose up -d --build` war die CIFS-Freigabe TOT (4 von 4
Zugriffen 10 s Timeout), der Mountpunkt aber weiter vorhanden. Damit brach
das Wiederherstellen genau dort ab, und `schreibtest()` (kein Timeout!)
blockierte den Start-Thread im Kernel.
Antwortet die Freigabe nicht, muss geloest und frisch gemountet werden -
genau wie reparieren() es tut, nur automatisch. Der Test faengt das an der
WIRKUNG: ismount darf dann gar nicht mehr gefragt werden."""
import types
import mounts
ablauf = []
monkeypatch.setattr(mounts.os, "makedirs", lambda *a, **k: None)
# Vorher tot (der Vor-Check), nach dem Mount dauerhaft erreichbar. Die
# Doppelprobe wird hier gestubbt, damit der Test nicht 3 s echt wartet.
monkeypatch.setattr(mounts, "ist_erreichbar", lambda name: False)
monkeypatch.setattr(mounts, "wirklich_erreichbar", lambda name: True)
monkeypatch.setattr(mounts, "_stale_mounts_loesen",
lambda ziel: ablauf.append("loesen"))
monkeypatch.setattr(mounts, "schreibtest", lambda p: True)
def fake_run(cmd, **kwargs):
ablauf.append(cmd[0])
return types.SimpleNamespace(returncode=0, stdout="", stderr="")
monkeypatch.setattr(mounts.subprocess, "run", fake_run)
# ismount darf hier gar nicht mehr gefragt werden
monkeypatch.setattr(mounts.os.path, "ismount",
lambda p: (_ for _ in ()).throw(AssertionError("zu frueh gefragt")))
assert mounts.mounten("rippy", "cifs", "//nas/rippy") is True
assert ablauf == ["loesen", "mount"]
def test_mounten_prueft_das_ergebnis_und_versucht_es_zweimal(monkeypatch):
"""Befund 26.07.2026, dreimal reproduziert: `mount` meldete Erfolg, und die
Freigabe antwortete danach TROTZDEM nicht (eine einzige, korrekt aussehende
Schicht in /proc/mounts). Derselbe Ablauf ein zweites Mal stellte sie sofort
her. Also wird das Ergebnis geprueft statt geglaubt."""
import types
import mounts
ablauf = []
# nie erreichbar: vorher, nach Versuch 1, nach Versuch 2
monkeypatch.setattr(mounts.os, "makedirs", lambda *a, **k: None)
monkeypatch.setattr(mounts, "ist_erreichbar", lambda name: False)
monkeypatch.setattr(mounts, "wirklich_erreichbar", lambda name: False)
monkeypatch.setattr(mounts, "_stale_mounts_loesen", lambda ziel: None)
monkeypatch.setattr(mounts, "_lazy_umount", lambda ziel: ablauf.append("lazy"))
monkeypatch.setattr(mounts, "schreibtest", lambda p: True)
monkeypatch.setattr(
mounts.subprocess, "run",
lambda cmd, **k: (ablauf.append(cmd[0]),
types.SimpleNamespace(returncode=0, stdout="", stderr=""))[1])
import pytest
with pytest.raises(RuntimeError) as fehler:
mounts.mounten("rippy", "cifs", "//nas/rippy")
# Zweimal gemountet, dazwischen einmal geloest
assert ablauf == ["mount", "lazy", "mount"]
# Und die Meldung sagt die Wahrheit statt "eingehaengt"
assert "antwortet aber nicht" in str(fehler.value)
def test_mounten_laesst_gesunden_mount_in_ruhe(monkeypatch):
"""Antwortet die Freigabe, bleibt sie unangetastet - kein Loesen, kein
zweites Mounten (das wuerde stapeln)."""
import mounts
monkeypatch.setattr(mounts.os, "makedirs", lambda *a, **k: None)
monkeypatch.setattr(mounts, "ist_erreichbar", lambda name: True)
monkeypatch.setattr(mounts.os.path, "ismount", lambda p: True)
monkeypatch.setattr(mounts, "schreibtest", lambda p: True)
monkeypatch.setattr(mounts, "_stale_mounts_loesen",
lambda ziel: (_ for _ in ()).throw(AssertionError("nicht loesen!")))
assert mounts.mounten("rippy", "cifs", "//nas/rippy") is True
def test_wirklich_erreichbar_prueft_zweimal_mit_abstand(monkeypatch):
"""Befund 26.07.2026: Direkt nach einem frischen `mount` antwortete die
Freigabe - und Sekunden spaeter lief jeder Zugriff in die Zeitgrenze
(Wettlauf mit dem lazy umount, dessen Abbau hinter den neuen Mount fiel).
Eine EINZIGE Probe kann das nicht sehen."""
import mounts
antworten = iter([True, False]) # erst ja, dann nein
gewartet = []
monkeypatch.setattr(mounts, "ist_erreichbar", lambda name: next(antworten))
assert mounts.wirklich_erreichbar("rippy", warten=gewartet.append) is False
assert gewartet == [3]
def test_wirklich_erreichbar_bei_gesunder_freigabe(monkeypatch):
import mounts
monkeypatch.setattr(mounts, "ist_erreichbar", lambda name: True)
assert mounts.wirklich_erreichbar("rippy", warten=lambda s: None) is True
def test_wirklich_erreichbar_spart_das_warten_wenn_schon_die_erste_probe_faellt(monkeypatch):
import mounts
gewartet = []
monkeypatch.setattr(mounts, "ist_erreichbar", lambda name: False)
assert mounts.wirklich_erreichbar("rippy", warten=gewartet.append) is False
assert gewartet == [] # nicht drei Sekunden fuer nichts