diff --git a/AI_AGENT_BRIEFING.md b/AI_AGENT_BRIEFING.md index 62ede18..4f08f7a 100644 --- a/AI_AGENT_BRIEFING.md +++ b/AI_AGENT_BRIEFING.md @@ -1,8 +1,8 @@ # AI Agent Briefing — PDF OCR Hotfolder **Zuletzt aktualisiert:** 2026-09-22 -**Version:** 0.6.0 -**Status:** Multi-Instanz-Betrieb, Preflight-Checks, Fehlerzählung, Wiederaufnahme aus `working/` und ein abgesicherter Updater (venv-Rebuild, Backup, Verifikation). Test-Suite grün (135 pytest-Tests). Ein Produktiv-Einsatz ist im Repo (README/CHANGELOG) nicht dokumentiert — die bisherigen Fixes stammen aus Issues #1–#6 und aus Vorbereitungen auf Debian 13, nicht aus einem belegten Dauerbetrieb. +**Version:** 0.6.1 +**Status:** Multi-Instanz-Betrieb, Preflight-Checks, Fehlerzählung, Wiederaufnahme aus `working/` und ein abgesicherter Updater (venv-Rebuild, Backup, Verifikation, Versionssprung-Meldung, Rauchtest). Test-Suite grün (152 pytest-Tests). Ein Produktiv-Einsatz ist im Repo (README/CHANGELOG) nicht dokumentiert — die bisherigen Fixes stammen aus Issues #1–#6 und aus Vorbereitungen auf Debian 13, nicht aus einem belegten Dauerbetrieb. > **Betriebsabläufe stehen nicht hier**, sondern in: > [README.md](README.md) (Einstieg, Layout, Config-Überblick) · @@ -26,7 +26,7 @@ pdf-ocr-hotfolder/ │ ├── service.py # HotfolderService (watchdog + ThreadPool), Preflight, Resume, Zähler │ ├── processor.py # ocrmypdf-Call, veraPDF, Ausgabename, Original-Entsorgung │ └── uploaders.py # folder, nextcloud (WebDAV), sftp, E-Mail-Notify -├── tests/ # pytest-Suite (135 Tests, ocrmypdf wird gemockt) +├── tests/ # pytest-Suite (152 Tests, ocrmypdf wird gemockt) │ ├── conftest.py # Fixtures tmp_config / dummy_pdf │ ├── test_check_config.py # --check-config, Exit 0/1/2 │ ├── test_config_errors.py @@ -230,7 +230,7 @@ wenn der installierte Code das Flag noch nicht kennt. ## 🔄 Verarbeitungs-Flow **Beim Start (`run()` wie `run_once()`), vor allem anderen:** -1. `check_preflight()` — `tesseract` und `gs` müssen im PATH sein; ist `pdfa_level` gesetzt, wird zusätzlich die Ghostscript-Version gegen den 10.0.0–10.02.0-Bug geprüft +1. `check_preflight(pdfa_level, skip_text)` — `tesseract` und `gs` müssen im PATH sein; zusätzlich wird die Ghostscript-Version gegen den 10.0.0–10.02.0-Bug geprüft, und zwar unter genau der Bedingung, unter der ocrmypdf selbst abbricht (`_gs_block_reason()`: betroffene GS-Version **und** `skip_text` **und** (`pdfa_level` gesetzt **oder** ocrmypdf < 17)) 2. `check_output_config()` — validiert `original_on_success`, `archive_dir` (Pflicht bei `archive`) und `name_mode` 3. Scheitert eines davon → `PreflightError`, CLI beendet sich mit **Exit-Code 2** (ebenso bei kaputter/fehlender Config) 4. `ensure_dirs()`, dann `_scan_existing()`: **zuerst `working/`**, danach `incoming/` @@ -289,7 +289,11 @@ Der Service läuft in allen Fällen weiter (kein `exit 1` wie im alten Bash-Tool ## ⚠️ Fallstricke -- **Ghostscript 10.0.0–10.02.0 zerschießt OCR.** Das ist der Debian-12-Default. In Kombination aus `[ocr].pdfa_level` + `skip_text = true` blockiert ocrmypdf komplett (Issue #3). Deshalb ist `pdfa_level = ""` der sichere Default, der Preflight bricht mit Exit 2 ab, wenn `pdfa_level` gesetzt **und** die GS-Version betroffen ist, und `--check-config` warnt bei gesetztem `pdfa_level` grundsätzlich. Abhilfe: Ghostscript ≥ 10.02.1 aus bookworm-backports (der Installer bietet das an). +- **Ghostscript 10.0.0–10.02.0 zerschießt OCR.** Das ist der Debian-12-Default. ocrmypdf verweigert damit die Arbeit — aber die Bedingung dafür hängt an der **ocrmypdf-Version**, und genau daran ist 0.6.0 gescheitert: + - **ocrmypdf ≤ 16.x**: die Prüfung in `builtin_plugins/ghostscript.py::check_options()` läuft **bedingungslos**. `skip_text = true` allein reicht — `output_type` wird nicht geprüft. Auf Debian 12 scheitert damit **jede** Datei. + - **ocrmypdf ≥ 17.0**: derselbe Block steckt in einem `if options.output_type.startswith('pdfa'):`. Ohne PDF/A wird Ghostscript nicht angefasst. + + `pdfa_level = ""` ist deshalb **kein** Schutz für sich genommen — es wirkt nur mit ocrmypdf ≥ 17. `requirements.txt` pinnt daher 17.x; ein Downgrade auf 16.x macht jede Debian-12-Instanz unbrauchbar, bei grünem `systemctl status`. Der Preflight bildet die reale Bedingung ab (`_gs_block_reason()`) und bricht mit Exit 2 ab, `--check-config` meldet denselben Zustand als Fehler. `redo_ocr` ist bewusst **nicht** in der Bedingung: die Config kennt keinen solchen Key. Abhilfe: Ghostscript ≥ 10.02.1 aus bookworm-backports (der Installer bietet das an) oder `skip_text = false`. - **`[ocr].timeout` ist ein Timeout PRO SEITE**, kein Gesamt-Timeout pro PDF. Der Wert geht als `tesseract_timeout` (ocrmypdf-Option `--tesseract-timeout`) durch; ocrmypdf kennt kein Dokument-Timeout. Wer noch den alten Default `1800` in einer Config stehen hat, gibt Tesseract 30 Minuten **je Seite** — Richtwert ist 300, ab 900 warnt `--check-config`. Ein durchgereichtes `0` würde ocrmypdf dazu bringen, OCR **still zu überspringen**, deshalb wird bei `0` (oder negativ) gar nichts übergeben und der ocrmypdf-Default greift. - **`TimeoutStopSec=300` in der Unit ist Absicht.** Ein laufendes OCR soll beim Stoppen zu Ende laufen dürfen — ein `systemctl stop` kann deshalb pro Instanz bis zu 5 Minuten dauern, und `update.sh` (das nacheinander stoppt) entsprechend länger. Bei SIGKILL bliebe das Original in `working/` liegen; das wird zwar wiederaufgenommen, kostet aber den kompletten Durchlauf. - **Die venv hängt an der Python-Version der Distribution.** Nach einem Debian-Major-Upgrade ist `venv/bin/python` tot (systemd: `203/EXEC`) oder eine andere Version als das System-Python. Der Weg dahin und zurück steht in [docs/OS-UPGRADE.md](docs/OS-UPGRADE.md); im Code prüfen `install.sh` und `update.sh` das je mit einem eigenen `venv_is_healthy()` (die Variante in `update.sh` ist die gründlichere und schaut zusätzlich in `pyvenv.cfg`). @@ -312,14 +316,14 @@ python -m pdf_ocr_hotfolder --config /tmp/config.toml Tests (aus dem Repo-Root, `pytest.ini` setzt `testpaths = tests`): ```bash -pytest # aktuell 135 Tests +pytest # aktuell 152 Tests ``` `ocrmypdf` muss dafür **nicht** installiert sein: der Import in `processor.py` ist lazy, und `tests/test_ocr_timeout.py` schiebt ein Dummy-Modul in `sys.modules`. Die übrigen Tests mocken `process_pdf` bzw. arbeiten nur auf Config-Ebene. ## 📋 Roadmap / TODO -- [x] Tests (`pytest`) für `processor` und `uploaders` — 135 Tests +- [x] Tests (`pytest`) für `processor` und `uploaders` — 152 Tests - [x] Wiederaufnahme abgebrochener Läufe aus `working/` - [x] Config-Prüfung ohne Verarbeitung (`--check-config`) + Auswertung im Updater - [x] Updater übersteht Debian-Major-Upgrades (venv-Rebuild, Pins, Rollback) diff --git a/CHANGELOG.md b/CHANGELOG.md index e959358..77e4001 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,113 @@ # Changelog +## [0.6.1] - 2026-09-22 + +> **Fuer Bestandsinstallationen wichtig.** Wer 0.6.0 bereits eingespielt hat, +> laeuft auf Debian 12 mit hoher Wahrscheinlichkeit im Totalausfall: der Dienst +> meldet `active`, `--check-config` meldet "Preflight ok" — und **jede** PDF +> landet in `error/`. Nach dem Update auf 0.6.1 nachsehen, ob in `error/` +> unverarbeitete Dateien liegen, und diese zurueck nach `incoming/` schieben. +> Der Updater faehrt jetzt selbst einen Rauchtest, der so einen Zustand sofort +> aufdeckt. + +### Fixed +- **ocrmypdf-Pin von 16.13.0 auf 17.4.1 korrigiert — das war ein stiller + Totalausfall.** 0.6.0 pinnte `ocrmypdf==16.13.0`. Auf Bestandssystemen mit + vorher `ocrmypdf>=16.0` war das ein **Downgrade** von 17.4.1, und auf + Debian 12 (Ghostscript 10.0.0) bricht ocrmypdf 16.13.0 bei jeder PDF ab: + + ``` + MissingDependencyError: Ghostscript 10.0.0 through 10.02.0 (your version: + 10.0.0) contain serious regressions that corrupt PDFs with existing text + ``` + + Ursache, verifiziert im Quelltext von + `ocrmypdf/builtin_plugins/ghostscript.py::check_options()`: bis + einschliesslich 16.x laeuft die Ghostscript-Pruefung **bedingungslos** — + `skip_text=true` allein genuegt, `output_type` wird gar nicht geprueft, + obwohl die Fehlermeldung selbst `--output-type pdf` empfiehlt. Ab **17.0.0** + umschliesst denselben Block ein + `if options.output_type.startswith('pdfa'):`; ohne PDF/A wird Ghostscript + nicht angefasst. `run_ocr()` setzt bei leerem `pdfa_level` genau + `output_type="pdf"` und hielt sich damit faelschlich fuer sicher. + + Betroffen war nicht nur das Update, sondern ebenso jede **Neuinstallation**: + `skip_text = true` ist der Default. — 17.4.1 ist die auf Debian 12 + gs 10.0.0 + real verifizierte Version; `skip_text` bleibt in 17.x als Alias fuer + `mode='skip'` unterstuetzt, `run_ocr()` musste nicht angepasst werden. + +- **Preflight prueft jetzt die reale Bedingung.** `check_preflight()` sah die + Ghostscript-Version bisher nur bei gesetztem `pdfa_level` an (`if pdfa_level:`) + — genau deshalb ging der kaputte Zustand als "Preflight ok" durch. Die neue + Bedingung (`_gs_block_reason()`) bildet ocrmypdf nach: + + ``` + betroffene GS-Version UND skip_text UND (pdfa_level ODER ocrmypdf < 17) + ``` + + Die Signatur ist jetzt `check_preflight(pdfa_level, skip_text)`; alle + Aufrufstellen (`run()`, `run_once()`, `--check-config`) reichen beides durch. + `redo_ocr` steht bewusst **nicht** in der Bedingung: die Config kennt keinen + solchen Key, und ein erfundener waere schlimmer als ein fehlender. + + Ergebnis: der Dienst bricht beim **Start** mit Exit 2 ab statt bei der ersten + Datei, und `--check-config` meldet den Zustand als **Fehler** (Exit 2) — also + auch mitten im Update. Die Meldung nennt beide Auswege: Ghostscript >= 10.02.1 + aus bookworm-backports (der Installer bietet das an) oder + `[ocr].skip_text = false`. + +### Added +- **`update.sh` macht Versionsspruenge der Kernabhaengigkeiten sichtbar.** Die + Versionen der in `requirements.txt` gepinnten Pakete werden vor und nach + `pip install` gemessen; Downgrades erscheinen als `[WARN]`, Upgrades und neue + Pakete als `[INFO]`, beides zusaetzlich in der Abschluss-Zusammenfassung. Das + Downgrade 17.4.1 -> 16.13.0 verschwand bisher wortlos hinter + `[INFO] Dependencies ok ✓`. +- **Rauchtest in `update.sh`.** Nach dem Start jeder Instanz geht eine winzige + Test-PDF durch die **echte** Pipeline; der Test gilt als bestanden, wenn sie + in `outgoing/` ankommt. Erst das deckt einen Totalausfall auf, den systemd + nicht sieht. + - Die Test-PDF (694 Bytes, eine Seite) steckt als base64 im Skript — kein + Pillow, kein `gs`, kein `convert` noetig. + - Eindeutiger Dateiname (`__smoketest_update__.pdf`), der + mit keiner Kundendatei kollidieren kann. + - **Raeumt restlos auf** — Testdatei und Ergebnis, in `incoming/`, `working/` + (inkl. `__ocr_`-Zwischendatei), `outgoing/`, `error/` und Archiv, auch bei + Fehlschlag und Timeout. + - **Uebersprungen** bei Instanzen mit aktivem `[upload.nextcloud]`, + `[upload.sftp]`, `[notify.email]` oder `[upload.folder]` mit gesetztem + `target`: dort wuerde die Testdatei nach aussen gehen, im Zweifel zum + Kunden. `[upload.folder]` ohne `target` schreibt nach `outgoing/` und ist + harmlos. + - Wartezeit `SMOKE_TIMEOUT` (Standard 90 s), danach durchgefallen — das + Skript haengt nicht. + - Ein Fehlschlag setzt den Exit-Code auf 1 und nennt den `journalctl`-Befehl, + rollt aber **nichts** zurueck. + - Abschaltbar mit `--no-smoke-test`, dokumentiert in `--help`. +- `--check-config` zeigt zusaetzlich `skip_text` sowie die installierte + ocrmypdf- und Ghostscript-Version an. + +### Changed +- **Doku praezisiert.** `config.example.toml`, `config.py`, + `docs/INSTALLATION.md` und `docs/UPDATE.md` behaupteten sinngemaess, + `pdfa_level = ""` sei der sichere Default gegen den Ghostscript-Bug. Das + stimmt so nicht: die Entwarnung haengt an der ocrmypdf-Version und gilt erst + ab 17. Der Ghostscript-Abschnitt in `INSTALLATION.md` stellt die Bedingung + jetzt je ocrmypdf-Major gegenueber und nennt `skip_text = false` als zweiten + Weg; `UPDATE.md` beschreibt Rauchtest und Versionssprung-Meldung. +- Kommentarblock in `requirements.txt` korrigiert: der Schutz gilt gegen den + naechsten ungewollten Major-Sprung (18), **nicht** gegen 17 — samt + Begruendung, warum 16.x fuer uns unbrauchbar ist. + +### Tests +- 152 statt 135 Tests. Neu: die Preflight-Matrix aus GS-Version x `skip_text` x + `pdfa_level` x ocrmypdf-Major — darunter der Fall, der durchrutschte + (betroffene GS-Version + `skip_text=true` + leeres `pdfa_level` + + ocrmypdf 16.x muss `PreflightError` ausloesen) und die Gegenprobe, dass + dieselbe Config mit ocrmypdf 17.x **nicht** ausloest (sonst startet keine + Debian-12-Bestandsinstanz mehr). Dazu `ocrmypdf_checks_gs_always()`, der + Abbruch in `run_once()` und Exit 2 bei `--check-config`. + ## [0.6.0] - 2026-09-22 ### Added diff --git a/README.md b/README.md index 27fac62..fa1a016 100644 --- a/README.md +++ b/README.md @@ -146,7 +146,7 @@ gelöscht. ## Tests ```bash -pytest # 135 Tests +pytest # 152 Tests ``` `ocrmypdf` muss dafür nicht installiert sein — der Import ist lazy und wird in @@ -158,5 +158,5 @@ MIT — © Sonith UG --- -**Version:** 0.6.0 +**Version:** 0.6.1 **Repo:** https://gitea.sonith.de/sonith_ug/pdf-ocr-hotfolder diff --git a/VERSION b/VERSION index a918a2a..ee6cdce 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.6.0 +0.6.1 diff --git a/config.example.toml b/config.example.toml index ac9f0f0..28ab2a1 100644 --- a/config.example.toml +++ b/config.example.toml @@ -21,9 +21,17 @@ skip_text = true # Auflösung für gerasterte Seiten oversample = 300 # PDF/A-Konformitätsstufe ("1", "2", "3" oder leer für keinen PDF/A-Output) -# ACHTUNG: Ghostscript 10.0.0 bis 10.02.0 (Debian 12 default!) haben einen Bug, -# der mit pdfa_level + skip_text=true ocrmypdf komplett blockiert. -# Sicherer Default ist "" — nur auf "1"/"2"/"3" setzen, wenn gs >= 10.02.1 installiert ist. +# ACHTUNG: Ghostscript 10.0.0 bis 10.02.0 (Debian 12 default!) haben einen Bug; +# ocrmypdf lehnt damit die Kombination pdfa_level + skip_text=true komplett ab. +# Nur auf "1"/"2"/"3" setzen, wenn gs >= 10.02.1 installiert ist. +# +# pdfa_level = "" ist deshalb der Default — aber KEIN genereller Schutz gegen +# den Ghostscript-Bug: das gilt erst zusammen mit ocrmypdf >= 17. Bis +# ocrmypdf 16.x läuft dieselbe Prüfung auch ohne PDF/A, und dann scheitert mit +# skip_text = true jede einzelne Datei. Die Entwarnung hängt also an der +# ocrmypdf-Version, nicht an dieser Zeile; requirements.txt pinnt darum 17.x. +# Der Preflight prüft beides zusammen und lässt den Dienst gar nicht erst +# starten, wenn die Kombination nicht trägt. pdfa_level = "" # Schiefe Scans automatisch begradigen deskew = true diff --git a/docs/INSTALLATION.md b/docs/INSTALLATION.md index 4b1b8c5..237a3ed 100644 --- a/docs/INSTALLATION.md +++ b/docs/INSTALLATION.md @@ -249,19 +249,47 @@ Container-Instanzen reißt. ## Ghostscript-Bug auf Debian 12 Ghostscript 10.0.0 bis einschließlich 10.02.0 — der **Debian-12-Default** — -zerschießt OCR in der Kombination `[ocr].pdfa_level` + `skip_text = true`: -ocrmypdf blockiert komplett. +enthält Regressionen, die PDFs mit vorhandenem Text beschädigen. ocrmypdf +verweigert deshalb den Dienst, statt ein kaputtes Ergebnis zu liefern. -Deshalb: +### Wann genau ocrmypdf abbricht -- `pdfa_level = ""` ist der sichere Default (kein PDF/A-Output). -- Der Preflight beim Dienststart bricht mit **Exit 2** ab, wenn `pdfa_level` - gesetzt **und** die installierte Ghostscript-Version betroffen ist. -- `--check-config` meldet ein gesetztes `pdfa_level` als Warnung (siehe +Die Bedingung steht in `ocrmypdf/builtin_plugins/ghostscript.py` +(`check_options()`) und hängt an **zwei** Dingen — an der Config *und* an der +ocrmypdf-Version: + +| ocrmypdf | Die Prüfung greift bei | Heißt für uns | +|----------|------------------------|---------------| +| **≤ 16.x** | `skip_text` oder `redo_ocr` — **unabhängig vom `output_type`** | Auch ohne PDF/A scheitert **jede** Datei, denn `skip_text = true` ist unser Default | +| **≥ 17.0** | dasselbe, aber nur innerhalb von `if options.output_type.startswith('pdfa')` | Ohne PDF/A wird Ghostscript gar nicht angefasst — unkritisch | + +> ⚠️ **`pdfa_level = ""` allein ist damit kein Schutz.** Die Entwarnung gilt nur +> zusammen mit **ocrmypdf ≥ 17**. Das war der Fehler in 0.6.0: der Pin stand auf +> `ocrmypdf==16.13.0`, und auf Debian 12 landete daraufhin jede PDF in `error/` — +> bei grünem `systemctl status` und „Preflight ok". +> `requirements.txt` pinnt deshalb 17.x. + +### Was das Tool dagegen tut + +- `pdfa_level = ""` ist der Default (kein PDF/A-Output). +- `requirements.txt` pinnt **ocrmypdf 17.x**. Ein Downgrade auf 16.x macht jede + Debian-12-Instanz unbrauchbar; `update.sh` weist Versionssprünge der + gepinnten Pakete deshalb ausdrücklich aus. +- Der Preflight bricht beim **Dienststart** mit **Exit 2** ab, wenn die + Ghostscript-Version betroffen ist **und** die Kombination aus `skip_text`, + `pdfa_level` und installierter ocrmypdf-Version tatsächlich zum Abbruch + führen würde. Der Dienst startet dann gar nicht erst, statt jede Datei + einzeln scheitern zu lassen. +- `--check-config` meldet denselben Zustand als **Fehler (Exit 2)** und zeigt + ocrmypdf- und Ghostscript-Version an (siehe [UPDATE.md](UPDATE.md#config-prüfung-per---check-config)). +- Der [Rauchtest](UPDATE.md#rauchtest) in `update.sh` schiebt nach dem Update + eine Test-PDF durch die echte Pipeline — er hätte den Ausfall sofort gezeigt. -Der Installer erkennt betroffene Versionen und bietet auf Debian 12 -bookworm-backports an. Manuell: +### Abhilfe + +**Weg 1 — Ghostscript anheben** (empfohlen). Der Installer erkennt betroffene +Versionen und bietet auf Debian 12 bookworm-backports an. Manuell: ```bash echo 'deb http://deb.debian.org/debian bookworm-backports main' | \ @@ -272,6 +300,10 @@ sudo apt update && sudo apt install -t bookworm-backports ghostscript Ab Ghostscript 10.02.1 ist alles in Ordnung; PDF/A kann dann eingeschaltet werden. +**Weg 2 — `skip_text = false` setzen.** Dann wird vorhandener Text neu erkannt +statt übersprungen, und die Bedingung greift nicht mehr. Das kostet Laufzeit bei +PDFs, die bereits eine Textebene haben. + --- ## Instanz manuell löschen @@ -325,7 +357,7 @@ startet nicht. | `jobs` | `4` | Threads, die ocrmypdf innerhalb **einer** PDF nutzt | | `skip_text` | `true` | Seiten, die schon Text haben, nicht neu OCRen | | `oversample` | `300` | Auflösung für gerasterte Seiten | -| `pdfa_level` | `""` | `"1"`, `"2"`, `"3"` oder leer für reines PDF — leer wegen des [Ghostscript-Bugs](#ghostscript-bug-auf-debian-12) | +| `pdfa_level` | `""` | `"1"`, `"2"`, `"3"` oder leer für reines PDF — leer wegen des [Ghostscript-Bugs](#ghostscript-bug-auf-debian-12). Achtung: leer allein schützt nur zusammen mit ocrmypdf ≥ 17 | | `deskew` | `true` | schiefe Scans begradigen | | `clean` | `false` | Hintergrund säubern (unpaper) | | `max_workers` | `2` | wie viele PDFs **parallel** verarbeitet werden | diff --git a/docs/OS-UPGRADE.md b/docs/OS-UPGRADE.md index 96e6066..57099e5 100644 --- a/docs/OS-UPGRADE.md +++ b/docs/OS-UPGRADE.md @@ -191,28 +191,45 @@ Upgrade entfernt. Hintergrund: Die Python-Abhängigkeiten sind **bewusst fest gepinnt**: ``` -ocrmypdf==16.13.0 +ocrmypdf==17.4.1 watchdog==6.0.0 requests==2.33.1 paramiko==4.0.0 ``` Ohne Pins würde ein `pip install --upgrade` bei jedem Update ungefragt eine neue -Major-Version ziehen — ein Sprung von ocrmypdf **16 auf 17** reißt sonst alle -Instanzen auf einmal, und zwar im Moment des Updates, nicht zu einem Zeitpunkt, -den man sich ausgesucht hat. +Major-Version ziehen — der nächste Sprung wäre ocrmypdf **17 auf 18**, und der +reißt sonst alle Instanzen auf einmal, und zwar im Moment des Updates, nicht zu +einem Zeitpunkt, den man sich ausgesucht hat. + +> ⚠️ **ocrmypdf darf nicht unter 17 fallen.** Bis einschließlich 16.x prüft +> ocrmypdf die Ghostscript-Version auch dann, wenn gar kein PDF/A erzeugt wird — +> auf Debian 12 (Ghostscript 10.0.0) scheitert damit **jede** PDF, weil +> `skip_text = true` unser Default ist. Genau das war der Ausfall in 0.6.0. +> Hintergrund: [INSTALLATION.md](INSTALLATION.md#ghostscript-bug-auf-debian-12). Die aktuellen Pins sind gegen Python 3.11 (Debian 12) und 3.13 (Debian 13) -geprüft; für beide gibt es fertige Wheels, es wird nichts kompiliert. +geprüft; für beide gibt es fertige Wheels, es wird nichts kompiliert. Das gilt +auch für die Abhängigkeiten, die ocrmypdf 17 zusätzlich mitbringt (`pydantic`, +`pypdfium2`, `fpdf2`, `uharfbuzz`). **Beim Anheben:** 1. **Testmaschine benutzen** — nie direkt auf dem produktiven Hotfolder. -2. Dort `update.sh --rebuild-venv` fahren, damit die Pakete wirklich frisch +2. Prüfen, dass es für die neue Version auf **beiden** Python-Versionen fertige + Wheels gibt, sonst wird auf dem Zielsystem kompiliert: + ```bash + pip install --dry-run --only-binary=:all: --python-version 3.11 \ + --target /tmp/wheelcheck ocrmypdf== + pip install --dry-run --only-binary=:all: --python-version 3.13 \ + --target /tmp/wheelcheck ocrmypdf== + ``` +3. Dort `update.sh --rebuild-venv` fahren, damit die Pakete wirklich frisch aufgelöst werden. -3. `pytest` muss grün bleiben (135 Tests). -4. Eine echte PDF durchschieben — die Test-Suite mockt ocrmypdf, ein Major-Sprung - fällt dort also nicht auf. +4. `pytest` muss grün bleiben (152 Tests). +5. Eine echte PDF durchschieben — die Test-Suite mockt ocrmypdf, ein Major-Sprung + fällt dort also nicht auf. Der [Rauchtest](UPDATE.md#rauchtest) in `update.sh` + macht genau das automatisch. 5. Erst dann committen und auf die produktiven Systeme geben. Der ocrmypdf-Sprung 16 → 17 ist ein **Major-Sprung** und gehört in einen eigenen diff --git a/docs/UPDATE.md b/docs/UPDATE.md index 7fecc52..8bbd757 100644 --- a/docs/UPDATE.md +++ b/docs/UPDATE.md @@ -19,8 +19,9 @@ sudo ./update.sh ``` ``` -sudo ./update.sh --help # Optionen anzeigen -sudo ./update.sh --rebuild-venv # venv zwingend neu bauen (nach dist-upgrade) +sudo ./update.sh --help # Optionen anzeigen +sudo ./update.sh --rebuild-venv # venv zwingend neu bauen (nach dist-upgrade) +sudo ./update.sh --no-smoke-test # ohne Rauchtest durchlaufen ``` `update.sh` muss aus dem Repo laufen. Findet es sich nicht selbst im Repo, liest @@ -37,12 +38,13 @@ muss also liegen bleiben**, das Tool kopiert daraus. | 4 | **Instanzen stoppen** | nur die, die vorher liefen oder kaputt waren | | 5 | **Backup** | [Inhalt und Ort](#backup) | | 6 | **Code kopieren** | `pdf_ocr_hotfolder/`, `requirements.txt`, `VERSION`, `config.example.toml`, `.repo_path` | -| 7 | **Dependencies** | `pip install --upgrade -r requirements.txt` — oder venv-Neubau, falls nötig | +| 7 | **Dependencies** | `pip install --upgrade -r requirements.txt` — oder venv-Neubau, falls nötig. Versionssprünge der gepinnten Pakete werden [benannt](#versionssprünge-der-kernabhängigkeiten) | | 8 | **systemd-Units** | Template-Unit aus dem Repo, LXC-Drop-in nachziehen, `daemon-reload` | | 9 | **Berechtigungen** | Code gehört dem primären User (i.d.R. `pdfocr`) | | 10 | **Configs prüfen** | `--check-config` je Instanz, siehe [unten](#config-prüfung-per---check-config) | | 11 | **Instanzen starten + verifizieren** | mit Wartezeit und Crash-Loop-Erkennung | -| 12 | **Zusammenfassung** | Soll gegen Ist | +| 12 | **Rauchtest** | eine Test-PDF durch die echte Pipeline, siehe [unten](#rauchtest) | +| 13 | **Zusammenfassung** | Soll gegen Ist | Ab Schritt 2 gilt: **System-Pakete werden auch beim Update nachgezogen**, nicht nur bei der Installation. Bereits installierte Tesseract-Sprachpakete bleiben @@ -187,6 +189,95 @@ Ein abgebrochenes Update lässt also keinen Hotfolder stumm gestoppt zurück. --- +## Versionssprünge der Kernabhängigkeiten + +`update.sh` misst die Versionen der in `requirements.txt` gepinnten Pakete +**vor** und **nach** `pip install` und benennt jede Änderung: + +``` +[WARN] DOWNGRADE: ocrmypdf: 17.4.1 -> 16.13.0 +[INFO] Upgrade: watchdog: 5.0.0 -> 6.0.0 +[INFO] Neu: requests 2.33.1 +``` + +Beides steht auch noch einmal in der Abschluss-Zusammenfassung, weil es im +Fließtext zwischen den pip-Ausgaben untergeht. + +**Downgrades sind der interessante Fall.** Sie entstehen, wenn ein Pin in +`requirements.txt` gesenkt wurde. Genau so ist der Totalausfall in 0.6.0 +entstanden: `ocrmypdf` wurde von 17.4.1 auf 16.13.0 heruntergezogen, das Update +lief mit Exit 0 durch, der Dienst meldete `active` — und jede PDF landete in +`error/`. Sichtbar war davon nichts außer `[INFO] Dependencies ok ✓`. + +War ein Downgrade nicht beabsichtigt: Pin korrigieren und +`sudo ./update.sh --rebuild-venv` erneut fahren. + +--- + +## Rauchtest + +Nach dem Start schiebt `update.sh` pro Instanz eine winzige Test-PDF durch die +**echte** Pipeline und prüft, ob sie in `outgoing/` ankommt. + +Das ist der Schritt, den 0.6.0 gefehlt hat: `systemctl` sagt `active`, +`--check-config` sagt `Preflight ok` — und trotzdem scheitert jede einzelne +Datei. Ein laufender Dienst ist eben kein Beleg dafür, dass er etwas +verarbeitet. + +- Die Test-PDF steckt als base64 **im Skript** (694 Bytes, eine Seite). Es + braucht also kein Pillow, kein `gs` und kein `convert` auf dem Zielsystem. +- Der Dateiname ist eindeutig (`__smoketest_update__.pdf`) und + kann mit keiner Kundendatei kollidieren. +- Wartezeit: `SMOKE_TIMEOUT` Sekunden (Standard 90), danach gilt der Test als + durchgefallen. Das Skript hängt nicht. + +### Aufräumen + +Test-PDF **und** Ergebnis werden danach restlos entfernt — in jedem Ausgang, +auch bei Fehlschlag und Timeout. Angefasst werden dabei ausschließlich Dateien +mit dem Testnamen, in `incoming/`, `working/` (inkl. `__ocr_`-Zwischendatei), +`outgoing/`, `error/` und im Archivverzeichnis. In keinem dieser Verzeichnisse +bleibt etwas vom Test liegen. + +### Wann der Rauchtest übersprungen wird + +Hat eine Instanz ein aktives Ziel, würde die Testdatei **nach außen** gehen — +im Zweifel zum Kunden. Solche Instanzen werden mit klarer Meldung übersprungen: + +| Übersprungen bei | Grund | +|------------------|-------| +| `[upload.nextcloud].enabled = true` | Testdatei landete in der Nextcloud | +| `[upload.sftp].enabled = true` | Testdatei landete auf dem SFTP-Ziel | +| `[upload.folder]` mit gesetztem `target` | Zielordner liegt außerhalb von `outgoing/`, oft eine Kundenfreigabe | +| `[notify.email].enabled = true` | löst eine Benachrichtigungs-Mail aus | + +`[upload.folder]` **ohne** `target` schreibt nach `outgoing/` und ist damit +harmlos — dort läuft der Test normal. + +Für diese Instanzen bleibt der manuelle Weg: eine eigene PDF in `incoming/` +legen und `journalctl -u pdf-ocr-hotfolder@ -f` mitlesen. + +### Wenn der Rauchtest fehlschlägt + +Der Rauchtest setzt den **Exit-Code** des Updates auf 1 und nennt den +Journal-Befehl: + +``` +[ERROR] RAUCHTEST FEHLGESCHLAGEN: kunde1 +[ERROR] Diese Instanzen laufen, verarbeiten aber keine PDFs. +[ERROR] Es wurde NICHT zurueckgerollt. Journal ansehen: +[ERROR] journalctl -u pdf-ocr-hotfolder@kunde1.service -n 80 --no-pager +``` + +**Es wird nichts automatisch zurückgerollt.** Der Code ist getauscht, die +Instanzen laufen. Rollback nur von Hand und nur bewusst — siehe +[Rollback](#rollback). + +Abschalten: `sudo ./update.sh --no-smoke-test`. Dann fällt ein Totalausfall +erst der ersten echten Kundendatei auf. + +--- + ## Config-Prüfung per `--check-config` Nach dem Code-Update und vor dem Start prüft `update.sh` jede Instanz-Config mit @@ -199,8 +290,11 @@ dem neuen Code: `--check-config` verarbeitet nichts, es hat sogar Vorrang vor `--once`. Es lädt die Config, zeigt die vier Pfade (inkl. Hinweis, falls ein Verzeichnis noch -fehlt), Sprachen, Seiten-Timeout und PDF/A-Level, fährt den Preflight -(`tesseract`, `gs`, Ghostscript-Version bei gesetztem `pdfa_level`) und +fehlt), Sprachen, Seiten-Timeout, PDF/A-Level, `skip_text` sowie die +installierte ocrmypdf- und Ghostscript-Version, fährt den Preflight +(`tesseract`, `gs`, und die Ghostscript-Version gegen die tatsächliche +ocrmypdf-Bedingung — siehe [Rauchtest](#rauchtest) und +[INSTALLATION.md](INSTALLATION.md#ghostscript-bug-auf-debian-12)) und validiert die `[output]`-Sektion. | Exit | Bedeutung | Was der Admin tun soll | @@ -257,9 +351,15 @@ ocrmypdf `tesseract_timeout=0` als "OCR komplett überspringen" interpretiert. `pdfa_level` gehört auf `""` (reines PDF, kein PDF/A). Ist es gesetzt, warnt `--check-config`, weil Ghostscript 10.0.0–10.02.0 — der Debian-12-Default — in -Kombination mit `skip_text` das OCR blockiert. Der Preflight bricht in dem Fall -mit Exit 2 ab; ab Ghostscript 10.02.1 ist PDF/A unproblematisch. Hintergrund: -[INSTALLATION.md](INSTALLATION.md#ghostscript-bug-auf-debian-12). +Kombination mit `skip_text` von ocrmypdf abgelehnt wird. Der Preflight bricht in +dem Fall mit Exit 2 ab; ab Ghostscript 10.02.1 ist PDF/A unproblematisch. + +> **`pdfa_level = ""` allein ist kein Schutz gegen den Ghostscript-Bug.** Das +> gilt erst zusammen mit **ocrmypdf ≥ 17**. Bis ocrmypdf 16.x läuft dieselbe +> Prüfung auch ohne PDF/A, und dann scheitert mit `skip_text = true` jede +> einzelne Datei. Die Entwarnung hängt also an der ocrmypdf-Version. +> `requirements.txt` pinnt darum 17.x, und der Preflight prüft beides zusammen. +> Hintergrund: [INSTALLATION.md](INSTALLATION.md#ghostscript-bug-auf-debian-12). ### Unbekannte Keys diff --git a/pdf_ocr_hotfolder/__init__.py b/pdf_ocr_hotfolder/__init__.py index c543914..20f3261 100644 --- a/pdf_ocr_hotfolder/__init__.py +++ b/pdf_ocr_hotfolder/__init__.py @@ -1,3 +1,3 @@ """PDF OCR Hotfolder — Scanner-PDFs automatisch durchsuchbar machen.""" -__version__ = "0.6.0" +__version__ = "0.6.1" diff --git a/pdf_ocr_hotfolder/__main__.py b/pdf_ocr_hotfolder/__main__.py index 4fa680e..2f017c6 100644 --- a/pdf_ocr_hotfolder/__main__.py +++ b/pdf_ocr_hotfolder/__main__.py @@ -14,6 +14,8 @@ from .service import ( PreflightError, check_output_config, check_preflight, + detect_ghostscript_version, + detect_ocrmypdf_version, ) log = logging.getLogger(__name__) @@ -70,11 +72,15 @@ def check_config(cfg_path: Path) -> int: print(f" OCR-Sprachen = {cfg.ocr.languages}") print(f" Seiten-Timeout= {cfg.ocr.timeout} s") print(f" PDF/A-Level = {cfg.ocr.pdfa_level or '(aus)'}") + print(f" skip_text = {'an' if cfg.ocr.skip_text else 'aus'}") + print(f" ocrmypdf = {detect_ocrmypdf_version() or '(nicht installiert)'}") + print(f" Ghostscript = {detect_ghostscript_version() or '(nicht gefunden)'}") errors: list[str] = [] try: - check_preflight(cfg.ocr.pdfa_level) - print(" Preflight ok (tesseract, gs vorhanden).") + check_preflight(cfg.ocr.pdfa_level, cfg.ocr.skip_text) + print(" Preflight ok (tesseract, gs vorhanden, Ghostscript-Version " + "passt zu ocrmypdf + [ocr]-Einstellungen).") except PreflightError as e: errors.append(str(e)) try: diff --git a/pdf_ocr_hotfolder/config.py b/pdf_ocr_hotfolder/config.py index 964d30a..a1336b7 100644 --- a/pdf_ocr_hotfolder/config.py +++ b/pdf_ocr_hotfolder/config.py @@ -25,8 +25,13 @@ class OcrConfig: jobs: int = 4 skip_text: bool = True oversample: int = 300 - # Default bewusst leer: pdfa_level + skip_text zerschießt OCR mit - # Ghostscript 10.0.0-10.02.0 (Debian-12-Default), siehe Issue #3 + # Default bewusst leer: mit Ghostscript 10.0.0-10.02.0 (Debian-12-Default) + # lehnt ocrmypdf die Kombination pdfa_level + skip_text ab (Issue #3). + # ACHTUNG, kein Freibrief: pdfa_level = "" allein schützt nur zusammen mit + # ocrmypdf >= 17. Bis 16.x läuft dieselbe Prüfung auch ohne PDF/A und + # blockiert dann JEDE Datei — deshalb pinnt requirements.txt 17.x und der + # Preflight prüft die installierte ocrmypdf-Version mit (siehe + # service._gs_block_reason). pdfa_level: str = "" deskew: bool = True clean: bool = False @@ -251,9 +256,10 @@ def legacy_warnings(cfg: Config) -> list[str]: out.append( f"[ocr].pdfa_level = {cfg.ocr.pdfa_level!r}: PDF/A-Ausgabe ist " "aktiv. Ghostscript 10.0.0-10.02.0 (Debian-12-Default) hat einen " - "Bug, der zusammen mit skip_text das OCR blockiert (Issue #3). Der " - "Preflight bricht ab, falls die installierte Ghostscript-Version " - "betroffen ist; ab 10.02.1 ist alles in Ordnung." + "Bug, wegen dem ocrmypdf die Kombination mit skip_text ablehnt " + "(Issue #3). Der Preflight bricht ab, falls die installierte " + "Ghostscript-Version betroffen ist; ab 10.02.1 ist alles in " + "Ordnung." ) return out diff --git a/pdf_ocr_hotfolder/service.py b/pdf_ocr_hotfolder/service.py index 46e1cf2..d0dc1cd 100644 --- a/pdf_ocr_hotfolder/service.py +++ b/pdf_ocr_hotfolder/service.py @@ -35,11 +35,20 @@ class PreflightError(RuntimeError): # Pflicht-Binaries für ocrmypdf _REQUIRED_BINARIES = ("tesseract", "gs") -# Ghostscript-Versionen mit bekanntem PDF/A+skip_text Bug (Issue #3): +# Ghostscript-Versionen mit bekanntem Bug (Issue #3): # 10.0.0 .. 10.02.0 (inklusive). Ab 10.02.1 wieder nutzbar. _GS_BROKEN_MIN = (10, 0, 0) _GS_BROKEN_MAX = (10, 2, 0) +# Ab dieser ocrmypdf-Major steht die Ghostscript-Pruefung hinter +# `if options.output_type.startswith('pdfa')` — mit pdfa_level = "" wird +# Ghostscript gar nicht angefasst und die Pruefung greift nicht. +# Darunter (16.x und aelter) laeuft sie BEDINGUNGSLOS, also auch bei +# output_type="pdf": dort reicht skip_text=true, um auf Debian 12 jede +# einzelne PDF scheitern zu lassen. Quelle jeweils +# ocrmypdf/builtin_plugins/ghostscript.py::check_options(). +_OCRMYPDF_GS_GUARD_MAJOR = 17 + def _parse_version(text: str) -> tuple[int, ...] | None: """Extrahiert die erste X.Y[.Z] Version aus einem String.""" @@ -50,7 +59,7 @@ def _parse_version(text: str) -> tuple[int, ...] | None: def is_ghostscript_broken(version: str | None) -> bool: - """Prüft, ob eine Ghostscript-Version vom PDF/A+skip_text Bug betroffen ist. + """Prüft, ob eine Ghostscript-Version vom bekannten Bug betroffen ist. Betrifft 10.0.0 bis einschließlich 10.02.0. Ab 10.02.1 wieder sicher. """ @@ -79,6 +88,36 @@ def detect_ghostscript_version() -> str | None: return result.stdout.strip() or None +def detect_ocrmypdf_version() -> str | None: + """Liest die installierte ocrmypdf-Version aus den Paket-Metadaten. + + Bewusst über `importlib.metadata` statt über einen Import: das ist + billiger und funktioniert auch in den Tests, in denen ocrmypdf gar nicht + installiert ist (dann None). + """ + try: + from importlib.metadata import version + return version("ocrmypdf") + except Exception: # noqa: BLE001 - fehlende Metadaten dürfen nichts umwerfen + return None + + +def ocrmypdf_checks_gs_always(version: str | None) -> bool: + """True, wenn ocrmypdf die Ghostscript-Pruefung unabhaengig vom output_type fährt. + + Das ist bei 16.x und aelter der Fall (siehe `_OCRMYPDF_GS_GUARD_MAJOR`). + Ist die Version unbekannt, wird `False` angenommen: der Pin in + requirements.txt steht auf 17.x, und ein Fehlalarm, der den Dienst nicht + starten laesst, waere schlimmer als die fehlende Warnung. + """ + if not version: + return False + parsed = _parse_version(version) + if parsed is None: + return False + return parsed[0] < _OCRMYPDF_GS_GUARD_MAJOR + + def check_output_config(mode: str, archive_dir: str, name_mode: str = "prefix") -> None: """Validiert die [output]-Section. Wirft PreflightError bei Problemen.""" @@ -101,12 +140,13 @@ def check_output_config(mode: str, archive_dir: str, ) -def check_preflight(pdfa_level: str = "") -> None: +def check_preflight(pdfa_level: str = "", skip_text: bool = False) -> None: """Prüft externe Abhängigkeiten. - Tesseract und Ghostscript müssen im PATH sein - - Bei gesetztem pdfa_level wird die Ghostscript-Version gegen den - bekannten 10.0.0–10.02.0 Bug geprüft + - 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`). Wirft PreflightError bei fehlenden Binaries oder unsicherem Ghostscript. """ @@ -117,15 +157,73 @@ def check_preflight(pdfa_level: str = "") -> None: + ". Bitte installieren: sudo apt install tesseract-ocr ghostscript" ) + reason = _gs_block_reason(pdfa_level, skip_text) + if reason: + raise PreflightError(reason) + + +def _gs_block_reason(pdfa_level: str, skip_text: bool) -> str | None: + """Liefert die Fehlermeldung, wenn ocrmypdf mit diesem Ghostscript abbricht. + + Abgebildet wird die reale Bedingung aus + `ocrmypdf/builtin_plugins/ghostscript.py::check_options()`: + + betroffene GS-Version UND (skip_text ODER redo_ocr) + UND (PDF/A-Ausgabe ODER ocrmypdf < 17) + + Die letzte Klammer ist der Teil, der v0.6.0 durchrutschen ließ: bis + einschließlich ocrmypdf 16.x steht die Pruefung ohne jeden Guard in + `check_options()` und schlaegt deshalb auch bei `output_type="pdf"` zu. + Ab 17.0.0 umschliesst sie ein + `if options.output_type.startswith('pdfa'):` — ohne PDF/A wird + Ghostscript nicht angefasst. + + `redo_ocr` kennt unsere Config nicht (es gibt keinen entsprechenden Key in + `OcrConfig`), deshalb steht es hier bewusst nicht in der Bedingung. + + Returns: + Fehlermeldung oder None, wenn die Kombination unkritisch ist. + """ + if not skip_text: + # Weder skip_text noch redo_ocr — ocrmypdf fasst den Pfad nicht an. + return None + + gs_version = detect_ghostscript_version() + if not is_ghostscript_broken(gs_version): + return None + + ocrmypdf_version = detect_ocrmypdf_version() + always = ocrmypdf_checks_gs_always(ocrmypdf_version) + if not pdfa_level and not always: + return None + if pdfa_level: - gs_version = detect_ghostscript_version() - if is_ghostscript_broken(gs_version): - raise PreflightError( - f"Ghostscript {gs_version} ist mit pdfa_level='{pdfa_level}' nicht " - "kompatibel (bekannter Bug in 10.0.0–10.02.0). " - "Entweder ghostscript auf >=10.02.1 upgraden (z.B. via bookworm-backports) " - "oder in der Config [ocr].pdfa_level = \"\" setzen." - ) + ursache = ( + f"[ocr].pdfa_level = {pdfa_level!r} (PDF/A-Ausgabe) zusammen mit " + "[ocr].skip_text = true" + ) + else: + ursache = ( + f"[ocr].skip_text = true und ocrmypdf {ocrmypdf_version} — bis " + f"einschließlich {_OCRMYPDF_GS_GUARD_MAJOR - 1}.x prüft ocrmypdf " + "Ghostscript auch dann, wenn gar kein PDF/A erzeugt wird. Jede " + "einzelne PDF würde in error/ landen" + ) + + return ( + f"Ghostscript {gs_version} ist von einem bekannten Fehler betroffen " + "(10.0.0–10.02.0, der Debian-12-Standard) und wird von ocrmypdf " + f"abgelehnt: {ursache}. " + "Abhilfe — eines von beidem: " + "(1) Ghostscript >= 10.02.1 aus bookworm-backports installieren " + "(install.sh bietet das an): " + "echo 'deb http://deb.debian.org/debian bookworm-backports main' | " + "sudo tee /etc/apt/sources.list.d/bookworm-backports.list && " + "sudo apt update && sudo apt install -t bookworm-backports ghostscript — " + "oder (2) in der Config [ocr].skip_text = false setzen " + "(dann wird vorhandener Text neu erkannt statt übersprungen)" + + (" bzw. [ocr].pdfa_level = \"\"." if pdfa_level else ".") + ) def _is_pdf(path: Path) -> bool: @@ -201,7 +299,7 @@ class HotfolderService: # ---- Lifecycle ---- def run(self) -> None: - check_preflight(self.cfg.ocr.pdfa_level) + 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) @@ -228,7 +326,7 @@ class HotfolderService: Returns: Anzahl fehlgeschlagener PDFs (0 = alles ok). """ - check_preflight(self.cfg.ocr.pdfa_level) + 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) diff --git a/requirements.txt b/requirements.txt index e4bfff5..eeda1b1 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,9 +1,24 @@ # Feste Pins: ein Update darf nicht ungefragt einen Major-Sprung einziehen -# (ein ocrmypdf 16 -> 17 reisst sonst alle Instanzen auf einmal). +# (der naechste waere ocrmypdf 18 — der reisst sonst alle Instanzen auf einmal). # Geprueft gegen Python 3.11 (Debian 12) und 3.13 (Debian 13) — fuer beide # gibt es fertige Wheels, es wird nichts kompiliert. # Beim Anheben: update.sh --rebuild-venv auf einer Testmaschine fahren. -ocrmypdf==16.13.0 +# +# ocrmypdf: MUSS 17.x sein, 16.x ist fuer uns unbrauchbar (v0.6.1). +# In ocrmypdf 16.x laeuft die Ghostscript-Pruefung in +# builtin_plugins/ghostscript.py:check_options() BEDINGUNGSLOS, also auch bei +# output_type="pdf". Auf Debian 12 (Ghostscript 10.0.0) bricht damit +# JEDE PDF ab, sobald skip_text=true gesetzt ist — und das ist unser Default: +# if Version('10.0.0') <= gs_version < Version('10.02.1') and ( +# options.skip_text or options.redo_ocr +# ): raise MissingDependencyError(...) +# Ab 17.0.0 steckt genau dieser Block in einem +# `if options.output_type.startswith('pdfa'):` — bei pdfa_level = "" wird +# Ghostscript gar nicht erst angefasst und die Pruefung greift nicht mehr. +# Deshalb hier 17.x. 17.4.1 ist die im Feld auf Debian 12 + gs 10.0.0 +# verifizierte Version; 17.12.1 traegt denselben Guard und waere der +# naechste Kandidat, ist aber noch nicht auf einer Testmaschine gefahren. +ocrmypdf==17.4.1 watchdog==6.0.0 requests==2.33.1 paramiko==4.0.0 diff --git a/tests/test_ghostscript_version.py b/tests/test_ghostscript_version.py index 666b92c..9031da7 100644 --- a/tests/test_ghostscript_version.py +++ b/tests/test_ghostscript_version.py @@ -1,6 +1,16 @@ -"""Tests für Issue #3: Ghostscript 10.0.0–10.02.0 PDF/A-Bug-Erkennung.""" +"""Tests für Issue #3: Ghostscript 10.0.0–10.02.0 Bug-Erkennung. + +Seit v0.6.1 bildet der Preflight die reale ocrmypdf-Bedingung ab: + + betroffene GS-Version UND skip_text UND (pdfa_level ODER ocrmypdf < 17) + +Der letzte Teil ist der Fall, der in v0.6.0 durchrutschte: mit ocrmypdf 16.x +greift die Ghostscript-Prüfung auch ohne PDF/A, und der Dienst meldete +trotzdem "Preflight ok", während jede einzelne PDF in error/ landete. +""" from __future__ import annotations +from contextlib import contextmanager from unittest.mock import patch import pytest @@ -9,9 +19,21 @@ from pdf_ocr_hotfolder.service import ( PreflightError, check_preflight, is_ghostscript_broken, + ocrmypdf_checks_gs_always, ) +@contextmanager +def _env(gs_version: str, ocrmypdf_version: str): + """Binaries vorhanden, Ghostscript- und ocrmypdf-Version vorgegeben.""" + with patch("pdf_ocr_hotfolder.service.shutil.which", return_value="/usr/bin/fake"), \ + patch("pdf_ocr_hotfolder.service.detect_ghostscript_version", + return_value=gs_version), \ + patch("pdf_ocr_hotfolder.service.detect_ocrmypdf_version", + return_value=ocrmypdf_version): + yield + + @pytest.mark.parametrize("version,expected", [ # Betroffene Versionen ("10.0.0", True), @@ -36,29 +58,92 @@ def test_is_ghostscript_broken(version, expected) -> None: assert is_ghostscript_broken(version) is expected -def test_check_preflight_without_pdfa_passes_with_broken_gs() -> None: - """Ohne pdfa_level darf der betroffene GS verwendet werden.""" - with patch("pdf_ocr_hotfolder.service.shutil.which", return_value="/usr/bin/fake"), \ - patch("pdf_ocr_hotfolder.service.detect_ghostscript_version", - return_value="10.0.0"): - check_preflight(pdfa_level="") # darf nicht werfen +@pytest.mark.parametrize("version,expected", [ + ("16.13.0", True), # der Pin aus v0.6.0, der den Ausfall ausgeloest hat + ("16.0.0", True), + ("15.4.4", True), + ("17.0.0", False), # ab hier steckt die Pruefung hinter output_type + ("17.4.1", False), # unser Pin + ("17.12.1", False), + ("18.0.0", False), + (None, False), # unbekannt -> kein Fehlalarm + ("", False), + ("garbage", False), +]) +def test_ocrmypdf_checks_gs_always(version, expected) -> None: + assert ocrmypdf_checks_gs_always(version) is expected -def test_check_preflight_with_pdfa_fails_on_broken_gs() -> None: - """Mit pdfa_level + kaputtem GS → PreflightError mit hilfreicher Meldung.""" - with patch("pdf_ocr_hotfolder.service.shutil.which", return_value="/usr/bin/fake"), \ - patch("pdf_ocr_hotfolder.service.detect_ghostscript_version", - return_value="10.0.0"): +# ---------------- Preflight: ocrmypdf 17.x (unser Pin) ---------------- + +def test_broken_gs_skip_text_without_pdfa_passes_on_ocrmypdf_17() -> None: + """Der Debian-12-Standardfall: ohne PDF/A fasst ocrmypdf 17 gs nicht an. + + Das ist die Default-Config (skip_text=true, pdfa_level="") auf Debian 12 — + sie muss laufen, sonst startet keine einzige Bestandsinstanz mehr. + """ + with _env("10.0.0", "17.4.1"): + check_preflight(pdfa_level="", skip_text=True) # darf nicht werfen + + +def test_broken_gs_with_pdfa_and_skip_text_fails() -> None: + """Mit pdfa_level + skip_text + kaputtem GS → PreflightError.""" + with _env("10.0.0", "17.4.1"): with pytest.raises(PreflightError, match="Ghostscript 10.0.0"): - check_preflight(pdfa_level="2") + check_preflight(pdfa_level="2", skip_text=True) -def test_check_preflight_with_pdfa_passes_on_fixed_gs() -> None: - """Mit pdfa_level + gefixtem GS → ok.""" - with patch("pdf_ocr_hotfolder.service.shutil.which", return_value="/usr/bin/fake"), \ - patch("pdf_ocr_hotfolder.service.detect_ghostscript_version", - return_value="10.02.1"): - check_preflight(pdfa_level="2") # darf nicht werfen +def test_broken_gs_with_pdfa_without_skip_text_passes() -> None: + """Ohne skip_text greift die ocrmypdf-Bedingung nicht — kein Abbruch.""" + with _env("10.0.0", "17.4.1"): + check_preflight(pdfa_level="2", skip_text=False) # darf nicht werfen + + +def test_healthy_gs_with_pdfa_and_skip_text_passes() -> None: + """Nicht betroffene GS-Version → nie ein Abbruch.""" + with _env("10.02.1", "17.4.1"): + check_preflight(pdfa_level="2", skip_text=True) # darf nicht werfen + + +# ---------------- Preflight: ocrmypdf 16.x (der Ausfall aus v0.6.0) ---------------- + +def test_broken_gs_skip_text_without_pdfa_fails_on_ocrmypdf_16() -> None: + """DER Fall, der in v0.6.0 durchrutschte. + + ocrmypdf 16.13.0 + Ghostscript 10.0.0 + skip_text=true + pdfa_level="": + "Preflight ok", Dienst active — und jede PDF landete in error/. + Jetzt muss der Dienst beim START abbrechen. + """ + with _env("10.0.0", "16.13.0"): + with pytest.raises(PreflightError) as exc_info: + check_preflight(pdfa_level="", skip_text=True) + msg = str(exc_info.value) + assert "10.0.0" in msg + assert "16.13.0" in msg + + +def test_broken_gs_without_skip_text_passes_on_ocrmypdf_16() -> None: + """skip_text=false → auch 16.x prüft Ghostscript nicht.""" + with _env("10.0.0", "16.13.0"): + check_preflight(pdfa_level="", skip_text=False) # darf nicht werfen + + +def test_healthy_gs_passes_on_ocrmypdf_16() -> None: + """Nicht betroffene GS-Version → auch mit 16.x kein Abbruch.""" + with _env("10.02.1", "16.13.0"): + check_preflight(pdfa_level="", skip_text=True) # darf nicht werfen + + +# ---------------- Meldungstext ---------------- + +def test_error_message_names_both_remedies() -> None: + """Der Admin muss aus der Meldung heraus handeln können.""" + with _env("10.0.0", "16.13.0"): + with pytest.raises(PreflightError) as exc_info: + check_preflight(pdfa_level="", skip_text=True) + msg = str(exc_info.value) + assert "backports" in msg, "Weg 1: Ghostscript aus bookworm-backports" + assert "skip_text = false" in msg, "Weg 2: skip_text abschalten" def test_default_config_pdfa_level_is_empty() -> None: @@ -70,3 +155,47 @@ def test_default_config_pdfa_level_is_empty() -> None: data = tomllib.load(f) assert data["ocr"]["pdfa_level"] == "", \ "config.example.toml muss pdfa_level='' als sicheren Default haben" + + +# ---------------- Der Dienst muss beim START abbrechen ---------------- + +def test_run_once_aborts_on_ocrmypdf_16_with_broken_gs(tmp_config) -> None: + """Abbruch beim Start statt Totalausfall bei der ersten Datei.""" + from pdf_ocr_hotfolder.service import HotfolderService + + assert tmp_config.ocr.skip_text is True + assert tmp_config.ocr.pdfa_level == "" + service = HotfolderService(tmp_config) + try: + with _env("10.0.0", "16.13.0"): + with pytest.raises(PreflightError): + service.run_once() + finally: + service._executor.shutdown(wait=False) + + +def test_check_config_returns_2_on_ocrmypdf_16_with_broken_gs( + tmp_path, tmp_config, monkeypatch, capsys) -> None: + """--check-config meldet den Zustand als Fehler (Exit 2) — auch mitten im Update.""" + import sys + + from pdf_ocr_hotfolder.__main__ import CHECK_ERROR, main + + cfg_file = tmp_path / "cfg.toml" + cfg_file.write_text(f""" +[paths] +incoming = "{tmp_config.paths.incoming}" +outgoing = "{tmp_config.paths.outgoing}" +working = "{tmp_config.paths.working}" +error = "{tmp_config.paths.error}" + +[ocr] +skip_text = true +pdfa_level = "" +""") + monkeypatch.setattr(sys, "argv", + ["pdf-ocr-hotfolder", "--config", str(cfg_file), + "--check-config"]) + with _env("10.0.0", "16.13.0"): + assert main() == CHECK_ERROR + assert "Ghostscript" in capsys.readouterr().err diff --git a/update.sh b/update.sh index 37b433a..8b5682a 100755 --- a/update.sh +++ b/update.sh @@ -14,6 +14,7 @@ # Aufruf: # sudo ./update.sh # normales Update # sudo ./update.sh --rebuild-venv # venv-Neubau erzwingen (nach dist-upgrade) +# sudo ./update.sh --no-smoke-test # ohne Rauchtest durchlaufen # set -Eeuo pipefail @@ -32,6 +33,10 @@ log_step() { echo -e "\n${BLUE}==>${NC} $*"; } : "${TAR_ROOT:=/}" : "${VERIFY_WAIT:=6}" : "${BACKUP_KEEP:=5}" +# Max. Sekunden, die der Rauchtest auf das fertige OCR-PDF wartet. Eine +# Mini-PDF braucht ein paar Sekunden (Datei-Stabilisierung + OCR), unter Last +# auch mal laenger. +: "${SMOKE_TIMEOUT:=90}" SERVICE_TEMPLATE="pdf-ocr-hotfolder@.service" UNIT_GLOB='pdf-ocr-hotfolder@*.service' LXC_DROPIN_DIR="$SYSTEMD_DIR/${SERVICE_TEMPLATE}.d" @@ -44,6 +49,7 @@ LXC_SYNCED=0 BACKUP_FILE="" PRIMARY_USER="pdfocr" TOUCHED=0 # 1, sobald auf der Platte etwas getauscht wurde +SMOKE_TEST=1 # per --no-smoke-test abschaltbar UNITS_ALL=() # alle bekannten Instanz-Units PREV_OK=() # liefen vorher sauber -> muessen nachher laufen @@ -53,6 +59,12 @@ CFG_WARN=() # Instanzen mit Config-Warnungen (Exit 1) CFG_ERR=() # Instanzen mit Config-Fehlern (Exit 2) STARTED_OK=() STARTED_FAIL=() +DEP_UPGRADES=() # Paket: alt -> neu (hoeher) +DEP_DOWNGRADES=() # Paket: alt -> neu (niedriger) <- das Gefaehrliche +DEP_NEW=() # Paket war vorher nicht installiert +SMOKE_OK=() # Rauchtest bestanden +SMOKE_FAIL=() # Rauchtest fehlgeschlagen +SMOKE_SKIP=() # Rauchtest uebersprungen (mit Begruendung) usage() { cat < 13), wenn das System-Python eine neue Version bekommen hat. + --no-smoke-test Den Rauchtest nach dem Start auslassen. -h, --help Diese Hilfe. Ohne Option prueft das Skript selbst, ob die venv noch zum System-Python passt, und baut sie bei Bedarf neu. + +Rauchtest (Standard: an) + Nach dem Start schiebt das Skript pro Instanz eine winzige Test-PDF durch + die echte Pipeline und prueft, ob sie in outgoing/ ankommt. Erst das zeigt + einen Totalausfall, den systemd nicht sieht (Dienst active, aber jede PDF + landet in error/). Test-PDF und Ergebnis werden danach restlos wieder + entfernt, auch im Fehlerfall. + + Uebersprungen wird der Test bei Instanzen mit aktivem Upload-Ziel + (Nextcloud, SFTP, Ordner-Ziel ausserhalb von outgoing) oder aktivem + E-Mail-Notify: dort wuerde die Testdatei nach aussen gehen. + + Ein fehlgeschlagener Rauchtest setzt den Exit-Code, rollt aber NICHTS + zurueck. Wartezeit pro Instanz: ${SMOKE_TIMEOUT}s (SMOKE_TIMEOUT=...). EOF } @@ -223,6 +250,97 @@ pip_install_requirements() { return 1 } +# ============================================================ +# Versionen der Kernabhaengigkeiten +# ============================================================ +# +# Ein Downgrade (z.B. ocrmypdf 17.4.1 -> 16.13.0, weil requirements.txt neu +# gepinnt wurde) verschwand bis v0.6.0 wortlos hinter "[INFO] Dependencies ok". +# Genau so ist der Totalausfall aus Issue #7 entstanden. Deshalb: vorher und +# nachher messen und jede Aenderung benennen. + +# Paketnamen aus einer requirements.txt (normalisiert: klein, Bindestriche). +pinned_package_names() { + local req + for req in "$@"; do + [ -f "$req" ] || continue + sed -n 's/^[[:space:]]*\([A-Za-z0-9._-]\{1,\}\)[[:space:]]*[<>=!~].*/\1/p' "$req" + done | tr '[:upper:]_' '[:lower:]-' | sort -u +} + +# "paket version" je Zeile — nur fuer die in $2.. gepinnten Pakete. +snapshot_pinned_versions() { + local venv="$1"; shift + local line name ver n + local -a names=() + mapfile -t names < <(pinned_package_names "$@") + [ "${#names[@]}" -gt 0 ] || return 0 + [ -x "$venv/bin/pip" ] || return 0 + + while IFS= read -r line; do + case "$line" in (*==*) ;; (*) continue ;; esac + name="$(printf '%s' "${line%%==*}" | tr '[:upper:]_' '[:lower:]-')" + ver="${line#*==}" + for n in "${names[@]}"; do + [ "$n" = "$name" ] && printf '%s %s\n' "$name" "$ver" + done + done < <("$venv/bin/pip" list --format=freeze 2>/dev/null || true) +} + +# 0, wenn $1 eine aeltere Version als $2 ist. +version_is_older() { + [ "$1" = "$2" ] && return 1 + [ "$(printf '%s\n%s\n' "$1" "$2" | sort -V | head -n1)" = "$1" ] +} + +# Vergleicht zwei Snapshots und fuellt DEP_UPGRADES/DEP_DOWNGRADES/DEP_NEW. +diff_pinned_versions() { + local before="$1" after="$2" + local name new old + DEP_UPGRADES=(); DEP_DOWNGRADES=(); DEP_NEW=() + + while read -r name new; do + [ -n "$name" ] || continue + old="$(awk -v p="$name" '$1==p {print $2; exit}' "$before" 2>/dev/null || true)" + if [ -z "$old" ]; then + DEP_NEW+=("$name $new") + elif [ "$old" = "$new" ]; then + continue + elif version_is_older "$new" "$old"; then + DEP_DOWNGRADES+=("$name: $old -> $new") + else + DEP_UPGRADES+=("$name: $old -> $new") + fi + done < "$after" +} + +# Gibt die Aenderungen aus. Downgrades als WARN — die sind das Gefaehrliche. +report_dependency_changes() { + local entry + if [ "${#DEP_DOWNGRADES[@]}" -eq 0 ] && [ "${#DEP_UPGRADES[@]}" -eq 0 ] \ + && [ "${#DEP_NEW[@]}" -eq 0 ]; then + log_info "Kernabhaengigkeiten unveraendert." + return 0 + fi + for entry in "${DEP_DOWNGRADES[@]:-}"; do + [ -n "$entry" ] || continue + log_warn "DOWNGRADE: $entry" + done + if [ "${#DEP_DOWNGRADES[@]}" -gt 0 ]; then + log_warn " Ein Downgrade kommt aus einem gesenkten Pin in requirements.txt." + log_warn " Pruefen, ob das beabsichtigt ist — die aeltere Version kann auf" + log_warn " diesem System unbrauchbar sein, ohne dass systemd es merkt." + fi + for entry in "${DEP_UPGRADES[@]:-}"; do + [ -n "$entry" ] || continue + log_info "Upgrade: $entry" + done + for entry in "${DEP_NEW[@]:-}"; do + [ -n "$entry" ] || continue + log_info "Neu: $entry" + done +} + # Baut die venv neu: alte wegsichern, neue bauen, Requirements installieren, # und erst bei Erfolg die alte entfernen. Scheitert etwas, wird die alte # zurueckgerollt und hart abgebrochen. @@ -534,6 +652,266 @@ check_all_configs() { done } +# ============================================================ +# Rauchtest +# ============================================================ +# +# systemd sagt "active", --check-config sagt "ok" — und trotzdem landet jede +# PDF in error/ (Issue #7). Das sieht man nur, wenn man wirklich eine Datei +# durch die Pipeline schiebt. Deshalb: eine Mini-PDF nach incoming/, warten, +# bis sie in outgoing/ ankommt, danach restlos aufraeumen. +# +# Die Test-PDF steckt als base64 hier drin — kein Pillow, kein gs, kein +# convert, damit der Test keine Abhaengigkeit mitbringt, die auf dem Zielsystem +# fehlen kann. 694 Bytes, eine Seite, eine Textzeile. +smoke_pdf_base64() { + cat <<'SMOKEPDF' +JVBERi0xLjQKJeLjz9MKMSAwIG9iago8PCAvVHlwZSAvQ2F0YWxvZyAvUGFnZXMgMiAwIFIgPj4K +ZW5kb2JqCjIgMCBvYmoKPDwgL1R5cGUgL1BhZ2VzIC9LaWRzIFszIDAgUl0gL0NvdW50IDEgPj4K +ZW5kb2JqCjMgMCBvYmoKPDwgL1R5cGUgL1BhZ2UgL1BhcmVudCAyIDAgUiAvTWVkaWFCb3ggWzAg +MCA1OTUgODQyXSAvUmVzb3VyY2VzIDw8IC9Gb250IDw8IC9GMSA0IDAgUiA+PiA+PiAvQ29udGVu +dHMgNSAwIFIgPj4KZW5kb2JqCjQgMCBvYmoKPDwgL1R5cGUgL0ZvbnQgL1N1YnR5cGUgL1R5cGUx +IC9CYXNlRm9udCAvSGVsdmV0aWNhID4+CmVuZG9iago1IDAgb2JqCjw8IC9MZW5ndGggMTQ0ID4+ +CnN0cmVhbQpCVCAvRjEgMjQgVGYgNzIgNzAwIFRkIChQREYgT0NSIEhPVEZPTERFUiBTTU9LRVRF +U1QpIFRqIEVUCkJUIC9GMSAxMSBUZiA3MiA2NzAgVGQgKEF1dG9tYXRpc2NoIGVyemV1Z3Qgdm9u +IHVwZGF0ZS5zaCAtIGJpdHRlIGlnbm9yaWVyZW4uKSBUaiBFVAplbmRzdHJlYW0KZW5kb2JqCnhy +ZWYKMCA2CjAwMDAwMDAwMDAgNjU1MzUgZiAKMDAwMDAwMDAxNSAwMDAwMCBuIAowMDAwMDAwMDY0 +IDAwMDAwIG4gCjAwMDAwMDAxMjEgMDAwMDAgbiAKMDAwMDAwMDI0NyAwMDAwMCBuIAowMDAwMDAw +MzE3IDAwMDAwIG4gCnRyYWlsZXIKPDwgL1NpemUgNiAvUm9vdCAxIDAgUiA+PgpzdGFydHhyZWYK +NTExCiUlRU9GCg== +SMOKEPDF +} + +# Liest die fuer den Rauchtest noetigen Werte aus einer Instanz-Config. +# Ausgabe: "SCHLUESSELWert" je Zeile. +smoke_read_config() { + local py="$1" cfg="$2" + "$py" - "$cfg" <<'SMOKECFG' +import sys +import tomllib + +with open(sys.argv[1], "rb") as fh: + data = tomllib.load(fh) + + +def sec(*keys): + cur = data + for key in keys: + cur = cur.get(key, {}) if isinstance(cur, dict) else {} + return cur if isinstance(cur, dict) else {} + + +paths = sec("paths") +out = sec("output") +folder = sec("upload", "folder") +nextcloud = sec("upload", "nextcloud") +sftp = sec("upload", "sftp") +mail = sec("notify", "email") + +# Alles, was die Testdatei nach aussen tragen wuerde. Defaults wie in config.py. +blockers = [] +if nextcloud.get("enabled", False): + blockers.append("upload.nextcloud") +if sftp.get("enabled", False): + blockers.append("upload.sftp") +if mail.get("enabled", False): + blockers.append("notify.email") +# Ordner-Upload ohne target schreibt nach outgoing/ (harmlos); mit target +# geht die Datei irgendwo anders hin - oft eine Kundenfreigabe. +if folder.get("enabled", True) and str(folder.get("target", "")).strip(): + blockers.append("upload.folder (target gesetzt)") + +mode = str(out.get("original_on_success", "delete")) +archive = str(out.get("archive_dir", "")) if mode == "archive" else "" + +for key, value in ( + ("INCOMING", paths.get("incoming", "")), + ("OUTGOING", paths.get("outgoing", "")), + ("WORKING", paths.get("working", "")), + ("ERROR", paths.get("error", "")), + ("ARCHIVE", archive), + ("NAME_MODE", out.get("name_mode", "prefix")), + ("NAME_TAG", out.get("name_tag", "OCR_")), + ("BLOCKERS", ", ".join(blockers)), +): + print(f"{key}\t{value}") +SMOKECFG +} + +# Bildet build_output_name() aus processor.py nach. +smoke_output_name() { + local src="$1" mode="$2" tag="$3" + if [ "$mode" = "none" ] || [ -z "$tag" ]; then + printf '%s' "$src"; return 0 + fi + case "$mode" in + prefix) printf '%s%s' "$tag" "$src" ;; + suffix) printf '%s%s%s' "${src%.*}" "$tag" ".${src##*.}" ;; + *) printf '%s' "$src" ;; + esac +} + +# Raeumt alle Spuren des Rauchtests weg — in JEDEM Ausgang. Nach dieser +# Funktion darf in keinem der Verzeichnisse etwas vom Test liegen bleiben. +smoke_cleanup() { + local in_dir="$1" out_dir="$2" work_dir="$3" err_dir="$4" arch_dir="$5" + local src_name="$6" out_name="$7" + local f + for f in "$in_dir/$src_name" \ + "$work_dir/$src_name" \ + "$work_dir/__ocr_$out_name" \ + "$out_dir/$out_name" \ + "$err_dir/$src_name" \ + "$err_dir/$out_name" \ + "${arch_dir:+$arch_dir/$src_name}"; do + [ -n "$f" ] || continue + [ -e "$f" ] && rm -f "$f" 2>/dev/null + done + return 0 +} + +# Schiebt eine Test-PDF durch eine laufende Instanz. +# 0 = bestanden, 1 = durchgefallen, 2 = uebersprungen +smoke_test_instance() { + local name="$1" + local py="$INSTALL_DIR/venv/bin/python" + local cfg="$CONFIG_DIR/$name.toml" + local key value + local INCOMING="" OUTGOING="" WORKING="" ERROR="" ARCHIVE="" + local NAME_MODE="prefix" NAME_TAG="OCR_" BLOCKERS="" + + [ -f "$cfg" ] || { SMOKE_SKIP+=("$name (keine Config)"); return 2; } + [ -x "$py" ] || { SMOKE_SKIP+=("$name (kein venv-Python)"); return 2; } + + local cfg_out + if ! cfg_out="$(smoke_read_config "$py" "$cfg" 2>&1)"; then + log_warn " ⚠ $name — Config liess sich fuer den Rauchtest nicht lesen:" + printf '%s\n' "$cfg_out" | tail -n 5 + SMOKE_SKIP+=("$name (Config nicht lesbar)") + return 2 + fi + while IFS="$(printf '\t')" read -r key value; do + case "$key" in + INCOMING) INCOMING="$value" ;; + OUTGOING) OUTGOING="$value" ;; + WORKING) WORKING="$value" ;; + ERROR) ERROR="$value" ;; + ARCHIVE) ARCHIVE="$value" ;; + NAME_MODE) NAME_MODE="$value" ;; + NAME_TAG) NAME_TAG="$value" ;; + BLOCKERS) BLOCKERS="$value" ;; + esac + done <<< "$cfg_out" + + if [ -n "$BLOCKERS" ]; then + log_info " ⏭ $name — Rauchtest uebersprungen: aktive Ziele ($BLOCKERS)." + log_info " Eine Testdatei wuerde sonst beim Empfaenger landen." + SMOKE_SKIP+=("$name (aktive Ziele: $BLOCKERS)") + return 2 + fi + if [ -z "$INCOMING" ] || [ -z "$OUTGOING" ]; then + SMOKE_SKIP+=("$name ([paths] unvollstaendig)") + return 2 + fi + if [ ! -d "$INCOMING" ]; then + log_warn " ⚠ $name — $INCOMING existiert nicht; Rauchtest uebersprungen." + SMOKE_SKIP+=("$name (incoming fehlt)") + return 2 + fi + + # Eindeutiger Name, der mit keiner Kundendatei kollidieren kann. + local src_name out_name + src_name="__smoketest_update_$(date +%Y%m%d-%H%M%S)_$$.pdf" + out_name="$(smoke_output_name "$src_name" "$NAME_MODE" "$NAME_TAG")" + + # Als Instanz-User anlegen, damit Rechte und Eigentuemer denen einer + # echten Scan-Datei entsprechen. + local unit_user + unit_user="$(systemctl show -p User --value "pdf-ocr-hotfolder@${name}.service" 2>/dev/null || true)" + [ -n "$unit_user" ] || unit_user="$PRIMARY_USER" + + local tmp_pdf + tmp_pdf="$(mktemp)" + if ! smoke_pdf_base64 | base64 -d > "$tmp_pdf" 2>/dev/null; then + rm -f "$tmp_pdf" + log_warn " ⚠ $name — Test-PDF liess sich nicht erzeugen (base64 fehlt?)." + SMOKE_SKIP+=("$name (base64 fehlt)") + return 2 + fi + + # Erst fertig schreiben, dann hineinbewegen: der Watcher soll keine + # halb geschriebene Datei sehen. + local staged="$INCOMING/.$src_name.part" + cp "$tmp_pdf" "$staged" 2>/dev/null || { + rm -f "$tmp_pdf" "$staged" + log_warn " ⚠ $name — nach $INCOMING liess sich nicht schreiben." + SMOKE_SKIP+=("$name (incoming nicht beschreibbar)") + return 2 + } + rm -f "$tmp_pdf" + chown "$unit_user":"$unit_user" "$staged" 2>/dev/null || true + chmod 644 "$staged" 2>/dev/null || true + if ! mv "$staged" "$INCOMING/$src_name" 2>/dev/null; then + rm -f "$staged" + log_warn " ⚠ $name — Testdatei liess sich nicht in $INCOMING einstellen." + SMOKE_SKIP+=("$name (incoming nicht beschreibbar)") + return 2 + fi + + # Warten: fertiges PDF in outgoing/ oder Abbruch in error/. + local waited=0 result=1 + while [ "$waited" -lt "$SMOKE_TIMEOUT" ]; do + if [ -f "$OUTGOING/$out_name" ]; then result=0; break; fi + if [ -n "$ERROR" ] && { [ -f "$ERROR/$src_name" ] || [ -f "$ERROR/$out_name" ]; }; then + result=1; break + fi + sleep 1 + waited=$((waited + 1)) + done + + smoke_cleanup "$INCOMING" "$OUTGOING" "$WORKING" "$ERROR" "$ARCHIVE" \ + "$src_name" "$out_name" + + if [ "$result" -eq 0 ]; then + log_info " ✅ $name — Test-PDF kam nach ${waited}s in outgoing/ an" + SMOKE_OK+=("$name") + return 0 + fi + if [ "$waited" -ge "$SMOKE_TIMEOUT" ]; then + log_error " ❌ $name — Test-PDF war nach ${SMOKE_TIMEOUT}s nicht in $OUTGOING" + else + log_error " ❌ $name — Test-PDF landete in $ERROR statt in $OUTGOING" + fi + log_error " Der Dienst laeuft, verarbeitet aber nichts. Ursache im Journal:" + log_error " journalctl -u pdf-ocr-hotfolder@${name}.service -n 80 --no-pager" + SMOKE_FAIL+=("$name") + return 1 +} + +# Faehrt den Rauchtest fuer alle Instanzen, die nach dem Update laufen. +run_smoke_tests() { + SMOKE_OK=(); SMOKE_FAIL=(); SMOKE_SKIP=() + if [ "$SMOKE_TEST" -ne 1 ]; then + log_step "Rauchtest" + log_warn "Uebersprungen (--no-smoke-test). Ein Totalausfall bei der" + log_warn "Verarbeitung faellt damit erst der ersten echten Datei auf." + return 0 + fi + if [ "${#STARTED_OK[@]}" -eq 0 ]; then + log_step "Rauchtest" + log_info "Keine laufende Instanz — nichts zu testen." + return 0 + fi + + log_step "Rauchtest (Test-PDF durch die echte Pipeline)" + local unit name + for unit in "${STARTED_OK[@]}"; do + name="${unit#pdf-ocr-hotfolder@}" + name="${name%.service}" + smoke_test_instance "$name" || true + done +} + # ============================================================ # Backup # ============================================================ @@ -672,6 +1050,7 @@ fi while [ "$#" -gt 0 ]; do case "$1" in --rebuild-venv) REBUILD_VENV=1 ;; + --no-smoke-test) SMOKE_TEST=0 ;; -h|--help) usage; exit 0 ;; *) log_error "Unbekannte Option: $1"; echo; usage; exit 1 ;; esac @@ -754,6 +1133,14 @@ stop_instances create_backup +# Versionen der gepinnten Pakete festhalten, SOLANGE die alte +# requirements.txt noch liegt — sonst verschwindet ein Downgrade +# (z.B. ocrmypdf 17.4.1 -> 16.13.0) wortlos hinter "Dependencies ok ✓". +DEP_BEFORE="$(mktemp)" +DEP_AFTER="$(mktemp)" +snapshot_pinned_versions "$INSTALL_DIR/venv" \ + "$INSTALL_DIR/requirements.txt" "$REPO_DIR/requirements.txt" > "$DEP_BEFORE" || true + log_step "Code aktualisieren" TOUCHED=1 rm -rf "$INSTALL_DIR/pdf_ocr_hotfolder" @@ -772,6 +1159,12 @@ else log_info "Dependencies ok ✓" fi +snapshot_pinned_versions "$INSTALL_DIR/venv" \ + "$INSTALL_DIR/requirements.txt" "$REPO_DIR/requirements.txt" > "$DEP_AFTER" || true +diff_pinned_versions "$DEP_BEFORE" "$DEP_AFTER" +report_dependency_changes +rm -f "$DEP_BEFORE" "$DEP_AFTER" + install_units log_step "Berechtigungen setzen" @@ -785,10 +1178,16 @@ check_all_configs log_step "Instanzen starten" start_instances +# Ab hier wird nichts mehr getauscht. Der Rauchtest laeuft bewusst OHNE +# ERR-Trap: ein durchgefallener Test setzt den Exit-Code, loest aber keinen +# Abbruch und kein Zurueckrollen aus. +trap - ERR INT TERM + +run_smoke_tests + # ============================================================ # Zusammenfassung: Ist gegen Soll # ============================================================ -trap - ERR INT TERM RC=0 echo @@ -851,6 +1250,42 @@ if [ "$APT_WARN" -eq 1 ]; then log_warn "System-Pakete konnten nicht vollstaendig abgeglichen werden (siehe oben)." fi +# Versionsspruenge der Kernabhaengigkeiten hier noch einmal — im Log weiter +# oben gehen sie zwischen pip-Ausgaben unter. +if [ "${#DEP_DOWNGRADES[@]}" -gt 0 ]; then + log_warn "Abhaengigkeiten DOWNGEGRADED:" + for entry in "${DEP_DOWNGRADES[@]}"; do + log_warn " $entry" + done + log_warn " Das kommt aus requirements.txt. War es nicht beabsichtigt: Pin" + log_warn " korrigieren und update.sh --rebuild-venv erneut fahren." +fi +if [ "${#DEP_UPGRADES[@]}" -gt 0 ]; then + log_info "Abhaengigkeiten angehoben:" + for entry in "${DEP_UPGRADES[@]}"; do + log_info " $entry" + done +fi +if [ "${#DEP_NEW[@]}" -gt 0 ]; then + log_info "Abhaengigkeiten neu dazugekommen: ${DEP_NEW[*]}" +fi + +if [ "$SMOKE_TEST" -ne 1 ]; then + log_warn "Rauchtest: uebersprungen (--no-smoke-test)" +else + [ "${#SMOKE_OK[@]}" -gt 0 ] && log_info "Rauchtest bestanden: ${SMOKE_OK[*]}" + [ "${#SMOKE_SKIP[@]}" -gt 0 ] && log_info "Rauchtest uebersprungen: ${SMOKE_SKIP[*]}" + if [ "${#SMOKE_FAIL[@]}" -gt 0 ]; then + log_error "RAUCHTEST FEHLGESCHLAGEN: ${SMOKE_FAIL[*]}" + log_error " Diese Instanzen laufen, verarbeiten aber keine PDFs." + log_error " Es wurde NICHT zurueckgerollt. Journal ansehen:" + for name in "${SMOKE_FAIL[@]}"; do + log_error " journalctl -u pdf-ocr-hotfolder@${name}.service -n 80 --no-pager" + done + RC=1 + fi +fi + echo if [ "$RC" -eq 0 ]; then log_info "Update auf $NEW_VERSION abgeschlossen ✓"