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:
2026-09-23 00:59:17 +02:00
parent 305454eeb5
commit cd803a3dfe
28 changed files with 2902 additions and 286 deletions
+200 -37
View File
@@ -2,9 +2,11 @@
from __future__ import annotations
import logging
import os
import shutil
import subprocess
from dataclasses import dataclass
from datetime import datetime
from pathlib import Path
from .config import OcrConfig, OutputConfig, VeraPdfConfig
@@ -45,6 +47,27 @@ def build_output_name(src_name: str, mode: str, tag: str) -> str:
raise ValueError(f"Unbekannter name_mode: {mode!r}")
class VeraPdfUnavailable(RuntimeError):
"""veraPDF konnte nicht befragt werden — Programm fehlt, startet nicht, Timeout.
Ausdrücklich KEIN inhaltliches Urteil über die PDF. Der Unterschied ist
existenziell: ein nicht startbares veraPDF, das wie ein FAIL behandelt
wird, schiebt JEDES OCR-Ergebnis nach error/ und entsorgt das Original
laut [output].original_on_success — bei dessen Default `delete` also
Scan für Scan die Vorlage, während der Dienst als "läuft" dasteht.
"""
# veraPDF schreibt mit `--format text` pro Datei eine Zeile, die mit dem
# Urteil beginnt. Steht in der Ausgabe weder PASS noch FAIL, hat veraPDF gar
# nichts geprüft (fehlendes Java, kaputter Wrapper, falsches Flavour) — das
# ist kein "nicht konform", sondern ein fehlendes Urteil.
_VERAPDF_VERDICTS = ("PASS", "FAIL")
# Sekunden, die veraPDF pro Datei laufen darf
VERAPDF_TIMEOUT = 300
@dataclass
class ProcessResult:
source: Path
@@ -52,6 +75,9 @@ class ProcessResult:
success: bool
error: str = ""
verapdf_passed: bool | None = None
# Gesetzt, wenn der Durchlauf erfolgreich war, aber etwas Nennenswertes
# danebenlief (aktuell: das Original ließ sich nicht entsorgen).
warning: str = ""
def run_ocr(src: Path, dst: Path, cfg: OcrConfig) -> None:
@@ -86,24 +112,65 @@ def run_ocr(src: Path, dst: Path, cfg: OcrConfig) -> None:
log.info("OCR done: %s", dst.name)
def resolve_verapdf_binary(binary: str) -> str | None:
"""Sucht das veraPDF-Programm und prüft, ob es ausführbar ist.
Beide Schreibweisen sind zulässig: ein Pfad (`/opt/verapdf/verapdf`, der
Default) wird direkt geprüft, ein nackter Name (`verapdf`) im PATH
gesucht.
Returns:
Der aufrufbare Pfad oder None.
"""
if not binary:
return None
if os.sep in binary:
p = Path(binary)
return str(p) if p.is_file() and os.access(p, os.X_OK) else None
return shutil.which(binary)
def run_verapdf(pdf: Path, cfg: VeraPdfConfig) -> bool:
"""Validiert PDF/A mit veraPDF (CLI). Gibt True zurück, wenn konform."""
"""Validiert PDF/A mit veraPDF (CLI). Gibt True zurück, wenn konform.
Returns:
True = konform (PASS), False = nicht konform (FAIL).
Raises:
VeraPdfUnavailable: veraPDF ließ sich nicht befragen. Das ist kein
FAIL — siehe Klassen-Docstring.
"""
if not cfg.enabled:
return True
if not Path(cfg.binary).exists():
log.warning("veraPDF binary nicht gefunden: %s", cfg.binary)
return False
binary = resolve_verapdf_binary(cfg.binary)
if binary is None:
raise VeraPdfUnavailable(
f"[verapdf].binary = {cfg.binary!r} existiert nicht oder ist nicht "
"ausführbar"
)
try:
result = subprocess.run(
[cfg.binary, "--flavour", cfg.flavour, "--format", "text", str(pdf)],
capture_output=True, text=True, timeout=300,
[binary, "--flavour", cfg.flavour, "--format", "text", str(pdf)],
capture_output=True, text=True, timeout=VERAPDF_TIMEOUT,
)
ok = result.returncode == 0 and "PASS" in result.stdout
log.info("veraPDF %s: %s", "PASS" if ok else "FAIL", pdf.name)
return ok
except subprocess.TimeoutExpired:
log.error("veraPDF Timeout: %s", pdf.name)
return False
except subprocess.TimeoutExpired as e:
raise VeraPdfUnavailable(
f"veraPDF hat für {pdf.name} nach {VERAPDF_TIMEOUT} s nicht "
"geantwortet"
) from e
except OSError as e:
raise VeraPdfUnavailable(f"veraPDF ({binary}) nicht startbar: {e}") from e
if not any(v in result.stdout for v in _VERAPDF_VERDICTS):
ausgabe = (result.stdout + result.stderr).strip().replace("\n", " ")
raise VeraPdfUnavailable(
f"veraPDF ({binary}) hat kein Urteil geliefert "
f"(Exit {result.returncode}): {ausgabe[:300] or '(keine Ausgabe)'}"
)
ok = result.returncode == 0 and "PASS" in result.stdout
log.info("veraPDF %s: %s", "PASS" if ok else "FAIL", pdf.name)
return ok
def process_pdf(
@@ -151,7 +218,24 @@ def process_pdf(
vera_ok: bool | None = None
if vera_cfg.enabled:
vera_ok = run_verapdf(work_out, vera_cfg)
try:
vera_ok = run_verapdf(work_out, vera_cfg)
except VeraPdfUnavailable as e:
# Kein Urteil über die Datei — also darf auch nichts entsorgt
# werden. Original UND OCR-Ergebnis gehen nach error/; das
# Original bleibt damit unabhängig von
# [output].original_on_success erhalten.
log.error(
"veraPDF nicht aufrufbar (%s) — %s wird NICHT als ungültig "
"gewertet: Original und OCR-Ergebnis liegen in %s, das "
"Original wurde weder gelöscht noch archiviert. "
"[verapdf].binary prüfen (--check-config)",
e, src.name, error_dir,
)
_move_to_error(work_out, error_dir)
_move_to_error(work_src, error_dir)
return ProcessResult(src, final_out, False,
f"veraPDF nicht aufrufbar: {e}")
if not vera_ok:
# Das OCR-Ergebnis ist unbrauchbar und wandert nach error/. Das
# Original wird aber NICHT bedingungslos gelöscht: es folgt derselben
@@ -168,9 +252,26 @@ def process_pdf(
"verapdf validation failed", verapdf_passed=False)
outgoing_dir.mkdir(parents=True, exist_ok=True)
# Liegt in outgoing/ schon eine Datei desselben Namens (Scanner liefert
# denselben Dateinamen ein zweites Mal, oder das Vorgängerergebnis wurde
# noch nicht abgeholt), würde der move sie kommentarlos überschreiben.
# Stattdessen derselbe Zeitstempel-Ausweg wie im Archiv.
final_out = _collision_free_path(final_out)
if final_out.name != out_name:
log.warning(
"In %s liegt bereits eine Datei %s — das neue OCR-Ergebnis wird "
"als %s abgelegt, damit das ältere nicht überschrieben wird",
outgoing_dir, out_name, final_out.name,
)
shutil.move(str(work_out), str(final_out))
_dispose_original(work_src, src.name, output_cfg)
return ProcessResult(src, final_out, True, verapdf_passed=vera_ok)
# Scheitert die Entsorgung des Originals (Platte voll, read-only), ist der
# Durchlauf trotzdem gelungen: das fertige PDF liegt bereits in outgoing/.
# Der Fehler darf ihn deshalb nicht entwerten — sonst unterbleibt der
# Upload und das Ergebnis bleibt liegen. Er wird als Warnung
# weitergereicht und landet in der Benachrichtigung.
warning = _dispose_original(work_src, src.name, output_cfg)
return ProcessResult(src, final_out, True, verapdf_passed=vera_ok,
warning=warning)
def _is_same_file(a: Path, b: Path) -> bool:
@@ -181,41 +282,103 @@ def _is_same_file(a: Path, b: Path) -> bool:
return False
def _dispose_original(work_src: Path, original_name: str, cfg: OutputConfig) -> None:
def _collision_free_path(dest: Path) -> Path:
"""Weicht einem schon belegten Zielnamen per Zeitstempel-Suffix aus.
Einheitlich für outgoing/ und Archiv: `scan.pdf` wird zu
`scan_20260923-081500.pdf`. Ist auch der Zeitstempel-Name belegt (zwei
Dateien innerhalb derselben Sekunde, z.B. bei mehreren Workern), wird
zusätzlich hochgezählt — sonst überschriebe der anschließende `move` doch
wieder still.
Der Rest bleibt unverändert: existiert das Ziel nicht, kommt es
unverändert zurück.
"""
if not dest.exists():
return dest
ts = datetime.now().strftime("%Y%m%d-%H%M%S")
candidate = dest.with_name(f"{dest.stem}_{ts}{dest.suffix}")
counter = 2
while candidate.exists():
candidate = dest.with_name(f"{dest.stem}_{ts}-{counter}{dest.suffix}")
counter += 1
return candidate
def _dispose_original(work_src: Path, original_name: str, cfg: OutputConfig) -> str:
"""Entsorgt das Original laut [output].original_on_success — löschen oder archivieren.
Wird nach erfolgreichem OCR aufgerufen und ebenso, wenn veraPDF die
Validierung ablehnt: auch dann soll `archive` das Original erhalten.
Wirft bewusst NICHT: zum Aufrufzeitpunkt liegt das fertige PDF schon in
outgoing/. Eine Exception von hier würde den gelungenen Durchlauf im
Catch-all des Service in einen Fehler verwandeln — mitsamt
ausgefallenem Upload.
Returns:
Leerer String = erledigt. Sonst die Fehlermeldung (bereits geloggt).
"""
if not work_src.exists():
return
return ""
mode = cfg.original_on_success
if mode == "delete":
work_src.unlink(missing_ok=True)
return
if mode == "archive":
if not cfg.archive_dir:
log.error("original_on_success=archive aber archive_dir ist leer — lösche stattdessen")
work_src.unlink(missing_ok=True)
return
if mode == "archive" and cfg.archive_dir:
archive = Path(cfg.archive_dir)
archive.mkdir(parents=True, exist_ok=True)
dest = archive / original_name
# Bei Namens-Kollision mit Timestamp umbenennen
if dest.exists():
from datetime import datetime
ts = datetime.now().strftime("%Y%m%d-%H%M%S")
dest = archive / f"{dest.stem}_{ts}{dest.suffix}"
shutil.move(str(work_src), str(dest))
try:
archive.mkdir(parents=True, exist_ok=True)
# Bei Namens-Kollision mit Timestamp umbenennen (gleicher Weg wie
# für das Ergebnis in outgoing/)
dest = _collision_free_path(archive / original_name)
shutil.move(str(work_src), str(dest))
except OSError as e:
return _disposal_failed(work_src, original_name,
f"nicht nach {archive} archiviert", e)
log.info("Original archiviert: %s", dest)
return
log.warning("Unbekannter original_on_success=%r — lösche stattdessen", mode)
work_src.unlink(missing_ok=True)
return ""
if mode == "archive":
log.error("original_on_success=archive aber archive_dir ist leer — "
"lösche stattdessen")
elif mode != "delete":
log.warning("Unbekannter original_on_success=%r — lösche stattdessen", mode)
try:
work_src.unlink(missing_ok=True)
except OSError as e:
return _disposal_failed(work_src, original_name, "nicht gelöscht", e)
return ""
def _disposal_failed(work_src: Path, original_name: str, was: str,
exc: OSError) -> str:
"""Einheitliche Meldung, wenn das Original nicht entsorgt werden konnte."""
msg = (
f"Original {original_name} konnte {was} werden ({exc}). Das OCR-PDF ist "
f"fertig und wird normal ausgeliefert, das Original liegt aber "
f"weiterhin in {work_src.parent} — es wird beim nächsten Start dort "
f"aufgegriffen und ein zweites Mal durch das OCR geschickt. Bitte "
f"{work_src} von Hand aufräumen und die Ursache beheben "
f"(Plattenplatz, Schreibrechte)."
)
log.error("%s", msg)
return msg
def _move_to_error(p: Path, error_dir: Path) -> None:
"""Verschiebt eine Datei ins error-Verzeichnis, ohne dort etwas zu überschreiben.
Scheitert dieselbe `scan.pdf` zweimal, ersetzte die zweite bisher still die
erste — dieselbe Datenverlust-Klasse wie in outgoing/. Deshalb derselbe
Zeitstempel-Ausweg über `_collision_free_path()`.
"""
error_dir.mkdir(parents=True, exist_ok=True)
dest = _collision_free_path(error_dir / p.name)
if dest.name != p.name:
log.warning(
"In %s liegt bereits eine Datei %s — die neue wird als %s abgelegt, "
"damit die ältere nicht überschrieben wird",
error_dir, p.name, dest.name,
)
try:
shutil.move(str(p), str(error_dir / p.name))
shutil.move(str(p), str(dest))
except OSError:
log.exception("Konnte %s nicht in error-Verzeichnis verschieben", p)