fix: ocrmypdf-Pin auf 17.4.1, Preflight erkennt den GS-Fall, Rauchtest (v0.6.1)

v0.6.0 hat ocrmypdf auf 16.13.0 gepinnt, um einen ungewollten Major-Sprung
zu verhindern. Auf Bestandsinstallationen war das ein DOWNGRADE (dort lief
via ">=16.0" bereits 17.x) — und 16.13.0 bricht auf Debian 12 mit dem
Bord-Ghostscript 10.0.0 bei JEDER PDF ab, sobald skip_text gesetzt ist.
Im Test auf CT 200 lief das Update mit Exit 0 durch, der Dienst blieb
"active", --check-config meldete "Preflight ok" — und jede Datei landete
in error/. Stiller Totalausfall.

- requirements.txt: ocrmypdf==17.4.1 (real auf Debian 12 + gs 10.0.0
  verifiziert). Ab 17.0.0 steht die GS-Pruefung in ocrmypdf unter einem
  `if options.output_type.startswith('pdfa')`; bis 16.x lief sie ohne
  diesen Guard und schlug auch bei output_type="pdf" zu.
- check_preflight() prueft Ghostscript nicht mehr nur bei gesetztem
  pdfa_level, sondern bildet die reale Bedingung ab:
  betroffene GS-Version UND skip_text UND (PDF/A ODER ocrmypdf < 17).
  Der Dienst bricht damit beim Start ab statt bei der ersten Datei.
- update.sh zeigt Versionsspruenge der gepinnten Pakete; Downgrades als
  WARN, auch in der Abschluss-Zusammenfassung.
- update.sh faehrt nach dem Start einen Rauchtest (eingebettete Mini-PDF
  durch die echte Pipeline) und raeumt restlos auf. Uebersprungen, wenn
  Upload-Ziele oder E-Mail-Notify aktiv sind, damit kein Testmuell zum
  Kunden geht. Abschaltbar mit --no-smoke-test.
- Doku korrigiert: pdfa_level = "" allein ist keine Entwarnung, die haengt
  an der ocrmypdf-Version.

152 Tests gruen.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-09-22 22:55:47 +02:00
parent 3e24aa2ecd
commit aa9918adba
15 changed files with 1044 additions and 86 deletions
+11 -7
View File
@@ -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)
+108
View File
@@ -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_<zeitstempel>_<pid>.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
+2 -2
View File
@@ -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
+1 -1
View File
@@ -1 +1 @@
0.6.0
0.6.1
+11 -3
View File
@@ -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
+42 -10
View File
@@ -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 |
+26 -9
View File
@@ -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==<version>
pip install --dry-run --only-binary=:all: --python-version 3.13 \
--target /tmp/wheelcheck ocrmypdf==<version>
```
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
+107 -7
View File
@@ -21,6 +21,7 @@ sudo ./update.sh
```
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_<zeitstempel>_<pid>.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@<instanz> -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
+1 -1
View File
@@ -1,3 +1,3 @@
"""PDF OCR Hotfolder — Scanner-PDFs automatisch durchsuchbar machen."""
__version__ = "0.6.0"
__version__ = "0.6.1"
+8 -2
View File
@@ -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:
+11 -5
View File
@@ -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
+112 -14
View File
@@ -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,14 +157,72 @@ def check_preflight(pdfa_level: str = "") -> None:
+ ". Bitte installieren: sudo apt install tesseract-ocr ghostscript"
)
if pdfa_level:
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 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."
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:
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 ".")
)
@@ -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)
+17 -2
View File
@@ -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
+148 -19
View File
@@ -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
+436 -1
View File
@@ -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 <<EOF
@@ -65,10 +77,25 @@ Optionen:
installieren. Das ist der Aufruf nach einem Debian-
Major-Upgrade (apt full-upgrade, z.B. 12 -> 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: "SCHLUESSEL<TAB>Wert" 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 ✓"