feat: stille Datenverlust-Pfade geschlossen, gemeinsame Shell-Lib (v0.7.0)
Vor dem Rollout durchgesehen und die verbliebenen Stellen geschlossen, an denen etwas schiefgehen konnte, ohne dass es irgendwo sichtbar wurde. Datenverlust: - veraPDF: das in [verapdf].binary konfigurierte Programm wird im Preflight geprueft. Bisher galt bei falschem Pfad JEDE Datei als "nicht konform" — Ergebnis nach error/, Original geloescht (Default delete). run_verapdf() trennt jetzt ausserdem ein echtes FAIL-Urteil von einer Stoerung (VeraPdfUnavailable: nicht startbar, abgestuerzt, kein PASS/FAIL in der Ausgabe). Bei Stoerung wandern Original UND Ergebnis nach error/, das Original wird nicht entsorgt. - Gleichnamige Dateien wurden in outgoing/, error/ und beim Ordner-Upload mit abweichendem target kommentarlos ueberschrieben. Jetzt Zeitstempel daneben, mit Warnung; ProcessResult.output traegt den echten Pfad. Robustheit: - Kaputtes oder nicht lesbares TOML beim Start: Exit 2 statt Traceback. - RestartPreventExitStatus=2 in der Unit — Exit 2 (Config/Preflight) laeuft nicht mehr endlos neu, die Instanz bleibt sichtbar failed stehen. - Toter watchdog-Observer wird erkannt: Exit 3, systemd setzt den Watch neu auf. Vorher blieb die Unit "active" und verarbeitete nichts mehr. - Relative Pfade in [paths]/archive_dir/target sind ein Config-Fehler statt still unter /opt zu landen. - Fehler beim Archivieren entwertet den Durchlauf nicht mehr: Upload und Mail laufen, Sichtbarkeit ueber log.error + "OK mit Warnung"-Mail. - Nicht-PDFs in incoming/ werden beim Start-Scan gesammelt gemeldet. - Logging explizit nach stdout (die Doku versprach das schon). Struktur: - Neue lib/common.sh, von install.sh und update.sh gesourct. Die doppelte venv_is_healthy() gibt es nur noch einmal, in der gruendlichen Fassung — die schlanke in install.sh haette eine nach einem Distro-Sprung kaputte venv als gesund durchgewunken (nachgewiesen). - install.sh warnt in Containern, wenn systemd-journald nicht laeuft. Doku: Dateisystem-Festlegung (ext4/xfs/zfs, kein CIFS/NFS wegen inotify), Debian 13 in LXC auf Proxmox scheitert an journald (243/CREDENTIALS, AppArmor blockiert sd-mkdcreds) inkl. Abhilfe, echte Speicher-Messwerte, Exit-Code-Tabelle. 254 Tests gruen (vorher 152). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -22,6 +22,7 @@ from .processor import (
|
||||
ProcessResult,
|
||||
_move_to_error,
|
||||
process_pdf,
|
||||
resolve_verapdf_binary,
|
||||
)
|
||||
from .uploaders import notify_email, upload_folder, upload_nextcloud, upload_sftp
|
||||
|
||||
@@ -32,6 +33,13 @@ class PreflightError(RuntimeError):
|
||||
"""Erforderliche externe Binaries fehlen."""
|
||||
|
||||
|
||||
# Exit-Code, mit dem sich der Dienst bei totem watchdog-Observer beendet.
|
||||
# Bewusst NICHT 2: die Unit setzt RestartPreventExitStatus=2 für Config- und
|
||||
# Preflight-Fehler, die ein Neustart nicht heilt. Ein toter Observer soll
|
||||
# dagegen genau das — neu starten, damit der inotify-Watch neu aufgesetzt wird.
|
||||
EXIT_OBSERVER_DEAD = 3
|
||||
|
||||
|
||||
# Pflicht-Binaries für ocrmypdf
|
||||
_REQUIRED_BINARIES = ("tesseract", "gs")
|
||||
|
||||
@@ -140,13 +148,47 @@ def check_output_config(mode: str, archive_dir: str,
|
||||
)
|
||||
|
||||
|
||||
def check_preflight(pdfa_level: str = "", skip_text: bool = False) -> None:
|
||||
def check_verapdf_binary(enabled: bool, binary: str) -> None:
|
||||
"""Prüft das in [verapdf].binary konfigurierte Programm — wenn aktiviert.
|
||||
|
||||
Ohne diese Prüfung ist ein Tippfehler im Pfad der gefährlichste Fehler des
|
||||
ganzen Dienstes: `run_verapdf()` findet das Programm für JEDE Datei nicht,
|
||||
das OCR-Ergebnis wandert nach error/, und `_dispose_original()` löscht bei
|
||||
`original_on_success = "delete"` (dem Default) das Original. Scan für Scan
|
||||
verschwinden so die Vorlagen, während die Unit als `active (running)`
|
||||
dasteht.
|
||||
"""
|
||||
if not enabled:
|
||||
return
|
||||
if not binary:
|
||||
raise PreflightError(
|
||||
"[verapdf].enabled = true, aber [verapdf].binary ist leer. "
|
||||
"Entweder den Pfad zum veraPDF-Programm eintragen oder "
|
||||
"[verapdf].enabled = false setzen."
|
||||
)
|
||||
if resolve_verapdf_binary(binary) is None:
|
||||
raise PreflightError(
|
||||
f"[verapdf].enabled = true, aber [verapdf].binary = {binary!r} "
|
||||
"existiert nicht oder ist nicht ausführbar. Der Dienst startet "
|
||||
"bewusst nicht: ein nicht aufrufbares veraPDF würde sonst jede "
|
||||
"einzelne PDF als ungültig werten, das OCR-Ergebnis nach error/ "
|
||||
"schieben und das Original laut [output].original_on_success "
|
||||
"entsorgen. Pfad korrigieren (chmod +x nicht vergessen) oder "
|
||||
"[verapdf].enabled = false setzen."
|
||||
)
|
||||
|
||||
|
||||
def check_preflight(pdfa_level: str = "", skip_text: bool = False,
|
||||
verapdf_enabled: bool = False,
|
||||
verapdf_binary: str = "") -> None:
|
||||
"""Prüft externe Abhängigkeiten.
|
||||
|
||||
- Tesseract und Ghostscript müssen im PATH sein
|
||||
- Die Ghostscript-Version wird gegen den bekannten 10.0.0–10.02.0 Bug
|
||||
geprüft, und zwar genau unter der Bedingung, unter der ocrmypdf selbst
|
||||
abbricht (siehe `_gs_block_reason`).
|
||||
- Ist [verapdf].enabled gesetzt, muss auch das dort konfigurierte
|
||||
Programm vorhanden und ausführbar sein (siehe `check_verapdf_binary`).
|
||||
|
||||
Wirft PreflightError bei fehlenden Binaries oder unsicherem Ghostscript.
|
||||
"""
|
||||
@@ -161,6 +203,8 @@ def check_preflight(pdfa_level: str = "", skip_text: bool = False) -> None:
|
||||
if reason:
|
||||
raise PreflightError(reason)
|
||||
|
||||
check_verapdf_binary(verapdf_enabled, verapdf_binary)
|
||||
|
||||
|
||||
def _gs_block_reason(pdfa_level: str, skip_text: bool) -> str | None:
|
||||
"""Liefert die Fehlermeldung, wenn ocrmypdf mit diesem Ghostscript abbricht.
|
||||
@@ -298,11 +342,20 @@ class HotfolderService:
|
||||
|
||||
# ---- Lifecycle ----
|
||||
|
||||
def run(self) -> None:
|
||||
check_preflight(self.cfg.ocr.pdfa_level, self.cfg.ocr.skip_text)
|
||||
def _preflight(self) -> None:
|
||||
check_preflight(self.cfg.ocr.pdfa_level, self.cfg.ocr.skip_text,
|
||||
self.cfg.verapdf.enabled, self.cfg.verapdf.binary)
|
||||
check_output_config(self.cfg.output.original_on_success,
|
||||
self.cfg.output.archive_dir,
|
||||
self.cfg.output.name_mode)
|
||||
|
||||
def run(self) -> int:
|
||||
"""Startet den Dienst und läuft, bis gestoppt wird.
|
||||
|
||||
Returns:
|
||||
0 bei regulärem Stopp (SIGTERM/SIGINT), sonst `EXIT_OBSERVER_DEAD`.
|
||||
"""
|
||||
self._preflight()
|
||||
self.ensure_dirs()
|
||||
self._scan_existing()
|
||||
|
||||
@@ -315,21 +368,49 @@ class HotfolderService:
|
||||
signal.signal(signal.SIGINT, lambda *_: self._stop.set())
|
||||
|
||||
try:
|
||||
while not self._stop.is_set():
|
||||
self._stop.wait(1.0)
|
||||
return self._wait_loop()
|
||||
finally:
|
||||
self.shutdown()
|
||||
|
||||
def _wait_loop(self) -> int:
|
||||
"""Hauptschleife: wartet auf den Stopp und bewacht den Observer.
|
||||
|
||||
Stirbt der watchdog-Observer im Betrieb (erschöpftes
|
||||
inotify-Watch-Limit, ersetztes oder neu gemountetes Verzeichnis),
|
||||
blieb die Unit bisher `active (running)` und verarbeitete nichts mehr:
|
||||
kein Log, keine Mail, niemand merkt es. Für einen Hotfolder ist das
|
||||
der schlechteste denkbare Zustand. Deshalb wird der Observer
|
||||
sekündlich mitgeprüft und der Dienst im Ernstfall mit
|
||||
`EXIT_OBSERVER_DEAD` beendet, damit systemd ihn per
|
||||
`Restart=on-failure` neu startet und den Watch neu aufsetzt.
|
||||
"""
|
||||
while not self._stop.is_set():
|
||||
self._stop.wait(1.0)
|
||||
if self._stop.is_set():
|
||||
# Regulärer Stopp — hier darf kein Fehlalarm entstehen, auch
|
||||
# wenn der Observer planmäßig schon gestoppt wurde.
|
||||
break
|
||||
if self._observer is not None and not self._observer.is_alive():
|
||||
log.error(
|
||||
"Der Verzeichnis-Watch auf %s ist gestorben — es werden "
|
||||
"KEINE neuen Dateien mehr erkannt. Mögliche Ursachen: "
|
||||
"erschöpftes inotify-Watch-Limit "
|
||||
"(fs.inotify.max_user_watches), ersetztes oder neu "
|
||||
"gemountetes Verzeichnis. Der Dienst beendet sich mit "
|
||||
"Exit %d, damit systemd ihn neu startet und der Watch "
|
||||
"neu aufgesetzt wird.",
|
||||
self.cfg.paths.incoming, EXIT_OBSERVER_DEAD,
|
||||
)
|
||||
return EXIT_OBSERVER_DEAD
|
||||
return 0
|
||||
|
||||
def run_once(self) -> int:
|
||||
"""Verarbeitet alle bereits liegenden PDFs (incoming/ + working/) und beendet sich.
|
||||
|
||||
Returns:
|
||||
Anzahl fehlgeschlagener PDFs (0 = alles ok).
|
||||
"""
|
||||
check_preflight(self.cfg.ocr.pdfa_level, self.cfg.ocr.skip_text)
|
||||
check_output_config(self.cfg.output.original_on_success,
|
||||
self.cfg.output.archive_dir,
|
||||
self.cfg.output.name_mode)
|
||||
self._preflight()
|
||||
self.ensure_dirs()
|
||||
self._scan_existing()
|
||||
self._executor.shutdown(wait=True)
|
||||
@@ -356,9 +437,32 @@ class HotfolderService:
|
||||
incoming-Datei nach working/ will.
|
||||
"""
|
||||
self._scan_working()
|
||||
fremd: list[str] = []
|
||||
for p in sorted(self.cfg.paths.incoming.iterdir()):
|
||||
if _is_pdf(p):
|
||||
self.enqueue(p)
|
||||
elif p.is_file():
|
||||
fremd.append(p.name)
|
||||
self._report_non_pdf(fremd)
|
||||
|
||||
def _report_non_pdf(self, names: list[str]) -> None:
|
||||
"""Meldet einmalig, wie viele Fremddateien in incoming/ liegen.
|
||||
|
||||
Alles ohne .pdf-Endung wird ignoriert und sammelte sich bisher stumm
|
||||
an — Scanner-Fehlablagen, abgebrochene Uploads, Thumbnails. Eine
|
||||
Sammelmeldung beim Start-Scan, keine Zeile pro Datei und nichts im
|
||||
laufenden Betrieb: das soll auffallen, nicht spammen.
|
||||
"""
|
||||
if not names:
|
||||
return
|
||||
beispiele = ", ".join(names[:3])
|
||||
if len(names) > 3:
|
||||
beispiele += f", … (+{len(names) - 3} weitere)"
|
||||
log.warning(
|
||||
"In %s liegen %d Datei(en) ohne .pdf-Endung — sie werden nicht "
|
||||
"verarbeitet und bleiben dort liegen: %s",
|
||||
self.cfg.paths.incoming, len(names), beispiele,
|
||||
)
|
||||
|
||||
def _scan_working(self) -> None:
|
||||
"""Greift Dateien auf, die ein harter Stopp in working/ liegen ließ.
|
||||
@@ -563,6 +667,19 @@ class HotfolderService:
|
||||
notify_email(self.cfg.email, subject, body, False)
|
||||
|
||||
def _notify(self, result: ProcessResult) -> None:
|
||||
if result.success and result.warning:
|
||||
# Erfolgreich verarbeitet, aber das Original blieb liegen. Der
|
||||
# Durchlauf zählt als Erfolg (das PDF ist fertig und ausgeliefert),
|
||||
# die Mail geht aber als Nicht-Erfolg raus, damit sie auch bei
|
||||
# [notify.email].on = "errors" zugestellt wird — sonst wäre das
|
||||
# genau wieder ein stiller Fehlerpfad.
|
||||
subject = f"[pdf-ocr] OK mit Warnung: {result.source.name}"
|
||||
body = (
|
||||
f"Datei verarbeitet: {result.output}\n\n"
|
||||
f"ACHTUNG: {result.warning}\n"
|
||||
)
|
||||
notify_email(self.cfg.email, subject, body, False)
|
||||
return
|
||||
if result.success:
|
||||
subject = f"[pdf-ocr] OK: {result.source.name}"
|
||||
body = f"Datei verarbeitet: {result.output}\n"
|
||||
|
||||
Reference in New Issue
Block a user