diff --git a/docker/worker/tasks.py b/docker/worker/tasks.py index 582cacc..4243630 100644 --- a/docker/worker/tasks.py +++ b/docker/worker/tasks.py @@ -735,6 +735,46 @@ def _ist_uebersetzt(container_pfad: str, lokal_pfad: str) -> bool: return container_pfad != lokal_pfad +# Wie weit wird nach einem vorhandenen Elternordner gesucht. Begrenzt, damit auf +# einer toten Freigabe nicht endlos geklopft wird. +MAX_ELTERN_STUFEN = 12 + + +def erster_vorhandener_ordner(pfad: str, isdir=None, dirname=None) -> str: + """Der nächste EXISTIERENDE Ordner oberhalb von `pfad` — oder "". + + ⚠️ Vorfall 26.07.2026, im vollen Durchlauf aufgefallen: Der Rip lief sauber + durch (40 GB), die Kompression brach sofort ab mit „Dieser Worker erreicht + das Ziel nicht: \\\\NAS\\rippy\\movies\\Akira (1988)". Erreichbar war die + Freigabe sehr wohl — es fehlten schlicht ZWEI Ordner: `movies` und der + Filmordner darin. Die Prüfung ließ nur EINE fehlende Ebene durchgehen (sie + sah nach `dirname`), obwohl gleich darauf `os.makedirs` die ganze Kette + anlegt. Ein Ziel, das noch nie beschrieben wurde, war damit systematisch + unerreichbar — also jeder erste Film in einer neuen Ablage. + + Es zählt daher, ob IRGENDEIN Vorfahre existiert: Von dem aus kann + `makedirs` den Rest bauen. Existiert keiner, ist die Freigabe wirklich weg. + + `isdir`/`dirname` werden BEIM AUFRUF aufgelöst, nicht als Vorgabewert + gebunden — sonst zeigt die Vorgabe für immer auf die Funktion von damals, + und ein Ersetzen von `os.path.isdir` (im Test wie im Betrieb) ginge ins + Leere. Genau darüber bin ich beim Schreiben der Tests gestolpert. + """ + isdir = isdir or os.path.isdir + dirname = dirname or os.path.dirname + aktuell = dirname(pfad or "") + for _ in range(MAX_ELTERN_STUFEN): + if not aktuell: + return "" + if isdir(aktuell): + return aktuell + naechster = dirname(aktuell) + if naechster == aktuell: # Wurzel erreicht + return "" + aktuell = naechster + return "" + + def _erreichbarkeit_pruefen(raw_container: str, raw_lokal: str, final_container: str, final_lokal: str) -> str: """Kann DIESER Worker Quelle und Ziel überhaupt sehen? Klartext oder "". @@ -761,17 +801,18 @@ def _erreichbarkeit_pruefen(raw_container: str, raw_lokal: str, fremder_worker = not os.path.isdir("/app") for zweck, container, lokal in ( - ("Quelle (Rohdaten)", raw_container, raw_lokal), - ("Ziel (fertige Datei)", final_container, final_lokal), + ("die Quelle (Rohdaten)", raw_container, raw_lokal), + ("das Ziel (fertige Datei)", final_container, final_lokal), ): if os.path.isdir(lokal): continue - # Ziel darf fehlen, solange sein Elternordner erreichbar ist — es wird - # gleich angelegt. Nur ein unerreichbarer ELTERN-Pfad ist ein Problem. - if zweck.startswith("Ziel") and os.path.isdir(os.path.dirname(lokal) or "."): + # Das ZIEL darf fehlen — es wird gleich mit `os.makedirs` angelegt, und + # zwar samt aller fehlenden Zwischenebenen. Es genügt also, dass + # irgendein Vorfahre existiert (Herleitung in erster_vorhandener_ordner). + if zweck.startswith("das Ziel") and erster_vorhandener_ordner(lokal): continue - text = [f"Dieser Worker erreicht die {zweck} nicht: {lokal}"] + text = [f"Dieser Worker erreicht {zweck} nicht: {lokal}"] if fremder_worker and not mapping: text.append( "Ursache: RIPPY_PATH_MAP ist auf dieser Maschine nicht gesetzt. " @@ -784,13 +825,24 @@ def _erreichbarkeit_pruefen(raw_container: str, raw_lokal: str, "start-tray.bat setzen, z. B. " "set RIPPY_PATH_MAP=/app/media=\\\\NAS\\rippy-media" ) + elif fremder_worker and mapping and not _ist_uebersetzt(container, lokal): + text.append( + f"RIPPY_PATH_MAP ist gesetzt ({mapping}), deckt diesen Pfad aber " + f'nicht ab: „{container}" wurde von keinem Eintrag übersetzt — ' + 'fehlt ein Präfix, oder ist es ein anderes Verzeichnis als erwartet?' + ) elif fremder_worker and mapping: - text.append(f"RIPPY_PATH_MAP ist gesetzt ({mapping}), deckt diesen Pfad aber nicht ab.") - if not _ist_uebersetzt(container, lokal): - text.append( - f'„{container}" wurde von keinem Eintrag übersetzt — fehlt ein ' - 'Präfix, oder ist es ein anderes Verzeichnis als erwartet?' - ) + # ⚠️ Hier stand bis zum 26.07.2026 dieselbe Meldung wie oben — „deckt + # diesen Pfad nicht ab" —, obwohl die Karte ihn nachweislich übersetzt + # hatte (der UNC-Pfad stand im selben Satz). Wer der Meldung folgte, + # suchte den Fehler in der Karte, während die Freigabe schlicht nicht + # verbunden war. Eine falsche Ursache ist teurer als gar keine. + text.append( + f'Übersetzt wurde er korrekt ({container} → {lokal}, Karte: ' + f'{mapping}) — dieser Pfad ist auf diesem Rechner nur gerade ' + 'nicht erreichbar. Ist die Freigabe verbunden? Anmeldedaten noch ' + 'gültig? Von Hand prüfen: den Pfad im Explorer öffnen.' + ) else: text.append( "Der Pfad existiert nicht. Liegt das Arbeitsverzeichnis auf einer " diff --git a/docker/worker/test_erreichbarkeit.py b/docker/worker/test_erreichbarkeit.py new file mode 100644 index 0000000..37d73ef --- /dev/null +++ b/docker/worker/test_erreichbarkeit.py @@ -0,0 +1,143 @@ +"""Tests für die Erreichbarkeits-Prüfung vor der Kompression. + +Diese Prüfung hatte KEINEN einzigen Test — und genau deshalb ist sie am +26.07.2026 im ersten vollständigen Durchlauf umgefallen: Der Rip lief sauber +durch (40 GB Akira), die Kompression brach 0,2 s später ab mit „Dieser Worker +erreicht das Ziel nicht", obwohl die Freigabe erreichbar war. Es fehlten nur +zwei noch nie angelegte Ordner. +""" + +import tasks + + +# --- Der eigentliche Fehler: nur EINE fehlende Ebene war erlaubt ------------- + +def _ohne_schluss_trenner(p): + """`\\\\NAS\\rippy\\` und `\\\\NAS\\rippy` sind derselbe Ordner. + + Wichtig, weil `ntpath.dirname` für die UNC-Wurzel MIT abschließendem + Backslash zurückkommt. Das echte `os.path.isdir` stört das nicht — eine + Attrappe, die stur vergleicht, hätte hier einen Fehler erfunden, den es + nicht gibt. + """ + return p.rstrip("\\/") or p + + +def _fake_isdir(vorhandene): + """isdir-Ersatz, der nur die genannten Pfade kennt.""" + bekannt = {_ohne_schluss_trenner(p) for p in vorhandene} + return lambda p: _ohne_schluss_trenner(p) in bekannt + + +def test_findet_den_naechsten_vorhandenen_ordner(): + da = {"\\\\NAS\\rippy"} + assert tasks.erster_vorhandener_ordner( + "\\\\NAS\\rippy\\movies\\Akira (1988)", + isdir=_fake_isdir(da), dirname=lambda p: p.rsplit("\\", 1)[0] if "\\" in p.strip("\\") else "", + ) == "\\\\NAS\\rippy" + + +def test_zwei_fehlende_ebenen_sind_in_ordnung(): + """DER Vorfall: `movies` UND der Filmordner fehlten beide. + + `os.makedirs` legt die ganze Kette an — es genügt also, dass irgendein + Vorfahre existiert. Die alte Prüfung sah nur nach `dirname` und erklärte + damit jeden ERSTEN Film in einer neuen Ablage für unerreichbar. + """ + da = {"/share"} + assert tasks.erster_vorhandener_ordner( + "/share/movies/Akira (1988)", isdir=_fake_isdir(da)) == "/share" + + +def test_gar_nichts_erreichbar_gibt_leer(): + """Ist die Freigabe wirklich weg, muss die Prüfung anschlagen.""" + assert tasks.erster_vorhandener_ordner( + "/share/movies/Film", isdir=_fake_isdir(set())) == "" + + +def test_sucht_nicht_endlos_auf_einer_toten_freigabe(): + """Jeder Schritt kostet auf einer toten Freigabe einen Timeout.""" + gefragt = [] + + def zaehlend(p): + gefragt.append(p) + return False + + tiefer_pfad = "/" + "/".join(f"e{i}" for i in range(50)) + assert tasks.erster_vorhandener_ordner(tiefer_pfad, isdir=zaehlend) == "" + assert len(gefragt) <= tasks.MAX_ELTERN_STUFEN + + +def test_leerer_pfad_stuerzt_nicht_ab(): + assert tasks.erster_vorhandener_ordner("", isdir=_fake_isdir(set())) == "" + + +# --- Die Meldung: eine falsche Ursache ist teurer als gar keine ------------- + +def _pruefen(monkeypatch, vorhandene, mapping, im_container=False): + kennt = _fake_isdir(vorhandene) + monkeypatch.setattr(tasks.os.path, "isdir", + lambda p: kennt(p) or (im_container and p == "/app")) + monkeypatch.setenv("RIPPY_PATH_MAP", mapping) + return tasks._erreichbarkeit_pruefen( + "/app/media/rippy/job1", "\\\\NAS\\rippy\\job1", + "/app/media/rippy/movies/Akira (1988)", "\\\\NAS\\rippy\\movies\\Akira (1988)", + ) + + +def test_alles_da_meldet_nichts(monkeypatch): + assert _pruefen(monkeypatch, {"\\\\NAS\\rippy\\job1", "\\\\NAS\\rippy\\movies\\Akira (1988)"}, + "/app/media/rippy=\\\\NAS\\rippy") == "" + + +def test_ziel_fehlt_ganz_ist_kein_fehler(monkeypatch): + """Der Vorfall vom 26.07.2026 — jetzt geht er durch.""" + assert _pruefen(monkeypatch, {"\\\\NAS\\rippy\\job1", "\\\\NAS\\rippy"}, + "/app/media/rippy=\\\\NAS\\rippy") == "" + + +def test_fehlende_quelle_bleibt_ein_fehler(monkeypatch): + meldung = _pruefen(monkeypatch, {"\\\\NAS\\rippy"}, "/app/media/rippy=\\\\NAS\\rippy") + assert "Quelle" in meldung + assert "Rohdaten sind NICHT verloren" in meldung + + +def test_uebersetzter_aber_unerreichbarer_pfad_beschuldigt_nicht_die_karte(monkeypatch): + """⚠️ Genau die Falschaussage aus dem Vorfall. + + Die Meldung lautete „RIPPY_PATH_MAP … deckt diesen Pfad aber nicht ab", + während sie ihn im selben Satz korrekt übersetzt hatte. Wer dem folgte, + suchte in der Karte statt in der Freigabe. + """ + meldung = _pruefen(monkeypatch, set(), "/app/media/rippy=\\\\NAS\\rippy") + assert "deckt diesen Pfad aber nicht ab" not in meldung + assert "Übersetzt wurde er korrekt" in meldung + assert "Freigabe verbunden" in meldung + + +def test_nicht_uebersetzter_pfad_beschuldigt_sehr_wohl_die_karte(monkeypatch): + """Hier ist die Karte wirklich schuld — der Pfad blieb unverändert.""" + monkeypatch.setattr(tasks.os.path, "isdir", lambda p: False) + monkeypatch.setenv("RIPPY_PATH_MAP", "/app/temp=Y:\\temp") + meldung = tasks._erreichbarkeit_pruefen( + "/app/media/rippy/job1", "/app/media/rippy/job1", + "/app/media/movies/X", "/app/media/movies/X", + ) + assert "deckt diesen Pfad aber nicht ab" in meldung + assert "von keinem Eintrag übersetzt" in meldung + + +def test_ohne_mapping_nennt_die_wahre_ursache(monkeypatch): + meldung = _pruefen(monkeypatch, set(), "") + assert "nicht gesetzt" in meldung + assert "RIPPY_PATH_MAP" in meldung + + +def test_artikel_stimmt_in_beiden_meldungen(monkeypatch): + """„erreicht die Ziel" stand wörtlich im Log des Commanders.""" + quelle = _pruefen(monkeypatch, set(), "") + assert "erreicht die Quelle" in quelle + # Quelle da, Ziel unerreichbar (kein einziger Vorfahre) → Ziel-Meldung + ziel = _pruefen(monkeypatch, {"\\\\NAS\\rippy\\job1"}, + "/app/media/rippy=\\\\NAS\\rippy") + assert "erreicht das Ziel" in ziel