diff --git a/docker/api/mounts.py b/docker/api/mounts.py index c9ef838..2e48729 100644 --- a/docker/api/mounts.py +++ b/docker/api/mounts.py @@ -73,6 +73,30 @@ def schreibtest(pfad: str) -> bool: return False +def _stale_mounts_loesen(ziel: str) -> int: + """Loest ALLE (evtl. gestapelten/toten) Mounts an `ziel` per lazy umount. + + Warum: mounten() faellt bei einem TOTEN Mount (os.path.ismount wirft OSError) + auf den echten `mount` durch — der stapelt dann auf die Leiche. Vorfall 24.07.: + ueber viele Neustarts 12 Schichten, die tote oberste blockierte jeden Zugriff + (ls-Timeout, obwohl SMB-445 offen). Erst alle Schichten loesen macht das + Re-Mounten idempotent. `umount -l` (lazy) haengt nicht an einem toten CIFS. + Rueckgabe: Zahl der geloesten Schichten. + """ + geloest = 0 + for _ in range(20): # harte Obergrenze gegen Endlosschleife + try: + ergebnis = subprocess.run( + ["umount", "-l", ziel], capture_output=True, timeout=10 + ) + except (OSError, subprocess.TimeoutExpired): + break + if ergebnis.returncode != 0: + break # nichts (mehr) gemountet + geloest += 1 + return geloest + + def uebersetze_smb_fehler(fehler: str, mit_credentials: bool) -> str: """Pure Funktion (testbar): NT_STATUS-Kauderwelsch → handelbarer Klartext. @@ -155,7 +179,11 @@ def mounten(name: str, typ: str, quelle: str, optionen: str = "", if os.path.ismount(ziel): return schreibtest(ziel) except OSError: - pass # toter Mount → als „nicht gemountet" behandeln und neu mounten + pass # toter Mount → unten erst lösen, dann frisch mounten (kein Stapeln) + + # Idempotent: etwaige (auch gestapelte/tote) Alt-Mounts erst lösen, damit der + # folgende mount NICHT auf eine Leiche stapelt (Vorfall 24.07.: 12 Schichten). + _stale_mounts_loesen(ziel) creds_datei = None try: diff --git a/docker/api/test_mounts_helpers.py b/docker/api/test_mounts_helpers.py index be06b5b..f6e516e 100644 --- a/docker/api/test_mounts_helpers.py +++ b/docker/api/test_mounts_helpers.py @@ -39,3 +39,33 @@ def test_unbekannter_fehler_bleibt_erhalten_und_gekappt(): 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