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
+183
View File
@@ -1,5 +1,188 @@
# Changelog
## [0.7.0] - 2026-09-23
Schliesst die stillen Datenverlust-Pfade: gleichnamige Dateien werden nirgends
mehr ueberschrieben, und ein nicht aufrufbares veraPDF wird nicht mehr als
"PDF ist ungueltig" missverstanden. Dazu Robustheit (Exit 2 statt Traceback,
toter Verzeichnis-Watch faellt auf, relative Pfade sind ein Config-Fehler) und
eine gemeinsame Shell-Bibliothek fuer install.sh und update.sh.
Test-Suite: 254 pytest-Tests gruen.
### Added
- **`lib/common.sh` — gemeinsame Shell-Bibliothek.** `install.sh` und
`update.sh` sourcen sie und teilen sich darueber Log-Funktionen
(`log_info`/`log_warn`/`log_error`/`log_step`), `require_root`, die
Layout-Konstanten (`INSTALL_DIR`, `CONFIG_DIR`, `DATA_ROOT`, `SYSTEMD_DIR`,
`DEFAULT_USER`, `SERVICE_TEMPLATE`, `LXC_DROPIN*`), die apt-Paketliste
(`pdf_ocr_apt_packages()` zwischen den BEGIN/END-Marken) und die
venv-Pruefung (`py_mm`, `pyvenv_cfg_mm`, `venv_is_healthy`,
`report_venv_issues`).
- Die **Paketliste** steht damit nicht mehr in `install.sh`, sondern in
`lib/common.sh`; `update.sh` schneidet sie weiterhin per `sed` aus der
**Repo**-Fassung heraus, damit beim Update die neue Liste gilt und nicht
die vielleicht aeltere, bereits gesourcte aus der Installation.
- `venv_is_healthy()` gab es bisher **zweimal** — die schlanke Variante in
`install.sh` haette den Distro-Upgrade-Fall (`pyvenv.cfg` gegen
System-Python) nicht erkannt. Jetzt existiert nur noch die gruendliche
Fassung, und `install.sh` nennt die Befunde ueber `report_venv_issues`.
- `lib/` wird von `install.sh` **und** `update.sh` nach
`/opt/pdf-ocr-hotfolder/lib/` mitkopiert und liegt damit im
Update-Backup. `update.sh` sourct bevorzugt die Repo-Fassung neben sich
und faellt auf die installierte zurueck; fehlt sie ueberall, bricht es
sofort ab statt mitten im Lauf mit "command not found".
- **veraPDF wird im Preflight geprueft** (`check_verapdf_binary()`,
`resolve_verapdf_binary()`). Mit `[verapdf].enabled = true` muss
`[verapdf].binary` auf ein vorhandenes, ausfuehrbares Programm zeigen —
sonst startet der Dienst gar nicht erst (Exit 2), und `--check-config`
meldet den Fehler. Ein Pfad mit `/` wird direkt geprueft, ein nackter Name
im `PATH` gesucht. `--check-config` zeigt jetzt ausserdem Binary und Flavour
bzw. `(aus)` an.
Hintergrund: ein Tippfehler im Pfad war der **gefaehrlichste Fehler des
ganzen Dienstes**. `run_verapdf()` fand das Programm fuer JEDE Datei nicht,
wertete das als FAIL, schob das OCR-Ergebnis nach `error/` — und
`_dispose_original()` entsorgte das Original laut
`[output].original_on_success`, bei dessen Default `delete` also Scan fuer
Scan die Vorlage. Die Unit stand dabei als `active (running)` da.
- **Exit 3: toter Verzeichnis-Watch** (`EXIT_OBSERVER_DEAD` in `service.py`).
Stirbt der watchdog-Observer im Betrieb (erschoepftes
`fs.inotify.max_user_watches`, ersetztes oder neu gemountetes Verzeichnis),
blieb die Unit bisher `active (running)` und verarbeitete nichts mehr — kein
Log, keine Mail, niemand merkt es. Die Hauptschleife prueft den Observer
jetzt sekuendlich mit, loggt im Ernstfall die moeglichen Ursachen und
beendet sich mit Exit 3; `Restart=on-failure` startet den Dienst neu und der
Watch wird neu aufgesetzt. Bewusst **nicht** Exit 2 — den unterdrueckt die
Unit jetzt beim Neustart (s.u.).
- **`ProcessResult.warning`** — erfolgreicher Durchlauf mit Nebenbefund. Bisher
gab es nur Erfolg oder Fehler; ein liegengebliebenes Original passte in
keine der beiden Schubladen.
- **Sammelmeldung fuer Nicht-PDF-Dateien in `incoming/`**
(`_report_non_pdf()`). Alles ohne `.pdf`-Endung wurde ignoriert und
sammelte sich stumm an (Scanner-Fehlablagen, abgebrochene Uploads,
Thumbnails). Beim Start-Scan gibt es jetzt **eine** Warnung mit Anzahl und
bis zu drei Beispielnamen — keine Zeile pro Datei und nichts im laufenden
Betrieb.
- **`install.sh` warnt beim Erstinstall in Containern, wenn
`systemd-journald` nicht laeuft.** Der Dienst loggt ausschliesslich nach
journald; ist journald kaputt, gibt es gar keine Logs. Die Warnung nennt den
Drop-in-Befehl fuer den Debian-13-Fall und fragt, ob fortgefahren werden
soll.
- **Dokumentation** (siehe unten unter *Docs*): Dateisystem-Festlegung,
Debian-13-journald-Befund, konkrete Speicher-Messwerte, Exit-Code-Tabelle.
### Fixed
- **`outgoing/`: gleichnamige Datei wird nicht mehr ueberschrieben.** Liefert
der Scanner denselben Dateinamen ein zweites Mal (oder wurde das
Vorgaengerergebnis noch nicht abgeholt), legte der `shutil.move` die
aeltere Datei kommentarlos um. Neu: `_collision_free_path()` haengt einen
Zeitstempel an (`scan.pdf` -> `scan_20260923-081500.pdf`), bei Kollision
innerhalb derselben Sekunde zusaetzlich einen Zaehler. Es gibt eine
`log.warning`, und `ProcessResult.output` traegt den **tatsaechlich**
geschriebenen Pfad — die Upload-Ziele und die Mail nennen damit die richtige
Datei.
- **Dieselbe Klasse in `error/` und beim Ordner-Upload.** `_move_to_error()`
(und damit auch `_rescue_to_error()`) sowie `upload_folder()` mit
abweichendem `[upload.folder].target` ersetzten bisher still eine
gleichnamige Datei im Ziel. Beide nutzen jetzt denselben
Zeitstempel-Ausweg. Scheitert dieselbe `scan.pdf` zweimal, liegen jetzt
beide Fassungen in `error/`.
- **veraPDF: Stoerung wird nicht mehr als FAIL gewertet.** `run_verapdf()`
unterscheidet jetzt zwischen einem echten Urteil (PASS/FAIL) und
"Programm nicht aufrufbar, nicht startbar, Timeout oder kein PASS/FAIL in
der Ausgabe" — Letzteres wirft `VeraPdfUnavailable`. Im Stoerungsfall
wandern **Original UND OCR-Ergebnis** nach `error/`, und das Original wird
weder geloescht noch archiviert, unabhaengig von
`[output].original_on_success`. Vorher lieferte jeder dieser Faelle
schlicht `False` und damit ein Fehlurteil ueber die Datei.
- **Kaputtes TOML und nicht lesbare Config beenden den Dienststart mit
Exit 2** statt mit einem nackten Traceback. `main()` faengt jetzt
`tomllib.TOMLDecodeError` und `OSError` genauso ab wie `--check-config`; die
Meldung nennt Zeile und Spalte, sofern der Interpreter sie liefert
(`TOMLDecodeError.lineno`/`.colno` gibt es erst ab Python 3.14 — auf
Debian 12 steht die Position nur im Meldungstext, deshalb `getattr`).
- **Relative Pfade in der Config sind jetzt ein Fehler.** `[paths].incoming`,
`outgoing`, `working`, `error` sowie `[output].archive_dir` und
`[upload.folder].target` muessen absolut sein (`_require_absolute()`,
`ConfigError` + Exit 2). Ein relativer Pfad wurde gegen das
`WorkingDirectory` der Unit aufgeloest und landete still unter
`/opt/pdf-ocr-hotfolder/` — der Scanner schrieb dann woanders hin als der
Dienst schaute, ohne dass irgendwo ein Fehler auftauchte. Leere Werte
bleiben erlaubt (`archive_dir`/`target` sind optional).
- **Ein Fehler beim Entsorgen des Originals entwertet den Durchlauf nicht
mehr.** `_dispose_original()` wirft nicht mehr, sondern liefert eine
Meldung zurueck: zum Aufrufzeitpunkt liegt das fertige PDF schon in
`outgoing/`, eine Exception von dort haette den gelungenen Durchlauf im
Catch-all des Service in einen Fehler verwandelt — mitsamt ausgefallenem
Upload. Jetzt gilt der Lauf als Erfolg, Upload und Benachrichtigung laufen,
es gibt aber eine `log.error` (Original liegt noch in `working/` und wird
beim naechsten Start erneut durch das OCR geschickt), und die Mail geht als
**"OK mit Warnung"** auch bei `[notify.email].on = "errors"` raus — sonst
waere genau das wieder ein stiller Fehlerpfad.
- **Log geht explizit nach stdout.** `logging.basicConfig()` schreibt per
Default nach **stderr**; README und `docs/INSTALLATION.md` versprachen aber
stdout. Fuer journald egal, fuer den dort beschriebenen
Vordergrund-Notbehelf und fuer jede Weiterleitung nicht.
### Changed
- **`systemd/pdf-ocr-hotfolder@.service`: `RestartPreventExitStatus=2`.**
Exit 2 = Config- oder Preflight-Fehler, den behebt kein Neustart. Bisher
startete `Restart=on-failure` die Instanz endlos im 5-Sekunden-Takt neu
(das Start-Rate-Limit greift bei `RestartSec=5` nie). Jetzt bleibt die
Instanz sichtbar `failed` stehen.
- `HotfolderService.run()` gibt einen **Exit-Code** zurueck (0 oder
`EXIT_OBSERVER_DEAD`) statt `None`; `main()` reicht ihn durch. Preflight und
`check_output_config()` stehen jetzt gebuendelt in `_preflight()`, das
`run()` und `run_once()` gemeinsam nutzen.
- `check_preflight()` nimmt zwei weitere Parameter (`verapdf_enabled`,
`verapdf_binary`) — beide mit Default, bestehende Aufrufe bleiben gueltig.
- `config.example.toml`: Warnhinweise zu absoluten Pfaden (`[paths]`,
`[output].archive_dir`, `[upload.folder].target`), zur veraPDF-Preflight-
Pruefung samt Begruendung und zum Kollisionsverhalten im Upload-Ziel.
- `install.sh` ist von ~73 Zeilen Kopf auf das Sourcen von `lib/common.sh`
geschrumpft und nutzt durchgehend `SYSTEMD_DIR`/`LXC_DROPIN` statt
hartkodierter Pfade.
### Docs
- **`docs/INSTALLATION.md`, Systemanforderungen: Dateisystem festgelegt.** Der
Dienst laeuft ausschliesslich auf **ext4, xfs oder zfs**. `incoming/` gehoert
auf ein lokales, inotify-faehiges Dateisystem — **kein CIFS/NFS-Mount**: dort
liefert inotify grundsaetzlich keine Events, weil Schreibzugriffe anderer
Rechner am lokalen Kernel vorbeigehen. Der Dienst wuerde dann nur noch beim
Start etwas verarbeiten und im laufenden Betrieb nichts mehr bemerken.
- **Speicher-Messwerte konkretisiert.** Zur bestehenden 512-MB-Messung kommen
Zahlen von einem **2-GB-Container** (Debian 13, 300-dpi-A4-Seite mit
`deskew`, `oversample = 300`, `jobs = 4`): Laufzeit **19 s**, `MemoryPeak`
des Dienstes **380 MB**, `memory.peak` des ganzen Containers **503 MB**,
`oom_kill 0`. Auf derselben Maschine mit 512 MB war genau das der OOM-Kill.
Die Empfehlung "mindestens 2 GB" ist damit belegt statt geschaetzt.
- **Debian 13 in LXC auf Proxmox: journald scheitert — verifizierter Befund,
betrifft jede Debian-13-LXC auf Proxmox 8.4.** In 0.6.3 stand noch, das sei
ein Schaden auf genau einer Maschine; das war falsch. Ursache: systemd >= 255
(Debian 13 hat 257) setzt `ImportCredential=journal.*` in der
journald-Unit, der Hilfsprozess `(sd-mkdcreds)` mountet dafuer, und das
AppArmor-Profil des Proxmox-Hosts blockiert das
(`apparmor="DENIED" operation="mount" profile="lxc-<id>_</var/lib/lxc>"
name="/dev/" comm="(sd-mkdcreds)"` im Host-Log). Debian 12 (systemd 252)
kennt `ImportCredential` nicht und ist nicht betroffen. Dokumentiert sind
die reboot-feste Abhilfe im Container (Drop-in
`systemd-journald.service.d/no-credentials.conf` mit leerem
`ImportCredential=`), der Hinweis auf die weiteren betroffenen Units
(logind, networkd, console-getty, tmpfiles-setup) und der saubere Weg
host-seitig.
- **Exit-Code-Tabelle 0/1/2/3** in `docs/INSTALLATION.md`, verlinkt aus
README, `docs/UPDATE.md` und dem Troubleshooting — mit dem Hinweis, dass die
Unit bei 2 **nicht** neu startet und bei 3 gerade doch.
- Die fuenf Ueberschriften der Instanz-Abfragen in `docs/INSTALLATION.md` sind
**entnummeriert** (`### 4. OCR-Sprachen` -> `### OCR-Sprachen`). Die Anker
hiessen vorher `#4-ocr-sprachen` und waeren bei jeder Umsortierung
gebrochen; die Reihenfolge steht weiterhin im Text.
- `AI_AGENT_BRIEFING.md` auf den heutigen Stand gezogen: Dateibaum mit `lib/`
und allen 20 Test-Dateien, nur noch **ein** `venv_is_healthy()`,
Paketliste in `lib/common.sh`, veraPDF-Preflight, Kollisionsschutz,
Exit-Codes, Observer-Bewachung, Testzahl 254.
- Testzahl ueberall von 152 auf **254** korrigiert (README,
`AI_AGENT_BRIEFING.md`, `docs/OS-UPGRADE.md`).
## [0.6.3] - 2026-09-23
Reine Doku-Version — kein Code, kein Installer, kein Updater, keine Unit, keine