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
+48 -6
View File
@@ -27,13 +27,32 @@ CHECK_ERROR = 2
def _setup_logging(level: str) -> None:
# stream explizit auf stdout: der Default von basicConfig() ist stderr,
# README und docs/INSTALLATION.md versprechen aber stdout. Für journald
# ist das egal, für den dort beschriebenen Vordergrund-Notbehelf und für
# jeden, der die Ausgabe weiterleitet, nicht.
logging.basicConfig(
level=getattr(logging, level.upper(), logging.INFO),
format="%(asctime)s %(levelname)-7s %(name)s: %(message)s",
datefmt="%Y-%m-%d %H:%M:%S",
stream=sys.stdout,
)
def _toml_error_text(cfg_path: Path, exc: tomllib.TOMLDecodeError) -> str:
"""Formuliert die Meldung für kaputtes TOML — mit Zeile/Spalte, wenn möglich.
`TOMLDecodeError.lineno`/`.colno` gibt es erst ab Python 3.14. Auf Debian
12 (Python 3.11) fehlen die Attribute, dort steht die Position nur im
Meldungstext ("... (at line 3, column 12)") — deshalb `getattr` statt
direktem Zugriff.
"""
lineno = getattr(exc, "lineno", None)
colno = getattr(exc, "colno", None)
pos = f" (Zeile {lineno}, Spalte {colno})" if lineno is not None else ""
return f"{cfg_path} ist kein gültiges TOML{pos}: {exc}"
def _log_config_warnings(cfg: Config) -> None:
"""Schreibt Legacy- und Unbekannt-Warnungen beim Dienststart ins Log."""
for warning in config_warnings(cfg):
@@ -55,7 +74,7 @@ def check_config(cfg_path: Path) -> int:
print(f"FEHLER: {e}", file=sys.stderr)
return CHECK_ERROR
except tomllib.TOMLDecodeError as e:
print(f"FEHLER: {cfg_path} ist kein gültiges TOML: {e}", file=sys.stderr)
print(f"FEHLER: {_toml_error_text(cfg_path, e)}", file=sys.stderr)
return CHECK_ERROR
except OSError as e:
print(f"FEHLER: {cfg_path} nicht lesbar: {e}", file=sys.stderr)
@@ -76,10 +95,19 @@ def check_config(cfg_path: Path) -> int:
print(f" ocrmypdf = {detect_ocrmypdf_version() or '(nicht installiert)'}")
print(f" Ghostscript = {detect_ghostscript_version() or '(nicht gefunden)'}")
if cfg.verapdf.enabled:
print(f" veraPDF = {cfg.verapdf.binary} (Flavour "
f"{cfg.verapdf.flavour})")
else:
print(" veraPDF = (aus)")
errors: list[str] = []
try:
check_preflight(cfg.ocr.pdfa_level, cfg.ocr.skip_text)
print(" Preflight ok (tesseract, gs vorhanden, Ghostscript-Version "
check_preflight(cfg.ocr.pdfa_level, cfg.ocr.skip_text,
cfg.verapdf.enabled, cfg.verapdf.binary)
print(" Preflight ok (tesseract, gs"
+ (", veraPDF" if cfg.verapdf.enabled else "")
+ " vorhanden, Ghostscript-Version "
"passt zu ocrmypdf + [ocr]-Einstellungen).")
except PreflightError as e:
errors.append(str(e))
@@ -142,6 +170,18 @@ def main() -> int:
except ConfigError as e:
print(f"FEHLER: {e}", file=sys.stderr)
return 2
except tomllib.TOMLDecodeError as e:
# Ohne diesen Zweig endet ein Tippfehler in der Config (unbalancierte
# Anführungszeichen o.ä.) beim Dienststart in einem nackten Traceback.
# Dieselbe Behandlung wie in --check-config: verständliche Meldung,
# Exit 2 = Config-Fehler.
print(f"FEHLER: {_toml_error_text(cfg_path, e)}", file=sys.stderr)
print("Der Dienst startet nicht. Config korrigieren und mit "
"--check-config gegenprüfen.", file=sys.stderr)
return 2
except OSError as e:
print(f"FEHLER: {cfg_path} nicht lesbar: {e}", file=sys.stderr)
return 2
_setup_logging(cfg.log_level)
_log_config_warnings(cfg)
@@ -156,13 +196,15 @@ def main() -> int:
return 1 if errors > 0 else 0
try:
service.run()
# run() liefert 0 bei regulärem Stopp und EXIT_OBSERVER_DEAD, wenn der
# Verzeichnis-Watch gestorben ist — Letzteres muss nach außen
# durchschlagen, sonst startet systemd den Dienst nicht neu.
return service.run()
except PreflightError as e:
print(f"FEHLER: {e}", file=sys.stderr)
return 2
except KeyboardInterrupt:
pass
return 0
return 0
if __name__ == "__main__":