Files
rippy/docker/api/mounts.py
HitonabiandClaude Opus 5 d3e86d2641 fix(windows): Der Schalter, den es nicht gibt, und vierzehn weitere Funde
Commander: „Kompression fehlgeschlagen bei Spartacus … _t01.mkv: HandBrake
endete mit Code 0" — 16,5 GB fertiger Rohschnitt, und die Kompression war in
derselben Sekunde vorbei, in der sie begann.

Nachgestellt mit genau der Befehlszeile, die Rippy baute:

    unknown option (--audio-codec)
    HandBrake has exited.        $? = 0

Den Schalter `--audio-codec` gibt es bei HandBrakeCLI nicht; er heisst
`-E` / `--aencoder`. Ein unbekannter Schalter ist fuer HandBrake kein Fehler,
der Rueckgabewert ist 0. Der Test dazu forderte den falschen Namen sogar ein.

Daraus wurde ein Rundgang durch den Windows-Pfad. Alles unten ist gemessen,
nichts vermutet (Regel D).

## Die Kompression

1. `--aencoder` statt `--audio-codec`. Am mitgelieferten HandBrakeCLI 1.11.2
   gemessen, mit einem 5-Sekunden-Encode auf der echten Roh-Datei bestaetigt.

2. HandBrakes letzte Zeilen werden aufgehoben (12 gepuffert, 4 in der
   Meldung) und `unknown option (...)` wird als eigener Fall erkannt, VOR
   allen anderen. Vorher wurde jede Zeile weggeworfen, die kein Fortschritt
   war — bei Rueckgabewert 0 blieb damit keine Auskunft uebrig. Die geratene
   Zeile „Meist ist der Zielordner nicht beschreibbar" ist raus; sie war
   falsch und hat die Suche in die falsche Richtung geschickt.

3. Ein `ue`-Umlaut im Pfad toetete die Kompression. HandBrake schreibt zwei
   Kodierungen in denselben Strom (derselbe Pfad einmal UTF-8, einmal CP850).
   In CP850 ist das Byte 0x81, und das ist in cp1252 — was `text=True` auf
   deutschem Windows waehlt — undefiniert:

       UnicodeDecodeError: charmap codec can't decode byte 0x81

   Neu: `rip/handbrake_aufruf.py` mit `HB_LESEN`, benutzt von ripping.py und
   caps.py. Bewusst nicht binaer wie bei makemkvcon: HandBrake trennt
   Fortschrittszeilen mit CR, im Binaermodus waere der Balken weg.

## Die Rohdaten

4. `rohdaten.kandidaten` machte aus dem Arbeitsordner `F:\` ein `F:` und
   verband damit weiter. Das ist unter Windows der aktuelle Ordner auf
   Laufwerk F, nicht dessen Wurzel — 16,5 GB waren unsichtbar, und der
   Wiederholen-Dialog bot nur „Neu rippen" an. Die Falle steht woertlich im
   Kopf von `pfade.verbinden`.

5. Gesucht wurde unter der heutigen Einstellung statt unter der Wahl DIESES
   Rips (`meta["work_dir"]`). Genau dafuer wurde rohdaten.py am 26.07.
   gebaut; repariert wurde damals die Kandidatenliste, nicht der Aufrufer.
   Neu: `_arbeitsverzeichnis_des_jobs`, benutzt an vier Stellen.

6. Zwei Speicher fuer dieselben Ordner: Die Oberflaeche schreibt
   `outputDir`/`workDir` in die Datenbank, `betrieb` liest `storage.*` aus
   der Konfigurationsdatei, und die schreibt niemand. Gemessen: eingestellt
   `E:\Rippy`, angezeigt `C:\Users\...\Videos\Rippy`. Neu:
   `betrieb.mit_einstellungen`.

## Das Laufwerk

7. `device_info` fing den OSError ab und lieferte „unknown" ohne den Grund.
   Nach einem Rip mit Lesefehlern beantwortete das Laufwerk keine
   Medien-Abfragen mehr (Win32-Fehler 1), die Geraete-Auskunft aber schon —
   im UI stand eine volle Laufwerkskarte, kein Rip startbar, und im
   Protokoll das laengst veraltete „Disc erkannt". Neu: `ZUGRIFFS_GRUENDE`,
   ein Feld `grund` im Laufwerks-Eintrag und eine Protokollzeile je Wechsel.
   Eine fehlgeschlagene Disc-Erkennung wird ebenfalls protokolliert.

8. Der Linux-Treiber nannte ein unzugaengliches Laufwerk „empty", waehrend
   Windows richtig „unknown" sagt. Angeglichen, samt Feld-Paritaet.

9. `CreateFileW`, `DeviceIoControl` und `CloseHandle` hatten weder `restype`
   noch `argtypes` — 32-Bit-`c_int` fuer einen 64-Bit-HANDLE, in beide
   Richtungen. Mit `restype` aendert sich der Fehlerwert von -1 auf
   0xFFFFFFFFFFFFFFFF; die Pruefung deckt jetzt beides ab. Am echten
   Laufwerk gegengeprueft, Fehlerpfad eingeschlossen.

10. Der Vor-Scan lief bei JEDER eingelegten Disc ein `makemkvcon info` mit
    120 s Zeitgrenze — 20 bis 120 Sekunden „Disc wird gelesen". Frueher war
    das schnell, weil der Zweig unter Windows nie lief (`shutil.which`,
    repariert am 28.08.). Das Ergebnis landete allein in `toc["tracks"]`,
    das niemand liest: Der Rip-Dialog holt seine Liste ueber
    `/devices/{id}/scan-tracks`, wenn sie gebraucht wird. Entfernt.

## Notbremsen

11. `_frei_bytes` suchte den naechsten vorhandenen Ordner selbst.
    `os.path.dirname("Q:\\")` gibt sich selbst zurueck — ein
    Arbeitsverzeichnis auf einer abgezogenen Platte haette den Job vor dem
    Rip stumm haengen lassen. Benutzt jetzt
    `pfade.naechster_vorhandener`, das den Abbruch seit V2-1 hat.

12. `naechster_vorhandener` haelt Laufwerks- und UNC-Wurzeln jetzt absolut.

13. `aufraeum_skript` baut sein `rmdir /s /q` aus `InstallLocation` in der
    Registry. Waere das eine Laufwerks-Wurzel, loeschte die Deinstallation
    das Laufwerk. Nicht beobachtet, aber nicht wiedergutzumachen — der
    Loeschbefehl bleibt in dem Fall weg.

## Lesefehler

MakeMKV sicherte 1 von 2 Titeln, endete mit 0, und Rippy schrieb „Rip
fertig". Jetzt gibt es eine Warnung, auch wenn der Rip als Erfolg endet, und
die MSG-Nummer steht im Protokoll: MakeMKVs Texte sind uebersetzt, die
Nummern nicht.

## Aus der Gegenprobe am laufenden Rippy

Die erste Fassung dieses Standes war installiert, als der Commander meldete:
„nun oeffnen sich diverse fenster im hintergrund, gehen ganz kurz auf und dann
wieder zu. Das laufwerk hoert auch einfach auf zu lesen." Beides Altlasten,
die erst durch die neue Protokollzeile sichtbar wurden.

14. Prozesserzeugung mitgeschnitten:

        14:54:40  timeout.exe          timeout 4 ls -d C:\Users\...\d7ee6c06-...
        14:54:40  WindowsTerminal.exe

    `rohdaten.pruefen` fragt mit `timeout N ls -d`, ob es ein Verzeichnis
    gibt. Unter Linux ist das richtig (os.path.isdir kann an einem toten
    CIFS-Mount im Kernel haengen, ein Kindprozess laesst sich abbrechen).
    Unter Windows ist es dreifach falsch: timeout.exe gibt es dort, kennt
    aber weder `ls` noch `-d`; sie braucht eine Konsole, und die reisst
    Windows auf; und ihr Rueckgabewert ist nie 0, die Antwort lautete also
    „weg" fuer JEDES Verzeichnis. Rohdaten waren unter Windows
    grundsaetzlich unsichtbar. Neu: `nativ_nachsehen()`. Die zwei
    gleichartigen Aufrufe in mounts.py bekommen dieselbe Absicherung.

15. Der Waechter fragte das Laufwerk alle drei Sekunden ab — auch mitten im
    Rip, also drei CreateFileW plus IOCTLs auf ein Geraet, das makemkvcon
    gerade liest:

        12:49:52  bluray-Rip gestartet
        12:50:09  [watcher] Laufwerk G: beantwortet keine Medien-Abfragen
        12:50:12  MSG 2003 SCSI-Fehler ILLEGAL REQUEST:INVALID FIELD IN CDB
        12:50:12  makemkvcon endete mit Code 11

    `_auto_prescan` haelt sich seit dem 29.08.2026 an die Regel „waehrend
    eines Rips wird nicht gescannt"; die Laufwerksabfrage tat es nicht.
    Jetzt gilt in der Zeit der letzte bekannte Stand.

## Zwei Tests, die gelogen haben

* `assert "--audio-codec" in cmd` schrieb den Fehler fest.
* `lambda: {}` als Doppelgaenger fuer `get_settings(key, bei_fehler_leer)`
  brach, sobald ein Aufrufer einen Parameter benutzte — und zeigte dann auf
  den Code statt auf sich selbst.

977 Tests gruen, ruff sauber.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-30 15:11:00 +02:00

479 lines
20 KiB
Python

"""Netzwerk-Speicherziele (NFS/SMB) direkt aus Rippy heraus einhängen.
Commander-Anforderung 23.07.: Data-Mounts über das UI, universell für jede
Installation. Der api-Container läuft dafür mit CAP_SYS_ADMIN und einem
rshared-Bind auf /app/media — ein Mount hier propagiert über den Host in
alle anderen Container (Worker sieht das Ziel sofort).
Zugangsdaten liegen in Postgres (Klartext — bewusster Heimnetz-Kompromiss,
im README dokumentiert; SMB-Credentials wandern NIE in die Kommandozeile,
sondern über eine temporäre credentials-Datei).
"""
import os
import posixpath
from rippy.platform.winlauf import OHNE_FENSTER
import re
import subprocess
import tempfile
from rippy import store as db
MEDIA_ROOT = "/app/media"
NAME_MUSTER = re.compile(r"^[a-z0-9][a-z0-9-]{1,30}$")
def _mountpoint(name: str) -> str:
"""Mountpunkt eines Speicherziels — IMMER ein Container-Pfad.
posixpath statt os.path (Befund 26.07.2026): Innerhalb des api-Containers
ist das dasselbe, aber `pfad_map_vorschlag()` liefert diesen Pfad an einen
WINDOWS-Worker aus. Mit os.path.join entstand beim Test unter Windows
`/app/media\\rippy`, und `pfad_lokal()` fand dann kein Präfix — das Mapping
wäre still wirkungslos geblieben. Dieselbe Falle wie bei `_zielbasis()` im
Worker (v3.14), diesmal vom Test gefunden statt live.
"""
return posixpath.join(MEDIA_ROOT, name)
def validiere_name(name: str) -> bool:
return bool(NAME_MUSTER.match(name))
def ist_gemountet(name: str) -> bool:
"""Ist unter /app/media/<name> etwas gemountet? Ein TOTER CIFS-Mount
(NAS weg) lässt os.path.ismount mit OSError fliegen — das zählt weiter
als 'gemountet' (nur eben kaputt), damit die Reparatur greift."""
try:
return os.path.ismount(_mountpoint(name))
except OSError:
return True
def ist_erreichbar(name: str) -> bool:
"""Kann auf das Ziel WIRKLICH zugegriffen werden?
Ein toter CIFS-Mount (Host weg, stale) ist zwar 'gemountet', aber jeder
Zugriff scheitert. Der Zugriff wird HART auf 3 s begrenzt (`timeout 3 ls`)
— ein direktes os.listdir blockierte sonst bis zum CIFS-Timeout (~10 s,
Befund 24.07.: die Speicherziele-Seite lud dadurch träge).
"""
ziel = _mountpoint(name)
try:
ergebnis = subprocess.run(
["timeout", "3", "ls", ziel], capture_output=True,
creationflags=OHNE_FENSTER, timeout=5
)
return ergebnis.returncode == 0
except (OSError, subprocess.TimeoutExpired):
return False
def pfad_lage(ziel: str) -> str:
"""Existiert dieses Verzeichnis? „da" | „weg" | „unklar" — mit Zeitgrenze.
⚠️ Der Grund, warum es das gibt (Befund 26.07.2026, an Zeitstempeln gemessen):
Eine Wiederanbindung brauchte **3 Minuten 15 Sekunden**, und die Zeit ging
nicht in die Mount-Versuche, sondern in die allererste Zeile von `mounten()`:
os.makedirs(ziel, exist_ok=True)
`exist_ok` prüft mit `os.path.isdir()`, und ein `stat` auf einen TOTEN
CIFS-Mount blockiert im Kernel bis zum SMB-Timeout. Ausgerechnet der Aufruf,
der nur „lege den Ordner an, falls er fehlt" bedeutet, hing also drei Minuten
— bevor irgendeine der sorgfältig begrenzten Prüfungen überhaupt dran war.
Dritter Fund derselben Sorte an einem Tag: os-Aufruf auf einen Netzpfad ohne
Zeitgrenze.
Deshalb wird die Lage jetzt mit einem Kind-Prozess erfragt (`timeout 4 ls`),
der sich abbrechen lässt. „unklar" heißt: existiert, antwortet aber nicht —
dann ist ein makedirs weder nötig noch möglich.
"""
try:
ergebnis = subprocess.run(
["timeout", "4", "ls", "-d", ziel], capture_output=True,
creationflags=OHNE_FENSTER, timeout=6
)
except (OSError, subprocess.TimeoutExpired):
return "unklar"
if ergebnis.returncode == 0:
return "da"
return "unklar" if ergebnis.returncode == 124 else "weg"
def wirklich_erreichbar(name: str, warten=None) -> bool:
"""Antwortet die Freigabe auch noch DREI SEKUNDEN später? (zweimal geprüft)
Warum zweimal (Befund 26.07.2026): Direkt nach einem frischen `mount`
antwortete die Freigabe reproduzierbar — und Sekunden später lief jeder
Zugriff in die Zeitgrenze. Ursache ist ein Wettlauf beim Aufräumen:
`_stale_mounts_loesen` benutzt `umount -l`, und das ist LAZY — es hängt den
Mount sofort aus der Sicht aus, der eigentliche Abbau passiert später. Fällt
dieser Abbau samt Propagation (rshared) hinter den neuen Mount, zeigt der
Pfad wieder auf die Leiche.
Eine einzige Probe kann das nicht sehen. Zwei mit Abstand schon.
"""
if warten is None:
import time
warten = time.sleep
if not ist_erreichbar(name):
return False
warten(3)
return ist_erreichbar(name)
def schreibtest(pfad: str) -> bool:
"""Berechtigungs-Prüfung: können wir im Ziel wirklich schreiben?
Commander-Anforderung 23.07.: beim Hinzufügen muss geprüft werden, ob
die Rechte stimmen — ein nur-lesbares Ziel fällt sonst erst beim Rip auf.
"""
testdatei = os.path.join(pfad, ".rippy-schreibtest")
try:
with open(testdatei, "w") as f:
f.write("ok")
os.unlink(testdatei)
return True
except OSError:
return False
def _stale_mounts_loesen(ziel: str) -> int:
"""Loest ALLE (evtl. gestapelten/toten) Mounts an `ziel` per lazy umount.
Warum: mounten() fällt bei einem TOTEN Mount (os.path.ismount wirft OSError)
auf den echten `mount` durch — der stapelt dann auf die Leiche. Vorfall 24.07.:
über 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) hängt 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
if geloest:
# ⚠️ Kurz durchatmen, BEVOR neu gemountet wird (Befund 26.07.2026).
# `umount -l` ist lazy: Es hängt sofort aus der Sicht aus, der eigentliche
# Abbau samt Propagation (rshared) passiert später. Wer direkt danach
# mountet, riskiert, dass dieser Abbau HINTER dem neuen Mount landet und
# der Pfad wieder auf die Leiche zeigt. Genau das hat die erste
# Reparatur regelmäßig scheitern lassen — und der zweite Versuch mit
# seinen 30 s Timeout war der Grund, warum eine Wiederanbindung rund
# 150 Sekunden brauchte. Anderthalb Sekunden Warten sparen die.
import time
time.sleep(1.5)
return geloest
def uebersetze_smb_fehler(fehler: str, mit_credentials: bool) -> str:
"""Pure Funktion (testbar): NT_STATUS-Kauderwelsch → handelbarer Klartext.
Befund 24.07.: „session setup failed: NT_STATUS_ACCESS_DENIED" hieß in
Wahrheit nur „Windows/NAS erlauben keine Gast-Abfrage" — ohne Übersetzung
rät niemand, dass oben Benutzer/Passwort fehlen.
"""
if "NT_STATUS_ACCESS_DENIED" in fehler:
if not mit_credentials:
return (
"Zugriff verweigert — dieser Rechner erlaubt keine Gast-Abfrage. "
"Benutzername + Passwort eintragen (Windows: ein Konto mit "
"Zugriff auf die Freigabe), dann erneut auflisten."
)
return (
"Zugriff verweigert — Benutzername/Passwort stimmen nicht oder das "
"Konto darf die Freigaben nicht auflisten."
)
if "NT_STATUS_LOGON_FAILURE" in fehler:
return "Anmeldung fehlgeschlagen — Benutzername oder Passwort falsch."
if (
"NT_STATUS_HOST_UNREACHABLE" in fehler
or "NT_STATUS_IO_TIMEOUT" in fehler
or "NT_STATUS_UNSUCCESSFUL" in fehler
or "NT_STATUS_CONNECTION_REFUSED" in fehler
or "Connection to" in fehler
):
return (
"Rechner nicht erreichbar — Name/IP prüfen; läuft dort ein "
"SMB-Dienst (Port 445, Datei- und Druckerfreigabe aktiv)?"
)
return fehler[:200]
def liste_smb_freigaben(host: str, username: str = "", passwort: str = "") -> list:
"""Listet die SMB-Freigaben eines Rechners (smbclient -L) — damit man
seinen PC/NAS per Klick wählt statt //host/share zu raten."""
cmd = ["smbclient", "-L", f"//{host}", "-g"]
if username:
cmd += ["-U", f"{username}%{passwort or ''}"]
else:
cmd += ["-N"]
try:
ergebnis = subprocess.run(cmd, capture_output=True, text=True, timeout=20)
except subprocess.TimeoutExpired:
raise RuntimeError(
f"Freigaben-Abfrage fehlgeschlagen: {host} antwortet nicht "
"(Timeout nach 20 s) — Name/IP und Netzwerk prüfen."
)
freigaben = []
for zeile in (ergebnis.stdout or "").splitlines():
# -g-Format: Disk|Freigabename|Kommentar
teile = zeile.split("|")
if len(teile) >= 2 and teile[0] == "Disk" and not teile[1].endswith("$"):
freigaben.append(teile[1])
if not freigaben and ergebnis.returncode != 0:
fehler = (ergebnis.stderr or ergebnis.stdout or "").strip()
raise RuntimeError(
"Freigaben-Abfrage fehlgeschlagen: "
+ uebersetze_smb_fehler(fehler, bool(username))
)
return freigaben
def unc_aus_quelle(typ: str, quelle: str) -> str:
"""`//host/freigabe` → `\\\\host\\freigabe` (pure Funktion, testbar).
Nur für CIFS/SMB. NFS gibt "" zurück: Windows kann NFS zwar einhängen
(optionale Funktion „Client für NFS"), die Schreibweise ist aber eine
andere und der Export-Pfad lässt sich nicht zuverlässig übersetzen —
raten verstößt gegen AGENTS Regel D.
"""
if typ != "cifs" or not quelle:
return ""
rest = quelle.strip()
if not rest.startswith("//"):
return ""
return "\\\\" + rest[2:].replace("/", "\\")
def pfad_map_vorschlag(eintraege: list) -> list:
"""Welcher Container-Pfad entspricht welcher Windows-Freigabe?
Das ist der fehlende Anschluss für `RIPPY_PATH_MAP` (Befund 26.07.2026):
Ein externer Worker bekommt von Rippy Pfade wie `/app/media/rippy/<job>`
— das sind Pfade INNERHALB des Containers. Er sieht sie nur, wenn sie auf
eine Netzwerk-Freigabe übersetzt werden, und dieses Mapping wurde bis
dahin von niemandem gesetzt. Externes Encoden konnte also nie laufen.
Geraten werden muss dafür nichts: Rippy hat die Freigabe selbst
eingehängt und kennt ihre Quelle (z. B. `//NAS/rippy`). Der Mountpunkt ist
`/app/media/<name>`, und beides zusammen IST das Mapping.
"""
vorschlaege = []
for eintrag in eintraege or []:
name = (eintrag.get("name") or "").strip()
if not name:
continue
typ = (eintrag.get("typ") or "").strip()
unc = unc_aus_quelle(typ, eintrag.get("quelle") or "")
vorschlaege.append({
"name": name,
"typ": typ,
"quelle": eintrag.get("quelle") or "",
"container": _mountpoint(name),
"unc": unc,
"gemountet": ist_gemountet(name),
})
return vorschlaege
def pfad_map_zeile(vorschlaege: list) -> str:
"""Die Vorschläge als fertiger RIPPY_PATH_MAP-Wert (pure Funktion).
Format wie `worker/tasks.pfad_lokal()` es liest: Paare `container=ziel`,
getrennt durch `;`. Einträge ohne übersetzbaren UNC-Pfad (NFS) fallen
weg — ein halbes Mapping wäre schlimmer als keines, weil `pfad_lokal`
beim ersten passenden Präfix aufhört.
"""
paare = [f"{v['container']}={v['unc']}" for v in vorschlaege or [] if v.get("unc")]
return ";".join(paare)
def mounten(name: str, typ: str, quelle: str, optionen: str = "",
username: str = "", passwort: str = "") -> bool:
"""Hängt ein NFS/CIFS-Ziel unter /app/media/<name> ein.
Rückgabe: True wenn das Ziel SCHREIBBAR ist. Wirft RuntimeError bei
Mount-Fehlern.
"""
ziel = _mountpoint(name)
# Den Ordner NUR anlegen, wenn er wirklich fehlt — und das mit Zeitgrenze
# klären (siehe pfad_lage): `os.makedirs(..., exist_ok=True)` hing auf einem
# toten CIFS-Mount drei Minuten im Kernel, weil `exist_ok` ein `stat` macht.
if pfad_lage(ziel) == "weg":
try:
os.makedirs(ziel, exist_ok=True)
except OSError:
pass # Rennen mit einem anderen Aufruf oder Rechte — mount sagt es
# ⚠️ ERREICHBARKEIT VOR ismount — nicht umgekehrt (Befund 26.07.2026).
#
# Vorher stand hier `if os.path.ismount(ziel): return schreibtest(ziel)`.
# Das hatte zwei Folgen, beide am selben Nachmittag gemessen:
#
# 1. Nach `docker compose up -d --build` war die CIFS-Freigabe TOT (4 von 4
# Zugriffen liefen in 10 s Timeout, /storage-mounts meldete
# `reachable: false`) — und blieb es. Der Mountpunkt existierte im neuen
# Container weiter (Bind-Mount vom Host), `ismount` sagte also „ist schon
# da" und das Wiederherstellen brach genau hier ab. Die Reparatur per
# POST /storage-mounts/rippy/repair stellte sie in einer Sekunde her —
# beim Start hätte dasselbe passieren müssen.
# 2. `schreibtest()` öffnet eine Datei OHNE Zeitgrenze. Auf einem toten CIFS
# blockiert das im Kernel (Thread-Zustand D), und `alle_remounten()`
# hängt beim API-Start dauerhaft in einem Hintergrund-Thread.
#
# `ist_erreichbar()` hat eine harte Grenze (`timeout 3 ls`). Antwortet die
# Freigabe, sind ismount und schreibtest danach gefahrlos. Antwortet sie
# nicht, fällt es unten durch: lösen, frisch mounten. Das ist genau der Weg,
# den `reparieren()` geht — nur eben automatisch.
if ist_erreichbar(name):
try:
if os.path.ismount(ziel):
return schreibtest(ziel)
except OSError:
pass # 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:
if typ == "nfs":
opts = optionen or "vers=4,soft,timeo=100"
cmd = ["mount", "-t", "nfs", "-o", opts, quelle, ziel]
elif typ == "cifs":
teile = [optionen] if optionen else []
if username:
# Credentials über Datei statt Kommandozeile (ps-sichtbar!)
creds = tempfile.NamedTemporaryFile(
"w", delete=False, prefix="cifs-", suffix=".cred"
)
creds.write(f"username={username}\npassword={passwort or ''}\n")
creds.close()
os.chmod(creds.name, 0o600)
creds_datei = creds.name
teile.append(f"credentials={creds.name}")
else:
teile.append("guest")
teile.append("iocharset=utf8")
cmd = ["mount", "-t", "cifs", "-o", ",".join(teile), quelle, ziel]
else:
raise RuntimeError(f"Unbekannter Typ: {typ} (nfs oder cifs)")
# Zwei Versuche. Grund (26.07.2026, dreimal reproduziert): Beim API-Start
# meldete `mount` Erfolg — und die Freigabe antwortete danach trotzdem
# nicht (`timeout 6 ls` lief in die Grenze, eine einzige, korrekt
# aussehende Mount-Schicht in /proc/mounts). Derselbe Ablauf ein zweites
# Mal, per POST /storage-mounts/<name>/repair, stellte sie sofort her.
# Der erste SMB-Sitzungsaufbau kurz nach dem Container-Start geht also
# gelegentlich schief, ohne es zu melden.
#
# Deshalb wird das Ergebnis GEPRÜFT statt geglaubt: Antwortet die
# Freigabe nach dem Mount nicht, wird einmal gelöst und neu gemountet.
# Und wenn das auch nicht hilft, fliegt ein Fehler — dann steht im Log
# „FEHLER" statt „eingehängt", was schlicht die Wahrheit ist.
for versuch in (1, 2):
ergebnis = subprocess.run(cmd, capture_output=True, text=True, timeout=30)
if ergebnis.returncode != 0:
fehler = (ergebnis.stderr or ergebnis.stdout or "").strip()
hinweis = ""
if "error(13)" in fehler or "Permission denied" in fehler:
hinweis = (
" — Zugriff verweigert: Benutzer/Passwort prüfen; ohne "
"Angaben versucht Rippy einen Gast-Zugriff, den Windows/"
"NAS meist ablehnen."
)
raise RuntimeError(f"mount schlug fehl: {fehler[:300]}{hinweis}")
# Zweimal mit Abstand prüfen — eine einzige Probe direkt nach dem
# Mount sieht den Wettlauf mit dem lazy umount nicht (siehe
# wirklich_erreichbar).
if wirklich_erreichbar(name):
return schreibtest(ziel)
if versuch == 1:
_lazy_umount(ziel)
raise RuntimeError(
f"{quelle} wurde eingehängt, antwortet aber nicht dauerhaft (zwei "
"Versuche). Läuft die Freigabe? Bei einem NAS im Ruhezustand hilft "
"meist ein erneutes Einhängen über Einstellungen → Speicherziele → "
"Reparieren."
)
finally:
if creds_datei:
try:
os.unlink(creds_datei)
except OSError:
pass
def _lazy_umount(ziel: str) -> None:
"""`umount -l`: hängt auch einen TOTEN/beschäftigten Mount ab (detach now,
cleanup later). Ohne das ließ sich ein Mount zu einem weggefallenen NAS
gar nicht mehr entfernen (Befund 24.07.)."""
subprocess.run(["umount", "-l", ziel], capture_output=True, text=True, timeout=30)
def aushaengen(name: str) -> None:
"""Freigabe abhängen und den Mountpunkt aufräumen.
Ohne `os.path.ismount`/`os.rmdir` als erste Schritte: Beide `stat`en den
Pfad, und auf einem toten CIFS-Mount blockiert das im Kernel (siehe
pfad_lage). Ein `umount` auf einen Pfad ohne Mount kostet dagegen nichts.
"""
ziel = _mountpoint(name)
ergebnis = subprocess.run(
["umount", ziel], capture_output=True, text=True, timeout=30
)
if ergebnis.returncode != 0:
# Toter/beschäftigter Mount → lazy detach (klappt immer)
_lazy_umount(ziel)
# Erst wenn der Pfad wieder antwortet, darf rmdir ihn anfassen.
if pfad_lage(ziel) == "da":
try:
os.rmdir(ziel)
except OSError:
pass # nicht leer oder weg — egal
def reparieren(name: str, typ: str, quelle: str, optionen: str = "",
username: str = "", passwort: str = "") -> bool:
"""Toten/veralteten Mount frisch neu verbinden: lazy abhängen, neu mounten.
Nötig, wenn ein NAS-Mount stale geworden ist (Rebuild, NAS-Schlaf) — das
normale mounten() würde am „ist schon Mountpoint" hängenbleiben.
`os.path.ismount` stand hier vorher als Vorbedingung — auf einem toten
CIFS-Mount blockiert dieses `stat` im Kernel (siehe pfad_lage). Gefragt wird
es gar nicht mehr: `umount -l` auf einen Pfad, an dem nichts hängt, kostet
nichts und meldet nur einen Rückgabewert, den hier niemand braucht.
"""
ziel = _mountpoint(name)
_lazy_umount(ziel)
return mounten(name, typ, quelle, optionen, username, passwort)
def alle_remounten() -> list:
"""Beim API-Start: alle gespeicherten Mounts wiederherstellen."""
meldungen = []
for eintrag in db.list_mounts():
try:
mounten(
eintrag["name"], eintrag["typ"], eintrag["quelle"],
eintrag.get("optionen") or "", eintrag.get("username") or "",
eintrag.get("passwort") or "",
)
meldungen.append(f"{eintrag['name']}: eingehängt")
except Exception as e:
meldungen.append(f"{eintrag['name']}: FEHLER — {e}")
return meldungen