diff --git a/AI_AGENT_BRIEFING.md b/AI_AGENT_BRIEFING.md index 0e26eae..ff0103e 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-23 -**Version:** 0.6.3 -**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. +**Version:** 0.7.0 +**Status:** Multi-Instanz-Betrieb, Preflight-Checks (inkl. veraPDF-Binary), Fehlerzählung, Wiederaufnahme aus `working/`, Kollisionsschutz auf allen Schreibpfaden, Bewachung des Verzeichnis-Watches und ein abgesicherter Updater (venv-Rebuild, Backup, Verifikation, Versionssprung-Meldung, Rauchtest). `install.sh` und `update.sh` teilen sich `lib/common.sh`. Test-Suite grün (**254 pytest-Tests**). Ein Produktiv-Einsatz ist im Repo (README/CHANGELOG) nicht dokumentiert — die bisherigen Fixes stammen aus Issues #1–#6, aus Vorbereitungen auf Debian 13 und aus einer Durchsicht auf stille Datenverlust-Pfade (0.7.0), nicht aus einem belegten Dauerbetrieb. > **Betriebsabläufe stehen nicht hier**, sondern in: > [README.md](README.md) (Einstieg, Layout, Config-Überblick) · @@ -23,33 +23,46 @@ pdf-ocr-hotfolder/ │ ├── __init__.py # Versionsstring (__version__) │ ├── __main__.py # CLI (argparse: --config, --once, --check-config, --version) │ ├── config.py # TOML-Loader, Dataclasses, ConfigError, Warnungen -│ ├── service.py # HotfolderService (watchdog + ThreadPool), Preflight, Resume, Zähler -│ ├── processor.py # ocrmypdf-Call, veraPDF, Ausgabename, Original-Entsorgung +│ ├── service.py # HotfolderService (watchdog + ThreadPool), Preflight, Resume, Zähler, Observer-Bewachung +│ ├── processor.py # ocrmypdf-Call, veraPDF, Ausgabename, Original-Entsorgung, Kollisionsschutz │ └── uploaders.py # folder, nextcloud (WebDAV), sftp, E-Mail-Notify -├── tests/ # pytest-Suite (152 Tests, ocrmypdf wird gemockt) +├── lib/ +│ └── common.sh # gemeinsam fuer install.sh + update.sh: Logging, require_root, +│ # Layout-Konstanten, apt-Paketliste, venv_is_healthy() +├── tests/ # pytest-Suite (254 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 -│ ├── test_config_warnings.py # Legacy- und Unbekannt-Warnungen +│ ├── test_config_warnings.py # Legacy- und Unbekannt-Warnungen +│ ├── test_dispose_failure.py # Original nicht entsorgbar -> Erfolg + warning +│ ├── test_error_collision.py # error/ ueberschreibt nichts │ ├── test_error_counting.py │ ├── test_ghostscript_version.py +│ ├── test_incoming_non_pdf.py # Sammelmeldung fuer Fremddateien +│ ├── test_log_stream.py # Logging geht nach stdout +│ ├── test_observer_watchdog.py # toter Observer -> EXIT_OBSERVER_DEAD │ ├── test_ocr_timeout.py │ ├── test_once_exit_code.py +│ ├── test_outgoing_collision.py # outgoing/ + Archiv ueberschreiben nichts │ ├── test_output_naming.py │ ├── test_preflight.py -│ ├── test_resume_working.py # Wiederaufnahme + __ocr_-Fragmente -│ └── test_upload_folder.py +│ ├── test_relative_paths.py # relative Pfade -> ConfigError +│ ├── test_resume_working.py # Wiederaufnahme + __ocr_-Fragmente +│ ├── test_startup_toml_error.py # kaputtes TOML beim Dienststart -> Exit 2 +│ ├── test_upload_folder.py +│ └── test_verapdf_preflight.py # Binary-Pruefung + VeraPdfUnavailable ├── systemd/ -│ ├── pdf-ocr-hotfolder@.service # Template-Unit (Instanz = %i), TimeoutStopSec=300 +│ ├── pdf-ocr-hotfolder@.service # Template-Unit (Instanz = %i), TimeoutStopSec=300, +│ │ # RestartPreventExitStatus=2 │ └── lxc-compat.conf # Drop-in-Vorlage: Hardening für LXC abschalten ├── docs/ -│ ├── INSTALLATION.md # Erstinstallation + Konfigurationsreferenz +│ ├── INSTALLATION.md # Erstinstallation, Konfigurationsreferenz, Exit-Codes, Troubleshooting │ ├── UPDATE.md # update.sh, Backup/Rollback, --check-config, Config-Drift │ └── OS-UPGRADE.md # Debian 12 -> 13 -> 14, venv-Rebuild, Pins ├── pytest.ini # testpaths = tests ├── config.example.toml -├── install.sh # Interaktiver Installer + Instanz-Manager -├── update.sh # Updater (--help, --rebuild-venv), ~860 Zeilen +├── install.sh # Interaktiver Installer + Instanz-Manager, ~500 Zeilen +├── update.sh # Updater (--help, --rebuild-venv, --no-smoke-test), ~1270 Zeilen ├── requirements.txt # feste Pins (ocrmypdf 17.x — 16.x ist unbrauchbar, s. 0.6.1) ├── VERSION ├── CHANGELOG.md @@ -82,6 +95,7 @@ Instanzen auf einmal reißen). Geprüft gegen Python 3.11 (Debian 12) und 3.13 | Pfad | Inhalt | |------|--------| | `/opt/pdf-ocr-hotfolder/` | Code + venv (für alle Instanzen gemeinsam) | +| `/opt/pdf-ocr-hotfolder/lib/common.sh` | Kopie der gemeinsamen Shell-Bibliothek; `update.sh` sourct sie, wenn es nicht aus dem Repo läuft | | `/opt/pdf-ocr-hotfolder/.repo_path` | Pfad zum Repo, aus dem installiert wurde (nutzt `update.sh`) | | `/etc/pdf-ocr-hotfolder/.toml` | Config pro Instanz (mode 640, root:) | | `/etc/systemd/system/pdf-ocr-hotfolder@.service` | Template-Unit | @@ -92,7 +106,9 @@ Instanzen auf einmal reißen). Geprüft gegen Python 3.11 (Debian 12) und 3.13 Ein eigenes Logverzeichnis gibt es **nicht** (seit 0.4.1 auch nicht mehr vom Installer angelegt): `_setup_logging()` nutzt `logging.basicConfig()` ohne -FileHandler, alles geht nach stdout → journald. +FileHandler, alles geht nach stdout → journald. Der Stream wird seit 0.7.0 +**explizit** auf `sys.stdout` gesetzt — der `basicConfig()`-Default ist +**stderr**, und README wie `docs/INSTALLATION.md` versprachen stdout. ```bash journalctl -u pdf-ocr-hotfolder@ -f # eine Instanz mitlesen @@ -116,7 +132,12 @@ Arbeit am Code zählt: - Basis-Install wird an `venv` + Template-Unit erkannt und übersprungen — **außer** die venv passt nicht mehr zum System-Python, dann läuft er zur Reparatur erneut - (`venv_is_healthy()` in `install.sh`, schlankere Variante der Prüfung in `update.sh`). + (`venv_is_healthy()` aus `lib/common.sh`, Befunde über `report_venv_issues`). +- In Containern (`systemd-detect-virt --container`) bietet der Installer das + LXC-Drop-in an **und** prüft, ob `systemd-journald` läuft. Tut es das nicht, + warnt er (der Dienst loggt ausschließlich nach journald), nennt den + `ImportCredential=`-Drop-in für den Debian-13-Fall und fragt, ob fortgefahren + werden soll. - Abfragen pro Instanz: Name, Basis-Pfad, Service-User, **OCR-Sprachen**, **Original archivieren?** — `LANGS`/`ORIG_MODE`/`ARCHIVE_DIR` sind `local` in `create_instance()`, gelten also **instanz-lokal** und nicht global. @@ -132,15 +153,50 @@ Arbeit am Code zählt: Kommentarzeilen über den Keys nicht getroffen werden; Pfad-Variablen laufen vorher durch `sed_escape_repl()` (maskiert `\`, `&`, `|`). Nach dem sed-Lauf liest `config_value()` die drei Keys zurück und vergleicht sie mit der Eingabe. -- Die apt-Paketliste steht als **einzige Quelle** in `install.sh` zwischen den +- Die apt-Paketliste steht als **einzige Quelle** in `lib/common.sh` zwischen den Marken `# --- BEGIN apt-packages` / `# --- END apt-packages` in der Funktion - `pdf_ocr_apt_packages()`. **`update.sh` schneidet diesen Block per `sed` heraus - und evaluiert ihn** — Marken und Funktionsname dürfen sich nicht ändern, ohne - `update.sh` anzupassen. + `pdf_ocr_apt_packages()`. `install.sh` bekommt sie durchs Sourcen; + **`update.sh` schneidet den Block zusätzlich per `sed` aus der Repo-Fassung + heraus und evaluiert ihn**, weil gesourct evtl. die ältere installierte Kopie + wurde. Marken und Funktionsname dürfen sich nicht ändern, ohne `update.sh` + anzupassen. - Instanz wird sofort `enable --now` gestartet. Löschen macht der Installer nicht, das steht als Handgriff in [docs/INSTALLATION.md](docs/INSTALLATION.md#instanz-manuell-löschen). +## 🧰 `lib/common.sh` — gemeinsame Shell-Bibliothek + +Seit 0.7.0 sourcen `install.sh` und `update.sh` dieselbe Datei. Sie führt beim +Sourcen **nichts** aus, was das System anfasst, und enthält nur Definitionen: + +| Inhalt | Details | +|--------|---------| +| Ausgabe | `log_info`/`log_warn`/`log_error`/`log_step`, Farbkonstanten | +| Rechte | `require_root ""` | +| Layout | `INSTALL_DIR`, `CONFIG_DIR`, `DATA_ROOT`, `SYSTEMD_DIR`, `DEFAULT_USER`, `SERVICE_TEMPLATE`, `LXC_DROPIN_DIR`, `LXC_DROPIN`, `COMMON_LIB_REL` — alle per `: "${X:=…}"`, also aus der Umgebung überschreibbar (Tests) | +| Pakete | `pdf_ocr_apt_packages()` zwischen den BEGIN/END-Marken | +| venv | `py_mm()`, `pyvenv_cfg_mm()`, `venv_is_healthy()`, `report_venv_issues()` | + +Drei Dinge, die man dabei wissen muss: + +- **`venv_is_healthy()` gibt es nur noch einmal.** Vorher hatte jedes der beiden + Skripte eine eigene Fassung, und die in `install.sh` war die schlankere: sie + verglich nur `major.minor` des venv-Interpreters mit dem System-Python und + hätte den Distro-Upgrade-Fall über `pyvenv.cfg` nicht bemerkt. Erhalten + geblieben ist die gründliche Fassung (Verzeichnis, ausführbarer Interpreter, + Interpreter **läuft**, Version == System-Python, `pyvenv.cfg` == Interpreter). + Sie setzt `VENV_ISSUES` und gibt nichts selbst aus — dafür ist + `report_venv_issues()` da. +- **`lib/` wird mitinstalliert.** `install.sh` **und** `update.sh` kopieren es + nach `/opt/pdf-ocr-hotfolder/lib/`, jeweils mit vorherigem + `rm -rf "${INSTALL_DIR:?}/lib"`. Es liegt damit auch im Update-Backup (das + sichert `$INSTALL_DIR` ohne venv). +- **Fundreihenfolge in `update.sh`:** erst `$SCRIPT_DIR/lib/common.sh` (Repo), + dann `${INSTALL_DIR}/lib/common.sh`. Fehlt sie überall, bricht das Skript + **sofort** ab — ein `command not found` mitten im Lauf wäre die schlechtere + Nachricht. `install.sh` sucht nur neben sich und verlangt das vollständige + Repo. + ## 🔄 Update-Verhalten (Kurzfassung) Vollständig: [docs/UPDATE.md](docs/UPDATE.md). Für die Arbeit am Skript wichtig: @@ -159,10 +215,10 @@ Vollständig: [docs/UPDATE.md](docs/UPDATE.md). Für die Arbeit am Skript wichti - **Verifikation**: `verify_unit()` wartet `VERIFY_WAIT` (6 s) und prüft `is-active`, `is-failed` **und** `NRestarts` — sonst würde ein Crash-Loop bei `Type=simple` als Erfolg durchgehen. Vorher `reset-failed`. -- **venv-Health** (`venv_is_healthy()` in `update.sh`): Verzeichnis, ausführbarer - Interpreter, Interpreter **läuft** überhaupt, `major.minor` == System-Python, - `pyvenv.cfg` stimmt mit dem Interpreter überein. Bei Drift wird auch ohne - `--rebuild-venv` neu gebaut. +- **venv-Health** (`venv_is_healthy()` aus `lib/common.sh`): Verzeichnis, + ausführbarer Interpreter, Interpreter **läuft** überhaupt, `major.minor` == + System-Python, `pyvenv.cfg` stimmt mit dem Interpreter überein. Bei Drift wird + auch ohne `--rebuild-venv` neu gebaut. - **`rebuild_venv()` ist ganz oder gar nicht**: alte venv nach `venv.old-`, neu bauen, Requirements installieren, **erst bei Erfolg** die alte löschen; scheitert etwas, wird zurückgerollt und hart abgebrochen. @@ -182,7 +238,9 @@ Vollständig: [docs/UPDATE.md](docs/UPDATE.md). Für die Arbeit am Skript wichti muss erhalten bleiben — `update.sh` kopiert daraus (`.repo_path`). - `PDF_OCR_UPDATE_LIB_ONLY=1 source ./update.sh` lädt nur die Funktionen, ohne irgendetwas zu tun — dafür sind `INSTALL_DIR`, `CONFIG_DIR`, `SYSTEMD_DIR`, - `BACKUP_DIR`, `TAR_ROOT`, `VERIFY_WAIT`, `BACKUP_KEEP` überschreibbar. + `BACKUP_DIR`, `TAR_ROOT`, `VERIFY_WAIT`, `BACKUP_KEEP` überschreibbar. Die + Vorgaben stehen in `lib/common.sh` und sind dort ebenfalls überschreibbar + gehalten (`: "${X:=…}"`). ## ⚙️ Konfiguration (Überblick) @@ -191,16 +249,24 @@ Vollständiges Beispiel mit Kommentaren: `config.example.toml`. | Sektion | Zweck | |---------|-------| -| `[paths]` | `incoming`, `outgoing`, `working`, `error` — **Pflicht**, fehlt einer → `ConfigError` + Exit 2 | +| `[paths]` | `incoming`, `outgoing`, `working`, `error` — **Pflicht** und **absolut**, fehlt einer oder ist relativ → `ConfigError` + Exit 2 | | `[ocr]` | `languages`, `jobs`, `skip_text`, `oversample`, `pdfa_level`, `deskew`, `clean`, `max_workers`, `timeout` (Sekunden **pro Seite**) | -| `[output]` | `name_mode` (`prefix`/`suffix`/`none`), `name_tag`, `original_on_success` (`delete`/`archive`), `archive_dir` | +| `[output]` | `name_mode` (`prefix`/`suffix`/`none`), `name_tag`, `original_on_success` (`delete`/`archive`), `archive_dir` (absolut) | | `[verapdf]` | `enabled`, `binary`, `flavour` — optionale PDF/A-Validierung per CLI | -| `[upload.folder]` | `enabled`, `target` (leer = `[paths].outgoing`, dann No-op) | +| `[upload.folder]` | `enabled`, `target` (leer = `[paths].outgoing`, dann No-op; sonst absolut) | | `[upload.nextcloud]` | `enabled`, `url`, `username`, `password`, `remote_path`, `verify_ssl` | | `[upload.sftp]` | `enabled`, `host`, `port`, `username`, `key_file`, `password`, `remote_path` | | `[notify.email]` | `enabled`, SMTP-Daten, `from_addr`, `to_addrs`, `on` = `always`/`errors`/`never` | | `[logging]` | `level` = DEBUG/INFO/WARNING/ERROR | +**Absolute Pfade sind Pflicht** (`_require_absolute()` in `config.py`, seit +0.7.0): `[paths]`-Einträge, `[output].archive_dir` und +`[upload.folder].target`. Ein relativer Pfad wurde gegen das +`WorkingDirectory` der Unit aufgelöst, landete also still unter +`/opt/pdf-ocr-hotfolder/` — der Scanner schrieb dann woanders hin als der +Dienst schaute, ohne dass irgendwo ein Fehler auftauchte. Leere Werte bleiben +erlaubt (beide Keys sind optional). + Unbekannte Keys werden beim Laden zwar ignoriert, aber **nicht mehr still**: `_collect_unknown_keys()` sammelt sie in `Config.unknown_keys` (Format `[ocr].langauges`, auch ganze unbekannte Sektionen und Upload-/Notify-Targets), @@ -221,19 +287,37 @@ Unbekannte Keys werden beim Laden zwar ignoriert, aber **nicht mehr still**: ### `--check-config` `python -m pdf_ocr_hotfolder --check-config --config ` lädt die Config, -zeigt Pfade/Sprachen/Timeout/PDF/A, fährt `check_preflight()` und -`check_output_config()` und gibt die Warnungen aus. Exit-Codes: -`CHECK_OK=0`, `CHECK_WARN=1`, `CHECK_ERROR=2`. Hat Vorrang vor `--once`. -`update.sh` wertet genau diese Codes aus und erkennt an der argparse-Meldung, -wenn der installierte Code das Flag noch nicht kennt. +zeigt Pfade/Sprachen/Timeout/PDF/A sowie veraPDF-Binary und -Flavour (bzw. +`(aus)`), fährt `check_preflight()` und `check_output_config()` und gibt die +Warnungen aus. Exit-Codes: `CHECK_OK=0`, `CHECK_WARN=1`, `CHECK_ERROR=2`. Hat +Vorrang vor `--once`. `update.sh` wertet genau diese Codes aus und erkennt an +der argparse-Meldung, wenn der installierte Code das Flag noch nicht kennt. + +### Exit-Codes des Prozesses + +| Code | Woher | Bedeutung | +|------|-------|-----------| +| `0` | `main()` | regulärer Stopp, `--once` ohne Fehler, Config sauber | +| `1` | `main()` | `--once` mit `error_count > 0`; `--check-config` mit Warnungen | +| `2` | `main()` | `ConfigError`, `TOMLDecodeError`, `OSError` beim Laden, `PreflightError` | +| `3` | `service.EXIT_OBSERVER_DEAD` | watchdog-Observer gestorben — Neustart **erwünscht** | + +`run()` gibt seit 0.7.0 einen `int` zurück (vorher `None`), `main()` reicht ihn +durch. Die Unit setzt **`RestartPreventExitStatus=2`**: Config-/Preflight-Fehler +heilt kein Neustart, die Instanz bleibt sichtbar `failed` stehen statt im +5-Sekunden-Takt zu kreisen (das Start-Rate-Limit greift bei `RestartSec=5` nie). +Exit 3 ist **bewusst nicht** 2, damit `Restart=on-failure` dort greift. +Anwender-Sicht: [docs/INSTALLATION.md](docs/INSTALLATION.md#exit-codes). ## 🔄 Verarbeitungs-Flow -**Beim Start (`run()` wie `run_once()`), vor allem anderen:** -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/` +**Beim Start (`run()` wie `run_once()`), vor allem anderen** — beide rufen +dasselbe `_preflight()`: +1. `check_preflight(pdfa_level, skip_text, verapdf_enabled, verapdf_binary)` — `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_verapdf_binary()` — nur bei `[verapdf].enabled`: `binary` darf nicht leer sein und muss über `resolve_verapdf_binary()` auffindbar **und ausführbar** sein (Pfad mit `/` direkt geprüft, nackter Name über `shutil.which`) +3. `check_output_config()` — validiert `original_on_success`, `archive_dir` (Pflicht bei `archive`) und `name_mode` +4. Scheitert eines davon → `PreflightError`, CLI beendet sich mit **Exit-Code 2** (ebenso bei kaputter/unlesbarer/fehlender Config) +5. `ensure_dirs()`, dann `_scan_existing()`: **zuerst `working/`**, danach `incoming/`; Dateien ohne `.pdf`-Endung meldet `_report_non_pdf()` als **eine** Sammelzeile (Anzahl + bis zu 3 Beispiele), nur beim Start-Scan **Wiederaufnahme aus `working/` (`_scan_working()`):** `process_pdf()` verschiebt das Original vor dem OCR nach `working/`. Wird der @@ -252,16 +336,34 @@ liegen und wurde nie wieder angefasst — stiller Datenverlust. Jetzt: `process_pdf()` für die neue ab und lässt sie in `incoming/` liegen, statt den laufenden Vorgang stillschweigend zu überschreiben. +**Im Betrieb (`_wait_loop()`):** die Schleife wartet nicht nur auf den Stopp, +sie prüft **sekündlich `self._observer.is_alive()`**. Stirbt der Observer +(erschöpftes `fs.inotify.max_user_watches`, ersetztes oder neu gemountetes +Verzeichnis), blieb die Unit früher `active (running)` und verarbeitete stumm +nichts mehr. Jetzt: `log.error` mit den möglichen Ursachen und `return +EXIT_OBSERVER_DEAD` (3), damit `Restart=on-failure` den Watch neu aufsetzt. Bei +regulärem Stopp wird die Prüfung übersprungen, sonst gäbe es dort einen +Fehlalarm. + **Pro Datei:** 1. `watchdog` triggert auf `created`/`moved`/`closed` in `incoming/` 2. `_wait_until_stable()` wartet, bis die Datei nicht mehr wächst (max. ~60s) 3. Move nach `working/` (entfällt bei Wiederaufnahme) 4. `ocrmypdf.ocr()` als **Library-Call** (kein Subprozess-Start pro PDF), Ziel ist `working/__ocr_` -5. Optional: veraPDF-Validierung (CLI-Subprozess) — bei FAIL geht das OCR-Ergebnis nach `error/`, das Original folgt `original_on_success` (wird also bei `archive` **nicht** gelöscht) -6. Move nach `outgoing/` unter dem laut `[output]` gebauten Namen (`build_output_name()`) -7. Original in `working/` wird laut `original_on_success` **gelöscht** oder nach `archive_dir` **archiviert** (Kollision → Timestamp-Suffix) +5. Optional: veraPDF-Validierung (CLI-Subprozess). **FAIL** → OCR-Ergebnis nach `error/`, Original folgt `original_on_success` (bei `archive` also erhalten). **`VeraPdfUnavailable`** → Original **und** Ergebnis nach `error/`, das Original wird weder gelöscht noch archiviert +6. Move nach `outgoing/` unter dem laut `[output]` gebauten Namen (`build_output_name()`), vorher durch `_collision_free_path()` — `ProcessResult.output` trägt den **tatsächlich** geschriebenen Pfad +7. Original in `working/` wird laut `original_on_success` **gelöscht** oder nach `archive_dir` **archiviert** (Kollision → Timestamp-Suffix). `_dispose_original()` wirft nicht, sondern liefert bei Misserfolg einen Meldungstext → `ProcessResult.warning` 8. Aktive Upload-Targets ausführen (folder/nextcloud/sftp) -9. E-Mail-Notify je nach `[notify.email].on` +9. E-Mail-Notify je nach `[notify.email].on` — bei gesetztem `warning` als **„OK mit Warnung"** und mit `success=False` an `notify_email()`, damit sie auch bei `on = "errors"` zugestellt wird + +**Kollisionsschutz (`_collision_free_path()` in `processor.py`):** existiert das +Ziel, wird `scan.pdf` zu `scan_.pdf`; ist auch das belegt (zwei +Dateien in derselben Sekunde, mehrere Worker), wird zusätzlich hochgezählt. +Benutzt von `outgoing/`, `_dispose_original()` (Archiv), `_move_to_error()` — +und damit auch `_rescue_to_error()` — sowie `upload_folder()` in +`uploaders.py`. Jeder dieser Pfade überschrieb vorher still. **`uploaders.py` +importiert dafür `_collision_free_path` aus `processor.py`** — die einzige +Abhängigkeit in diese Richtung. **Fehlerbehandlung:** @@ -272,6 +374,8 @@ liegen und wurde nie wieder angefasst — stiller Datenverlust. Jetzt: | In `working/` liegt schon eine andere Datei gleichen Namens | ja | bleibt in `incoming/` | | OCR wirft (ocrmypdf) | ja | `error/` | | veraPDF FAIL | ja | OCR-Ergebnis nach `error/`, Original laut `original_on_success` (`delete` → weg, `archive` → `archive_dir`; seit 0.4.1) | +| veraPDF nicht befragbar (`VeraPdfUnavailable`) | ja | **Original UND Ergebnis** nach `error/`; das Original wird weder gelöscht noch archiviert (seit 0.7.0) | +| Original lässt sich nicht entsorgen (`_dispose_original`) | **nein** — gilt als Erfolg | Ergebnis in `outgoing/`, Upload läuft; Original bleibt in `working/` und wird beim nächsten Start erneut verarbeitet. `log.error` + Mail „OK mit Warnung" | | Beliebige Exception aus `process_pdf()` (z.B. `shutil.move` nach `outgoing/`) | ja | `_rescue_to_error()` sucht in `incoming/` und `working/` und verschiebt nach `error/` | | Mindestens ein Upload-Ziel schlägt fehl | ja | PDF bleibt **bewusst in `outgoing/`** (das OCR war ja erfolgreich), Fehler-Mail nennt die Ziele | @@ -296,9 +400,13 @@ Der Service läuft in allen Fällen weiter (kein `exit 1` wie im alten Bash-Tool `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`). +- **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 beide Skripte das mit **demselben** `venv_is_healthy()` aus `lib/common.sh` (seit 0.7.0 — vorher hatte jedes eine eigene Fassung, und die in `install.sh` war die schwächere). +- **`incoming/` darf nicht auf CIFS/NFS liegen.** Der Hotfolder hängt vollständig an inotify, und inotify sieht nur Änderungen des lokalen Kernels. Schreibt ein anderer Rechner über SMB/NFS in ein gemountetes Verzeichnis, entsteht **gar kein Event** — der Dienst meldet `active (running)`, arbeitet beim Start-Scan den Bestand ab und bemerkt danach nichts mehr. Betriebsvorgabe: ext4, xfs oder zfs, `incoming/` lokal ([docs/INSTALLATION.md](docs/INSTALLATION.md#dateisystem-ext4-xfs-oder-zfs)). +- **Debian 13 in LXC auf Proxmox: journald scheitert mit `243/CREDENTIALS`.** systemd ≥ 255 (Debian 13 hat 257) setzt `ImportCredential=journal.*`; der Hilfsprozess `(sd-mkdcreds)` mountet dafür, und das AppArmor-Profil des Proxmox-Hosts blockiert das. Da der Dienst ausschließlich nach journald loggt, gibt es dann **keine** Logs. Betrifft jede Debian-13-LXC auf Proxmox 8.4 (Debian 12 mit systemd 252 nicht) und legt auch logind, networkd, console-getty und tmpfiles-setup lahm. Abhilfe und Hintergrund: [docs/INSTALLATION.md](docs/INSTALLATION.md#debian-13-in-lxc-auf-proxmox-journald-scheitert-243credentials). In 0.6.3 stand hier noch, das sei ein Schaden auf genau einer Maschine — das war falsch. +- **Ein nicht aufrufbares veraPDF war bis 0.6.3 der gefährlichste Fehler des Dienstes.** `run_verapdf()` lieferte für ein fehlendes Binary, einen Timeout oder eine leere Ausgabe schlicht `False` — also ein inhaltliches FAIL-Urteil. Damit wanderte jedes OCR-Ergebnis nach `error/` und das Original wurde laut `original_on_success` entsorgt, beim Default `delete` also gelöscht. Scan für Scan, bei grünem `systemctl status`. Seit 0.7.0: Preflight-Prüfung (Exit 2) **und** `VeraPdfUnavailable` als eigene Ausnahme, die ausdrücklich kein Urteil über die Datei ist. - **systemd-Hardening bricht in LXC-Containern** (`Error 226/NAMESPACE` durch `PrivateTmp`, `ProtectSystem` usw., Issue #4). Gegenmittel ist das Drop-in `systemd/lxc-compat.conf` nach `/etc/systemd/system/pdf-ocr-hotfolder@.service.d/`; der Installer erkennt Container via `systemd-detect-virt --container` und bietet es an, `update.sh` zieht ein vorhandenes Drop-in nach. - **Das Paket wird nicht pip-installiert, sondern nach `/opt/pdf-ocr-hotfolder` kopiert.** Gestartet wird per `python -m pdf_ocr_hotfolder`, gefunden wird das Modul nur über das Arbeitsverzeichnis — `WorkingDirectory=/opt/pdf-ocr-hotfolder` in der Unit ist daher Pflicht, nicht Kosmetik (Issue #5). Auch `update.sh` ruft `--check-config` deshalb mit `cd "$INSTALL_DIR"` auf. +- **Relative Pfade in der Config waren still falsch.** Sie wurden gegen `WorkingDirectory=/opt/pdf-ocr-hotfolder` aufgelöst, nicht gegen das Verzeichnis der Config. Seit 0.7.0 `ConfigError` + Exit 2 für `[paths]`, `[output].archive_dir`, `[upload.folder].target`. **Das ist der einzige Fall, in dem ein Update von 0.6.x eine bisher „laufende" Instanz stoppt** — gewollt, siehe [docs/UPDATE.md](docs/UPDATE.md#relative-pfade--fehler-seit-070). - **Klartext-Passwörter in der Instanz-Config**: SMTP-, Nextcloud- und SFTP-Zugangsdaten stehen unverschlüsselt in `/etc/pdf-ocr-hotfolder/.toml`. Deshalb `chmod 640` und `chown root:`, und `/etc/pdf-ocr-hotfolder` selbst `750 root:pdfocr`. **Das Update-Backup enthält diese Configs** und ist deshalb `0600 root:root` in einem `700`-Verzeichnis. Beim Debuggen weder Config noch Backup in ein Ticket kopieren. - **Das Update-Backup enthält die venv NICHT.** Ein Rollback per `tar -xzf … -C /` holt den Paketstand also nicht zurück, und `tar` löscht nichts, was neu hinzugekommen ist. Grenzen des Rollbacks: [docs/UPDATE.md](docs/UPDATE.md#grenzen-des-rollbacks). @@ -316,18 +424,20 @@ python -m pdf_ocr_hotfolder --config /tmp/config.toml Tests (aus dem Repo-Root, `pytest.ini` setzt `testpaths = tests`): ```bash -pytest # aktuell 152 Tests +pytest # aktuell 254 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. +`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. veraPDF wird über `subprocess.run` gemockt, der watchdog-Observer über ein Fake-Objekt mit `is_alive()`. ## 📋 Roadmap / TODO -- [x] Tests (`pytest`) für `processor` und `uploaders` — 152 Tests +- [x] Tests (`pytest`) für `processor` und `uploaders` — 254 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) -- [ ] Test-Lücken schließen: der watchdog-Eventpfad (`_Handler`/`Observer`) wird nirgends getestet, `run_verapdf()` ebenso wenig (der FAIL-*Pfad* in `process_pdf()` ist getestet, die veraPDF-CLI-Anbindung selbst nicht), und `run_ocr()` nur gegen ein gemocktes ocrmypdf — es gibt keinen Test mit einer echten PDF-Datei. Auch `upload_nextcloud()` und `upload_sftp()` sind ungetestet (nur `upload_folder()`). **`install.sh`/`update.sh` haben keine automatisierten Tests** — die `LIB_ONLY`-Schnittstelle in `update.sh` ist dafür vorbereitet, aber ungenutzt. +- [x] Stille Datenverlust-Pfade geschlossen: Kollisionsschutz in `outgoing/`, Archiv, `error/` und Ordner-Upload; veraPDF-Preflight + `VeraPdfUnavailable`; relative Pfade als Config-Fehler +- [x] Toter watchdog-Observer wird erkannt (Exit 3) und getestet +- [ ] Test-Lücken schließen: `run_ocr()` läuft nur gegen ein gemocktes ocrmypdf — es gibt keinen Test mit einer echten PDF-Datei. Der `_Handler`-Eventpfad ist weiterhin ungetestet (getestet ist nur die Observer-**Bewachung** in `_wait_loop()`). `upload_nextcloud()` und `upload_sftp()` sind ungetestet (nur `upload_folder()`). **`install.sh`/`update.sh`/`lib/common.sh` haben keine automatisierten Tests** — die `LIB_ONLY`-Schnittstelle in `update.sh` ist dafür vorbereitet, aber ungenutzt; `lib/common.sh` wäre jetzt die einfachste Stelle zum Anfangen, weil sie beim Sourcen nichts tut. - [ ] Prometheus-Metriken (verarbeitete PDFs, Fehlerquote, Laufzeit) - [ ] CLI-Subkommandos: `pdf-ocr-hotfolder reprocess ` - [ ] Instanz-Löschung in `install.sh` statt als Handarbeit diff --git a/CHANGELOG.md b/CHANGELOG.md index e56a83f..7902f0b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,188 @@ # Changelog +## [0.7.0] - 2026-09-23 + +Schliesst die stillen Datenverlust-Pfade: gleichnamige Dateien werden nirgends +mehr ueberschrieben, und ein nicht aufrufbares veraPDF wird nicht mehr als +"PDF ist ungueltig" missverstanden. Dazu Robustheit (Exit 2 statt Traceback, +toter Verzeichnis-Watch faellt auf, relative Pfade sind ein Config-Fehler) und +eine gemeinsame Shell-Bibliothek fuer install.sh und update.sh. +Test-Suite: 254 pytest-Tests gruen. + +### Added +- **`lib/common.sh` — gemeinsame Shell-Bibliothek.** `install.sh` und + `update.sh` sourcen sie und teilen sich darueber Log-Funktionen + (`log_info`/`log_warn`/`log_error`/`log_step`), `require_root`, die + Layout-Konstanten (`INSTALL_DIR`, `CONFIG_DIR`, `DATA_ROOT`, `SYSTEMD_DIR`, + `DEFAULT_USER`, `SERVICE_TEMPLATE`, `LXC_DROPIN*`), die apt-Paketliste + (`pdf_ocr_apt_packages()` zwischen den BEGIN/END-Marken) und die + venv-Pruefung (`py_mm`, `pyvenv_cfg_mm`, `venv_is_healthy`, + `report_venv_issues`). + - Die **Paketliste** steht damit nicht mehr in `install.sh`, sondern in + `lib/common.sh`; `update.sh` schneidet sie weiterhin per `sed` aus der + **Repo**-Fassung heraus, damit beim Update die neue Liste gilt und nicht + die vielleicht aeltere, bereits gesourcte aus der Installation. + - `venv_is_healthy()` gab es bisher **zweimal** — die schlanke Variante in + `install.sh` haette den Distro-Upgrade-Fall (`pyvenv.cfg` gegen + System-Python) nicht erkannt. Jetzt existiert nur noch die gruendliche + Fassung, und `install.sh` nennt die Befunde ueber `report_venv_issues`. + - `lib/` wird von `install.sh` **und** `update.sh` nach + `/opt/pdf-ocr-hotfolder/lib/` mitkopiert und liegt damit im + Update-Backup. `update.sh` sourct bevorzugt die Repo-Fassung neben sich + und faellt auf die installierte zurueck; fehlt sie ueberall, bricht es + sofort ab statt mitten im Lauf mit "command not found". +- **veraPDF wird im Preflight geprueft** (`check_verapdf_binary()`, + `resolve_verapdf_binary()`). Mit `[verapdf].enabled = true` muss + `[verapdf].binary` auf ein vorhandenes, ausfuehrbares Programm zeigen — + sonst startet der Dienst gar nicht erst (Exit 2), und `--check-config` + meldet den Fehler. Ein Pfad mit `/` wird direkt geprueft, ein nackter Name + im `PATH` gesucht. `--check-config` zeigt jetzt ausserdem Binary und Flavour + bzw. `(aus)` an. + Hintergrund: ein Tippfehler im Pfad war der **gefaehrlichste Fehler des + ganzen Dienstes**. `run_verapdf()` fand das Programm fuer JEDE Datei nicht, + wertete das als FAIL, schob das OCR-Ergebnis nach `error/` — und + `_dispose_original()` entsorgte das Original laut + `[output].original_on_success`, bei dessen Default `delete` also Scan fuer + Scan die Vorlage. Die Unit stand dabei als `active (running)` da. +- **Exit 3: toter Verzeichnis-Watch** (`EXIT_OBSERVER_DEAD` in `service.py`). + Stirbt der watchdog-Observer im Betrieb (erschoepftes + `fs.inotify.max_user_watches`, ersetztes oder neu gemountetes Verzeichnis), + blieb die Unit bisher `active (running)` und verarbeitete nichts mehr — kein + Log, keine Mail, niemand merkt es. Die Hauptschleife prueft den Observer + jetzt sekuendlich mit, loggt im Ernstfall die moeglichen Ursachen und + beendet sich mit Exit 3; `Restart=on-failure` startet den Dienst neu und der + Watch wird neu aufgesetzt. Bewusst **nicht** Exit 2 — den unterdrueckt die + Unit jetzt beim Neustart (s.u.). +- **`ProcessResult.warning`** — erfolgreicher Durchlauf mit Nebenbefund. Bisher + gab es nur Erfolg oder Fehler; ein liegengebliebenes Original passte in + keine der beiden Schubladen. +- **Sammelmeldung fuer Nicht-PDF-Dateien in `incoming/`** + (`_report_non_pdf()`). Alles ohne `.pdf`-Endung wurde ignoriert und + sammelte sich stumm an (Scanner-Fehlablagen, abgebrochene Uploads, + Thumbnails). Beim Start-Scan gibt es jetzt **eine** Warnung mit Anzahl und + bis zu drei Beispielnamen — keine Zeile pro Datei und nichts im laufenden + Betrieb. +- **`install.sh` warnt beim Erstinstall in Containern, wenn + `systemd-journald` nicht laeuft.** Der Dienst loggt ausschliesslich nach + journald; ist journald kaputt, gibt es gar keine Logs. Die Warnung nennt den + Drop-in-Befehl fuer den Debian-13-Fall und fragt, ob fortgefahren werden + soll. +- **Dokumentation** (siehe unten unter *Docs*): Dateisystem-Festlegung, + Debian-13-journald-Befund, konkrete Speicher-Messwerte, Exit-Code-Tabelle. + +### Fixed +- **`outgoing/`: gleichnamige Datei wird nicht mehr ueberschrieben.** Liefert + der Scanner denselben Dateinamen ein zweites Mal (oder wurde das + Vorgaengerergebnis noch nicht abgeholt), legte der `shutil.move` die + aeltere Datei kommentarlos um. Neu: `_collision_free_path()` haengt einen + Zeitstempel an (`scan.pdf` -> `scan_20260923-081500.pdf`), bei Kollision + innerhalb derselben Sekunde zusaetzlich einen Zaehler. Es gibt eine + `log.warning`, und `ProcessResult.output` traegt den **tatsaechlich** + geschriebenen Pfad — die Upload-Ziele und die Mail nennen damit die richtige + Datei. +- **Dieselbe Klasse in `error/` und beim Ordner-Upload.** `_move_to_error()` + (und damit auch `_rescue_to_error()`) sowie `upload_folder()` mit + abweichendem `[upload.folder].target` ersetzten bisher still eine + gleichnamige Datei im Ziel. Beide nutzen jetzt denselben + Zeitstempel-Ausweg. Scheitert dieselbe `scan.pdf` zweimal, liegen jetzt + beide Fassungen in `error/`. +- **veraPDF: Stoerung wird nicht mehr als FAIL gewertet.** `run_verapdf()` + unterscheidet jetzt zwischen einem echten Urteil (PASS/FAIL) und + "Programm nicht aufrufbar, nicht startbar, Timeout oder kein PASS/FAIL in + der Ausgabe" — Letzteres wirft `VeraPdfUnavailable`. Im Stoerungsfall + wandern **Original UND OCR-Ergebnis** nach `error/`, und das Original wird + weder geloescht noch archiviert, unabhaengig von + `[output].original_on_success`. Vorher lieferte jeder dieser Faelle + schlicht `False` und damit ein Fehlurteil ueber die Datei. +- **Kaputtes TOML und nicht lesbare Config beenden den Dienststart mit + Exit 2** statt mit einem nackten Traceback. `main()` faengt jetzt + `tomllib.TOMLDecodeError` und `OSError` genauso ab wie `--check-config`; die + Meldung nennt Zeile und Spalte, sofern der Interpreter sie liefert + (`TOMLDecodeError.lineno`/`.colno` gibt es erst ab Python 3.14 — auf + Debian 12 steht die Position nur im Meldungstext, deshalb `getattr`). +- **Relative Pfade in der Config sind jetzt ein Fehler.** `[paths].incoming`, + `outgoing`, `working`, `error` sowie `[output].archive_dir` und + `[upload.folder].target` muessen absolut sein (`_require_absolute()`, + `ConfigError` + Exit 2). Ein relativer Pfad wurde gegen das + `WorkingDirectory` der Unit aufgeloest und landete still unter + `/opt/pdf-ocr-hotfolder/` — der Scanner schrieb dann woanders hin als der + Dienst schaute, ohne dass irgendwo ein Fehler auftauchte. Leere Werte + bleiben erlaubt (`archive_dir`/`target` sind optional). +- **Ein Fehler beim Entsorgen des Originals entwertet den Durchlauf nicht + mehr.** `_dispose_original()` wirft nicht mehr, sondern liefert eine + Meldung zurueck: zum Aufrufzeitpunkt liegt das fertige PDF schon in + `outgoing/`, eine Exception von dort haette den gelungenen Durchlauf im + Catch-all des Service in einen Fehler verwandelt — mitsamt ausgefallenem + Upload. Jetzt gilt der Lauf als Erfolg, Upload und Benachrichtigung laufen, + es gibt aber eine `log.error` (Original liegt noch in `working/` und wird + beim naechsten Start erneut durch das OCR geschickt), und die Mail geht als + **"OK mit Warnung"** auch bei `[notify.email].on = "errors"` raus — sonst + waere genau das wieder ein stiller Fehlerpfad. +- **Log geht explizit nach stdout.** `logging.basicConfig()` schreibt per + Default nach **stderr**; README und `docs/INSTALLATION.md` versprachen aber + stdout. Fuer journald egal, fuer den dort beschriebenen + Vordergrund-Notbehelf und fuer jede Weiterleitung nicht. + +### Changed +- **`systemd/pdf-ocr-hotfolder@.service`: `RestartPreventExitStatus=2`.** + Exit 2 = Config- oder Preflight-Fehler, den behebt kein Neustart. Bisher + startete `Restart=on-failure` die Instanz endlos im 5-Sekunden-Takt neu + (das Start-Rate-Limit greift bei `RestartSec=5` nie). Jetzt bleibt die + Instanz sichtbar `failed` stehen. +- `HotfolderService.run()` gibt einen **Exit-Code** zurueck (0 oder + `EXIT_OBSERVER_DEAD`) statt `None`; `main()` reicht ihn durch. Preflight und + `check_output_config()` stehen jetzt gebuendelt in `_preflight()`, das + `run()` und `run_once()` gemeinsam nutzen. +- `check_preflight()` nimmt zwei weitere Parameter (`verapdf_enabled`, + `verapdf_binary`) — beide mit Default, bestehende Aufrufe bleiben gueltig. +- `config.example.toml`: Warnhinweise zu absoluten Pfaden (`[paths]`, + `[output].archive_dir`, `[upload.folder].target`), zur veraPDF-Preflight- + Pruefung samt Begruendung und zum Kollisionsverhalten im Upload-Ziel. +- `install.sh` ist von ~73 Zeilen Kopf auf das Sourcen von `lib/common.sh` + geschrumpft und nutzt durchgehend `SYSTEMD_DIR`/`LXC_DROPIN` statt + hartkodierter Pfade. + +### Docs +- **`docs/INSTALLATION.md`, Systemanforderungen: Dateisystem festgelegt.** Der + Dienst laeuft ausschliesslich auf **ext4, xfs oder zfs**. `incoming/` gehoert + auf ein lokales, inotify-faehiges Dateisystem — **kein CIFS/NFS-Mount**: dort + liefert inotify grundsaetzlich keine Events, weil Schreibzugriffe anderer + Rechner am lokalen Kernel vorbeigehen. Der Dienst wuerde dann nur noch beim + Start etwas verarbeiten und im laufenden Betrieb nichts mehr bemerken. +- **Speicher-Messwerte konkretisiert.** Zur bestehenden 512-MB-Messung kommen + Zahlen von einem **2-GB-Container** (Debian 13, 300-dpi-A4-Seite mit + `deskew`, `oversample = 300`, `jobs = 4`): Laufzeit **19 s**, `MemoryPeak` + des Dienstes **380 MB**, `memory.peak` des ganzen Containers **503 MB**, + `oom_kill 0`. Auf derselben Maschine mit 512 MB war genau das der OOM-Kill. + Die Empfehlung "mindestens 2 GB" ist damit belegt statt geschaetzt. +- **Debian 13 in LXC auf Proxmox: journald scheitert — verifizierter Befund, + betrifft jede Debian-13-LXC auf Proxmox 8.4.** In 0.6.3 stand noch, das sei + ein Schaden auf genau einer Maschine; das war falsch. Ursache: systemd >= 255 + (Debian 13 hat 257) setzt `ImportCredential=journal.*` in der + journald-Unit, der Hilfsprozess `(sd-mkdcreds)` mountet dafuer, und das + AppArmor-Profil des Proxmox-Hosts blockiert das + (`apparmor="DENIED" operation="mount" profile="lxc-_" + name="/dev/" comm="(sd-mkdcreds)"` im Host-Log). Debian 12 (systemd 252) + kennt `ImportCredential` nicht und ist nicht betroffen. Dokumentiert sind + die reboot-feste Abhilfe im Container (Drop-in + `systemd-journald.service.d/no-credentials.conf` mit leerem + `ImportCredential=`), der Hinweis auf die weiteren betroffenen Units + (logind, networkd, console-getty, tmpfiles-setup) und der saubere Weg + host-seitig. +- **Exit-Code-Tabelle 0/1/2/3** in `docs/INSTALLATION.md`, verlinkt aus + README, `docs/UPDATE.md` und dem Troubleshooting — mit dem Hinweis, dass die + Unit bei 2 **nicht** neu startet und bei 3 gerade doch. +- Die fuenf Ueberschriften der Instanz-Abfragen in `docs/INSTALLATION.md` sind + **entnummeriert** (`### 4. OCR-Sprachen` -> `### OCR-Sprachen`). Die Anker + hiessen vorher `#4-ocr-sprachen` und waeren bei jeder Umsortierung + gebrochen; die Reihenfolge steht weiterhin im Text. +- `AI_AGENT_BRIEFING.md` auf den heutigen Stand gezogen: Dateibaum mit `lib/` + und allen 20 Test-Dateien, nur noch **ein** `venv_is_healthy()`, + Paketliste in `lib/common.sh`, veraPDF-Preflight, Kollisionsschutz, + Exit-Codes, Observer-Bewachung, Testzahl 254. +- Testzahl ueberall von 152 auf **254** korrigiert (README, + `AI_AGENT_BRIEFING.md`, `docs/OS-UPGRADE.md`). + ## [0.6.3] - 2026-09-23 Reine Doku-Version — kein Code, kein Installer, kein Updater, keine Unit, keine diff --git a/README.md b/README.md index 6f85173..ddfedab 100644 --- a/README.md +++ b/README.md @@ -20,11 +20,13 @@ Weiter: [CHANGELOG.md](CHANGELOG.md) · [AI_AGENT_BRIEFING.md](AI_AGENT_BRIEFING - 🔁 **Parallelverarbeitung** mehrerer PDFs (ThreadPool, konfigurierbar) - ♻️ **Wiederaufnahme aus `working/`** nach einem harten Stopp — keine Datei bleibt liegen - ✅ **PDF/A-Output** (1, 2 oder 3) optional -- 🛡️ **veraPDF-Validierung** optional +- 🛡️ **veraPDF-Validierung** optional — Binary wird im Preflight geprüft, eine Störung gilt nicht als FAIL +- 🚫 **Überschreibt nie eine gleichnamige Datei** — in `outgoing/`, `error/`, Archiv und Ordner-Upload weicht sie mit Zeitstempel aus - ☁️ **Upload-Ziele**: lokaler Ordner, Nextcloud (WebDAV via Python), SFTP - 📧 **E-Mail-Notify** (immer / nur Fehler / nie) - 🔐 **Service-User-Support** für lokale **und AD-User mit lokaler UID** (SSSD/Winbind) - ⚙️ Saubere systemd-Integration mit auto-Restart, **Multi-Instanz** über eine Template-Unit +- 👁️ **Toter Verzeichnis-Watch wird erkannt** — der Dienst beendet sich (Exit 3), systemd setzt den Watch neu auf - 🩺 **`--check-config`** prüft eine Instanz-Config ohne etwas zu verarbeiten ## Schnellstart @@ -32,6 +34,10 @@ Weiter: [CHANGELOG.md](CHANGELOG.md) · [AI_AGENT_BRIEFING.md](AI_AGENT_BRIEFING **Voraussetzungen:** Debian 12 oder 13, Python 3.11+, root — und **mindestens 2 GB RAM** (512 MB reichen für 300-dpi-Scans nachweislich nicht, siehe [Systemanforderungen](docs/INSTALLATION.md#systemanforderungen)). +Dateisystem **ext4, xfs oder zfs**; `incoming/` muss **lokal** liegen — auf +einem CIFS/NFS-Mount liefert inotify keine Events und der Hotfolder bemerkt +neue Dateien nur noch beim Start +([warum](docs/INSTALLATION.md#dateisystem-ext4-xfs-oder-zfs)). ```bash git clone gitea@gitea.sonith.de:sonith_ug/pdf-ocr-hotfolder.git @@ -64,6 +70,7 @@ Nach einem Debian-Major-Upgrade: **[docs/OS-UPGRADE.md](docs/OS-UPGRADE.md)**. | Pfad | Zweck | |------|-------| | `/opt/pdf-ocr-hotfolder/` | Code + venv (für alle Instanzen gemeinsam) | +| `/opt/pdf-ocr-hotfolder/lib/common.sh` | gemeinsame Shell-Funktionen für `install.sh`/`update.sh` (mitkopiert) | | `/etc/pdf-ocr-hotfolder/.toml` | Config pro Instanz (640, root:\) | | `/etc/systemd/system/pdf-ocr-hotfolder@.service` | systemd Template-Unit | | `/var/lib/pdf-ocr-hotfolder//incoming` | Eingang (Scanner schreibt hier rein) | @@ -83,7 +90,7 @@ Key-für-Key-Referenz: [docs/INSTALLATION.md](docs/INSTALLATION.md#konfiguration | Sektion | Zweck | |---------|-------| -| `[paths]` | `incoming`, `outgoing`, `working`, `error` — **Pflicht** | +| `[paths]` | `incoming`, `outgoing`, `working`, `error` — **Pflicht**, alle **absolut** | | `[ocr]` | Sprachen, `jobs`, `skip_text`, `pdfa_level`, `deskew`, `max_workers`, `timeout` (Sekunden **pro Seite**) | | `[output]` | Dateibenennung (`name_mode`/`name_tag`) und Original-Behandlung (`delete`/`archive`) | | `[verapdf]` | optionale PDF/A-Validierung per CLI | @@ -104,6 +111,17 @@ cd /opt/pdf-ocr-hotfolder && sudo ./venv/bin/python -m pdf_ocr_hotfolder \ Exit 0 = sauber, 1 = Warnungen, 2 = Fehler. Details: [docs/UPDATE.md](docs/UPDATE.md#config-prüfung-per---check-config). +### Exit-Codes des Dienstes + +| Exit | Bedeutung | Neustart durch systemd | +|------|-----------|------------------------| +| `0` | regulärer Stopp | — | +| `1` | nur bei `--once`: mindestens eine PDF fehlgeschlagen | — | +| `2` | Config- oder Preflight-Fehler | **nein** (`RestartPreventExitStatus=2`) — die Instanz bleibt sichtbar `failed` | +| `3` | Verzeichnis-Watch gestorben, es würden keine Dateien mehr erkannt | **ja**, genau dafür | + +Vollständig: [docs/INSTALLATION.md](docs/INSTALLATION.md#exit-codes). + ## Service-Verwaltung ```bash @@ -150,7 +168,7 @@ gelöscht. ## Tests ```bash -pytest # 152 Tests +pytest # 254 Tests ``` `ocrmypdf` muss dafür nicht installiert sein — der Import ist lazy und wird in @@ -162,5 +180,5 @@ MIT — © Sonith UG --- -**Version:** 0.6.3 +**Version:** 0.7.0 **Repo:** https://gitea.sonith.de/sonith_ug/pdf-ocr-hotfolder diff --git a/VERSION b/VERSION index 844f6a9..faef31a 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.6.3 +0.7.0 diff --git a/config.example.toml b/config.example.toml index 28ab2a1..3db60f2 100644 --- a/config.example.toml +++ b/config.example.toml @@ -2,6 +2,11 @@ # Vorlage — install.sh erzeugt daraus pro Instanz /etc/pdf-ocr-hotfolder/.toml [paths] +# ACHTUNG: Alle Pfade MUESSEN absolut sein. Ein relativer Pfad wuerde gegen +# das Arbeitsverzeichnis des Dienstes aufgeloest (WorkingDirectory der +# systemd-Unit), nicht gegen das Verzeichnis dieser Datei — der Scanner +# schriebe dann woanders hin als der Dienst schaut. Der Dienst startet +# deshalb mit einem relativen Pfad gar nicht erst. # Eingangsverzeichnis: hier landen gescannte PDFs incoming = "/var/lib/pdf-ocr-hotfolder/incoming" # Ausgangsverzeichnis: fertige durchsuchbare PDFs @@ -60,11 +65,19 @@ name_tag = "OCR_" # "delete" : Original wird gelöscht (alter Standard) # "archive" : Original wird in archive_dir verschoben original_on_success = "delete" -# Absoluter Pfad; nur relevant wenn original_on_success = "archive" +# Absoluter Pfad (Pflicht, relativ wird abgelehnt); nur relevant wenn +# original_on_success = "archive" archive_dir = "" [verapdf] # PDF/A-Validierung (optional) +# ACHTUNG: Mit enabled = true muss "binary" auf ein vorhandenes, ausfuehrbares +# Programm zeigen. Der Preflight prueft das und laesst den Dienst sonst gar +# nicht erst starten (--check-config meldet Exit 2). Grund: ein nicht +# aufrufbares veraPDF wuerde jede einzelne PDF als ungueltig werten, das +# OCR-Ergebnis nach error/ schieben und das Original laut +# original_on_success entsorgen — bei "delete" also Scan fuer Scan die +# Vorlage vernichten, waehrend der Dienst als "laeuft" dasteht. enabled = false binary = "/opt/verapdf/verapdf" flavour = "1b" @@ -74,7 +87,9 @@ flavour = "1b" [upload.folder] enabled = true -# Wenn leer, wird [paths].outgoing verwendet +# Wenn leer, wird [paths].outgoing verwendet. Sonst: absoluter Pfad (Pflicht, +# relativ wird abgelehnt). Liegt im Ziel schon eine gleichnamige Datei, wird +# die neue mit Zeitstempel abgelegt statt die alte zu ueberschreiben. target = "" [upload.nextcloud] diff --git a/docs/INSTALLATION.md b/docs/INSTALLATION.md index e8d5714..204f54c 100644 --- a/docs/INSTALLATION.md +++ b/docs/INSTALLATION.md @@ -13,14 +13,17 @@ Verwandte Dokumente: [README](../README.md) · [Update](UPDATE.md) · [Debian-Ma | Betriebssystem | Debian 12 (bookworm) oder Debian 13 — systemd wird vorausgesetzt | | Python | 3.11+ (wegen `tomllib` aus der stdlib); kommt aus der Distribution | | Arbeitsspeicher | **mindestens 2 GB** für den produktiven Betrieb — siehe [Systemanforderungen](#systemanforderungen) | +| Dateisystem | **ext4, xfs oder zfs**; `incoming/` **lokal**, kein CIFS/NFS — siehe [Dateisystem](#dateisystem-ext4-xfs-oder-zfs) | | Rechte | `root` (`sudo ./install.sh`) | | Netz | apt-Zugriff für die System-Pakete, PyPI-Zugriff für die venv | | Repo | muss dauerhaft liegen bleiben — `update.sh` kopiert daraus (s. [UPDATE.md](UPDATE.md)) | Die System-Pakete installiert der Installer selbst. Die Liste steht als -einzige Quelle in `install.sh` (Funktion `pdf_ocr_apt_packages()`, zwischen den -Marken `# --- BEGIN apt-packages` / `# --- END apt-packages`) und wird von -`update.sh` von dort ausgelesen: +einzige Quelle in `lib/common.sh` (Funktion `pdf_ocr_apt_packages()`, zwischen +den Marken `# --- BEGIN apt-packages` / `# --- END apt-packages`); `install.sh` +sourct die Datei, und `update.sh` schneidet den Block zusätzlich noch einmal +aus der **Repo**-Fassung heraus, damit beim Update die neue Liste gilt und +nicht die eventuell ältere Kopie unter `/opt/pdf-ocr-hotfolder/lib/`: ``` python3 python3-venv python3-pip @@ -30,7 +33,7 @@ ca-certificates curl ``` Weitere Tesseract-Sprachpakete installiert der Installer bei Bedarf pro Instanz -nach (siehe [OCR-Sprachen](#4-ocr-sprachen)). +nach (siehe [OCR-Sprachen](#ocr-sprachen)). Die Python-Abhängigkeiten stehen **fest gepinnt** in `requirements.txt` (ocrmypdf, watchdog, requests, paramiko). Warum das so ist und wie man die Pins @@ -47,6 +50,50 @@ Seitengröße, nicht an der Dateigröße der PDF. **Empfehlung: mindestens 2 GB RAM.** Für Mehr-Instanz-Betrieb oder `max_workers > 2` entsprechend mehr. +### Dateisystem: ext4, xfs oder zfs + +Der Dienst wird **ausschließlich auf ext4, xfs oder zfs** betrieben. Andere +Dateisysteme sind nicht vorgesehen und werden nicht getestet. + +**`incoming/` gehört auf ein lokales, inotify-fähiges Dateisystem — kein +CIFS/NFS-Mount.** Der Hotfolder hängt vollständig an inotify (`watchdog` +meldet `created`, `moved`, `closed`). inotify ist ein Mechanismus des *lokalen* +Kernels: er sieht nur Änderungen, die dieser Kernel selbst ausführt. Schreibt +ein anderer Rechner über SMB oder NFS in ein gemountetes Verzeichnis, geht das +am lokalen VFS vorbei und **es entsteht gar kein Event** — nicht verzögert, +nicht unzuverlässig, sondern grundsätzlich keines. + +Die Folge ist heimtückisch, weil nichts kaputt aussieht: Der Dienst startet, +meldet `active (running)`, arbeitet den Bestand beim Start-Scan sauber ab — und +bemerkt danach **keine einzige neue Datei mehr**. Es gibt keinen Fehler, keine +Meldung, keine Mail. Erst wenn jemand die Instanz neu startet, wird der +inzwischen angesammelte Stapel auf einmal verarbeitet. + +Richtiger Aufbau: der Scanner schreibt über SMB/NFS auf **den Rechner, auf dem +der Dienst läuft**, und `incoming/` liegt dort auf der lokalen Platte. Der +Netz-Export zeigt auf dieses lokale Verzeichnis, nicht umgekehrt. Für die +**Ausgabe** gilt die Einschränkung nicht — `outgoing/` und +`[upload.folder].target` dürfen auf einem Netz-Share liegen, dorthin wird nur +geschrieben. + +### Was 2 GB tatsächlich tragen + +Gemessen auf einem LXC-Container mit **2 GB RAM**, Debian 13 — eine +**A4-Seite in 300 dpi** mit `deskew = true`, `oversample = 300`, `jobs = 4`: + +| Messwert | Ergebnis | +|----------|----------| +| Laufzeit | **19 s** | +| `MemoryPeak` des Dienstes (`systemctl show -p MemoryPeak`) | **380 MB** | +| `memory.peak` des ganzen Containers | **503 MB** | +| `oom_kill` in `/sys/fs/cgroup/memory.events` | **0** | + +**Dieselbe Seite auf derselben Maschine mit 512 MB war genau der OOM-Kill +unten.** Der Spitzenbedarf liegt also bei rund einem halben Gigabyte für +*eine* Seite bei *einem* Worker — 2 GB lassen damit Luft für den +Default `max_workers = 2`, für das Betriebssystem und für einen zweiten +Hotfolder, sind aber keine üppige Reserve. + ### Warum 512 MB nachweislich nicht reichen Gemessen auf einem LXC-Container mit **512 MB RAM + 512 MB Swap**, Debian 13, @@ -141,7 +188,7 @@ Der Installer unterscheidet zwei Ebenen: | Ebene | Wann | Was passiert | |-------|------|--------------| -| **Basis-Install** | einmalig; erkannt an `venv` + Template-Unit | System-Pakete, Ghostscript-Check, Container-Erkennung, Default-User `pdfocr`, Code nach `/opt/pdf-ocr-hotfolder/`, venv, systemd-Template-Unit | +| **Basis-Install** | einmalig; erkannt an `venv` + Template-Unit | System-Pakete, Ghostscript-Check, Container-Erkennung (inkl. [journald-Prüfung](#debian-13-in-lxc-auf-proxmox-journald-scheitert-243credentials)), Default-User `pdfocr`, Code **und `lib/`** nach `/opt/pdf-ocr-hotfolder/`, venv, systemd-Template-Unit | | **Instanz-Anlage** | bei jedem Lauf, beliebig oft | Abfragen pro Instanz, Datenverzeichnisse, `.toml`, optionales User-Drop-in, `enable --now` | Ist die Basis-Installation vorhanden, aber die venv passt nicht mehr zum @@ -157,10 +204,12 @@ Installer in der Schleife `Weitere Instanz anlegen? [j/N]:`. ## Die Abfragen pro Instanz -`create_instance()` stellt fünf Fragen. Alle Antworten gelten **nur für diese -Instanz** — nichts davon ist global. +`create_instance()` stellt fünf Fragen, in der Reihenfolge der folgenden +Abschnitte: Instanz-Name, Basis-Pfad, Service-User, OCR-Sprachen, +Original-Behandlung. Alle Antworten gelten **nur für diese Instanz** — nichts +davon ist global. -### 1. Instanz-Name +### Instanz-Name ``` Instanz-Name (nur a-z, 0-9, -): @@ -171,7 +220,7 @@ Muster `^[a-z0-9][a-z0-9-]*$`. Der Name wird zum Unit-Suffix (`/etc/pdf-ocr-hotfolder/.toml`). Existiert die Config schon, bricht die Anlage ab — ein versehentliches Überschreiben gibt es nicht. -### 2. Basis-Pfad für die Daten +### Basis-Pfad für die Daten ``` Basis-Pfad für Daten [/var/lib/pdf-ocr-hotfolder/]: @@ -180,7 +229,7 @@ Basis-Pfad für Daten [/var/lib/pdf-ocr-hotfolder/]: Darunter entstehen `incoming/`, `outgoing/`, `working/`, `error/` und werden auf den Service-User gechownt. -### 3. Service-User +### Service-User ``` Service-User [pdfocr]: @@ -198,7 +247,7 @@ Service-User [pdfocr]: Bei AD-Usern mit lokaler UID werden die Datei-Berechtigungen über die UID gesetzt — das läuft transparent. -### 4. OCR-Sprachen +### OCR-Sprachen ``` Tesseract-Sprachen [deu+eng]: @@ -236,7 +285,7 @@ Was der Installer damit macht: 4. Ist `tesseract` gar nicht aufrufbar, wird die Prüfung übersprungen und die Eingabe unverändert übernommen. -### 5. Original archivieren? +### Original archivieren? ``` Original nach erfolgreichem OCR archivieren? [j/N]: @@ -436,6 +485,20 @@ Fehlt die Sektion oder einer der vier Einträge, gibt es eine deutsche `ConfigError`-Meldung mit Datei- und Key-Nennung und **Exit 2** — der Dienst startet nicht. +**Alle vier Pfade müssen absolut sein.** Ein relativer Pfad wird gegen das +Arbeitsverzeichnis des *Prozesses* aufgelöst, bei der Unit also gegen +`WorkingDirectory=/opt/pdf-ocr-hotfolder` — **nicht** gegen das Verzeichnis, in +dem die Config liegt. `incoming = "in"` legte damit still +`/opt/pdf-ocr-hotfolder/in` an: der Scanner schreibt woanders hin als der +Dienst schaut, und niemand sieht einen Fehler. Seit 0.7.0 ist das ein +Config-Fehler mit **Exit 2**. Dieselbe Regel gilt für +[`[output].archive_dir`](#output) und +[`[upload.folder].target`](#uploadfolder--uploadnextcloud--uploadsftp); +`install.sh` erzeugt ohnehin nur absolute Pfade. + +`incoming` muss außerdem auf einem **lokalen** Dateisystem liegen — siehe +[Dateisystem](#dateisystem-ext4-xfs-oder-zfs). + ### `[ocr]` | Key | Default | Bedeutung | @@ -467,17 +530,34 @@ ocrmypdf-Default greift. Siehe auch | `name_mode` | `"prefix"` | `prefix` → `OCR_scan.pdf`, `suffix` → `scan_OCR.pdf` (vor der Extension), `none` → unverändert | | `name_tag` | `"OCR_"` | verbatim eingefügter String; leer wirkt wie `none` | | `original_on_success` | `"delete"` | `delete` oder `archive` — Installer fragt das ab | -| `archive_dir` | `""` | absoluter Pfad, **Pflicht** bei `archive`; Namenskollision → Zeitstempel-Suffix | +| `archive_dir` | `""` | absoluter Pfad (relativ wird abgelehnt), **Pflicht** bei `archive`; Namenskollision → Zeitstempel-Suffix | Ein Tippfehler in `name_mode` oder `original_on_success` führt beim Start zum Abbruch mit **Exit 2**, nicht erst bei der ersten Datei. +**Kollisionen überschreiben nichts.** Liegt im Ziel bereits eine Datei +desselben Namens, wird die neue mit Zeitstempel danebengelegt +(`scan.pdf` → `scan_20260923-081500.pdf`; bei zwei Dateien innerhalb derselben +Sekunde zusätzlich mit Zähler), und es gibt eine Warnung im Journal. Das gilt +seit 0.7.0 einheitlich für **`outgoing/`, das Archiv, `error/` und den +Ordner-Upload** — vorher ersetzte der zweite Durchlauf das Ergebnis des ersten +kommentarlos. Die E-Mail-Benachrichtigung und die Upload-Ziele nennen den +tatsächlich geschriebenen Namen. + +**Scheitert das Entsorgen des Originals** (Platte voll, Verzeichnis +read-only), gilt der Durchlauf trotzdem als Erfolg: das fertige PDF liegt +bereits in `outgoing/` und wird normal ausgeliefert. Es gibt aber eine +`ERROR`-Zeile im Journal, und die Mail geht als **„OK mit Warnung"** raus — +auch bei `[notify.email].on = "errors"`. Das Original bleibt dann in +`working/` liegen und wird beim nächsten Start **erneut** durch das OCR +geschickt; es gehört von Hand aufgeräumt und die Ursache behoben. + ### `[verapdf]` | Key | Default | Bedeutung | |-----|---------|-----------| | `enabled` | `false` | PDF/A-Validierung per veraPDF-CLI | -| `binary` | `/opt/verapdf/verapdf` | Pfad zum veraPDF-Binary | +| `binary` | `/opt/verapdf/verapdf` | Pfad zum veraPDF-Binary, oder ein nackter Name, der im `PATH` gesucht wird | | `flavour` | `"1b"` | PDF/A-Flavour | veraPDF startet eine JVM und ist entsprechend teuer — nur einschalten, wenn die @@ -485,6 +565,27 @@ Validierung wirklich gebraucht wird. Bei FAIL wandert das OCR-Ergebnis nach `error/`; das Original folgt `original_on_success` (bei `archive` bleibt es also erhalten). +**Mit `enabled = true` wird `binary` im Preflight geprüft** (seit 0.7.0). Zeigt +der Pfad nicht auf ein vorhandenes, ausführbares Programm, startet der Dienst +gar nicht erst (**Exit 2**), und `--check-config` meldet denselben Fehler. +`--check-config` zeigt Binary und Flavour außerdem in der Übersicht an. + +> ⚠️ **Warum das eine harte Sperre ist.** Bis 0.6.3 war ein Tippfehler in +> `binary` der gefährlichste Fehler des ganzen Dienstes: `run_verapdf()` fand +> das Programm für **jede** Datei nicht, wertete das als „nicht konform", +> schob das OCR-Ergebnis nach `error/` — und entsorgte das Original laut +> `original_on_success`, beim Default `delete` also die Vorlage. Scan für Scan +> verschwanden so die Originale, während die Unit als `active (running)` +> dastand. + +**Störung ist kein FAIL.** Lässt sich veraPDF im laufenden Betrieb nicht mehr +befragen — Programm verschwunden, JVM startet nicht, Timeout (300 s pro Datei), +oder die Ausgabe enthält weder `PASS` noch `FAIL` —, ist das **kein Urteil über +die PDF**. In diesem Fall wandern **Original und OCR-Ergebnis** nach `error/`, +und das Original wird **weder gelöscht noch archiviert**, unabhängig von +`original_on_success`. Im Journal steht die Ursache samt Hinweis auf +`--check-config`. + ### `[upload.folder]` / `[upload.nextcloud]` / `[upload.sftp]` Beliebig viele Ziele gleichzeitig aktivierbar. Sind alle aus, bleibt das fertige @@ -492,7 +593,7 @@ PDF einfach in `outgoing/` liegen. | Sektion | Keys | |---------|------| -| `[upload.folder]` | `enabled`, `target` — leer heißt `[paths].outgoing`, dann No-op | +| `[upload.folder]` | `enabled`, `target` — leer heißt `[paths].outgoing`, dann No-op; sonst **absoluter** Pfad (relativ wird abgelehnt, Exit 2) | | `[upload.nextcloud]` | `enabled`, `url`, `username`, `password`, `remote_path`, `verify_ssl` | | `[upload.sftp]` | `enabled`, `host`, `port`, `username`, `key_file`, `password`, `remote_path` | @@ -500,6 +601,10 @@ Schlägt mindestens ein Ziel fehl, zählt das als Fehler und die Mail geht als FEHLER raus — das PDF bleibt aber **bewusst in `outgoing/`** liegen, das OCR selbst war ja erfolgreich. +Liegt im `target` von `[upload.folder]` schon eine gleichnamige Datei, wird sie +seit 0.7.0 **nicht mehr ersetzt**, sondern die Kopie mit Zeitstempel +danebengelegt (samt Warnung) — dieselbe Regel wie in [`[output]`](#output). + ### `[notify.email]` | Key | Default | Bedeutung | @@ -521,6 +626,36 @@ ins journal. --- +## Exit-Codes + +Der Dienst und die CLI benutzen vier Codes. `systemctl status` und +`journalctl` zeigen sie als `status=`. + +| Exit | Bedeutung | Startet systemd neu? | Was zu tun ist | +|------|-----------|----------------------|----------------| +| **0** | regulärer Stopp (SIGTERM/SIGINT); bei `--once`: alles verarbeitet, auch „nichts da"; bei `--check-config`: Config sauber | — | nichts | +| **1** | nur im Einmal-Betrieb: mindestens eine PDF ist fehlgeschlagen. Bei `--check-config`: Config nutzbar, aber mit **Warnungen** | — | `error/` ansehen bzw. Warnungen nachziehen | +| **2** | **Config- oder Preflight-Fehler** — kaputtes/unlesbares TOML, fehlender Pflicht-Key, relativer Pfad, ungültige `[output]`-Werte, fehlendes `tesseract`/`gs`, betroffene Ghostscript-Version, nicht aufrufbares veraPDF | **nein** — `RestartPreventExitStatus=2` in der Unit | Config korrigieren, mit `--check-config` gegenprüfen, dann `systemctl start` | +| **3** | **Der Verzeichnis-Watch ist gestorben** — es würden keine neuen Dateien mehr erkannt | **ja**, und genau darum geht es | meist nichts; häuft es sich, `fs.inotify.max_user_watches` und den Mount von `incoming/` prüfen | + +**Zu Exit 2:** Ein Neustart heilt einen Config-Fehler nicht. Ohne +`RestartPreventExitStatus=2` startete `Restart=on-failure` die Instanz endlos +im 5-Sekunden-Takt neu (das Start-Rate-Limit greift bei `RestartSec=5` nie). +Seit 0.7.0 bleibt sie stattdessen sichtbar `failed` stehen — das ist gewollt +und soll beim Nachsehen auffallen. + +**Zu Exit 3:** Stirbt der watchdog-Observer im Betrieb (erschöpftes +`fs.inotify.max_user_watches`, ersetztes oder neu gemountetes Verzeichnis), +blieb die Unit früher `active (running)` und verarbeitete stumm nichts mehr — +für einen Hotfolder der schlechteste denkbare Zustand. Der Dienst prüft den +Observer jetzt sekündlich mit, loggt eine `ERROR`-Zeile mit den möglichen +Ursachen und beendet sich mit 3, damit systemd ihn neu startet und der Watch +neu aufgesetzt wird. Ein einzelnes Vorkommnis ist damit selbstheilend; ein +steigendes `systemctl show -p NRestarts` ist der Hinweis, dass man nachsehen +sollte. + +--- + ## Troubleshooting ### Tesseract findet die Sprache nicht @@ -547,21 +682,65 @@ das Archiv, falls konfiguriert): sudo chown -R DOMAIN\\scanuser:DOMAIN\\scangroup /var/lib/pdf-ocr-hotfolder/ ``` -### veraPDF-Validierung schlägt immer fehl +### veraPDF: Dienst startet nicht / Dateien landen in `error/` -`[verapdf].binary` prüfen. Wenn die Validierung nicht zwingend gebraucht wird: -`enabled = false`. +Seit 0.7.0 prüft der Preflight `[verapdf].binary`. Startet die Instanz mit +**Exit 2** nicht mehr, nachdem vorher „alles lief", ist das die gute Nachricht: +der Pfad war schon vorher falsch, nur hat es bisher niemand gemerkt. Vorher +wurde jede PDF als ungültig gewertet und das Original laut +`original_on_success` entsorgt. + +```bash +ls -l /opt/verapdf/verapdf # vorhanden? ausführbar (chmod +x)? +``` + +Korrigieren — oder, wenn die Validierung nicht zwingend gebraucht wird, +`[verapdf].enabled = false` setzen. Landen **Original und OCR-Ergebnis +gemeinsam** in `error/`, war veraPDF im laufenden Betrieb nicht mehr +ansprechbar; das Original ist dann unangetastet, siehe +[`[verapdf]`](#verapdf). ### Dienst startet nicht (Exit 2) -Exit 2 heißt immer: Config oder Preflight. Die Ursache steht im journal und -ausführlicher in: +Exit 2 heißt immer: Config oder Preflight — siehe [Exit-Codes](#exit-codes). +Die Instanz bleibt bewusst `failed` stehen und wird **nicht** neu gestartet. +Die Ursache steht im journal und ausführlicher in: ```bash cd /opt/pdf-ocr-hotfolder && sudo ./venv/bin/python -m pdf_ocr_hotfolder \ --check-config --config /etc/pdf-ocr-hotfolder/.toml ``` +Typische Fälle: kaputtes TOML (die Meldung nennt Zeile und Spalte, sofern der +Interpreter sie liefert), ein relativer Pfad in `[paths]`, +`[output].archive_dir` oder `[upload.folder].target`, ein leeres `archive_dir` +bei `original_on_success = "archive"`, ein nicht aufrufbares veraPDF oder eine +betroffene Ghostscript-Version. + +### Dienst läuft, verarbeitet aber nichts mehr + +`systemctl status` sagt `active (running)`, in `incoming/` stapeln sich die +PDFs, im Journal passiert nichts. Drei Ursachen, in dieser Reihenfolge prüfen: + +1. **`incoming/` liegt auf einem CIFS/NFS-Mount.** Dann liefert inotify + grundsätzlich keine Events — der Dienst verarbeitet nur noch beim Start. + `findmnt -T /var/lib/pdf-ocr-hotfolder//incoming` zeigt den Typ; + Hintergrund und richtiger Aufbau unter + [Dateisystem](#dateisystem-ext4-xfs-oder-zfs). +2. **Der Verzeichnis-Watch ist gestorben.** Seit 0.7.0 fällt das auf: der + Dienst beendet sich mit [Exit 3](#exit-codes) und systemd startet ihn neu. + Im Journal steht die `ERROR`-Zeile, `systemctl show -p NRestarts` steigt. + Häuft sich das, ist meist das inotify-Limit erschöpft: + ```bash + cat /proc/sys/fs/inotify/max_user_watches + ``` +3. **Es sind gar keine PDFs.** Dateien ohne `.pdf`-Endung werden ignoriert. + Beim Start-Scan meldet der Dienst sie seit 0.7.0 als Sammelzeile mit Anzahl + und bis zu drei Beispielnamen: + ```bash + journalctl -u pdf-ocr-hotfolder@ | grep 'ohne .pdf-Endung' + ``` + ### Dienst startet nicht (203/EXEC) Der Interpreter der venv ist weg — fast immer nach einem Distributions-Upgrade. @@ -589,9 +768,13 @@ journald ist damit ein blinder Fleck: jede Fehlersuche läuft ins Leere, und der Ausfall sieht aus wie ein stummer Dienst. **Deshalb vor jeder Fehlersuche zuerst journald prüfen**, nicht erst, wenn nichts anderes mehr passt. -Das ist **kein** generelles LXC-Muster. Von zwei Testcontainern war genau einer -betroffen; auf dem zweiten lief journald einwandfrei. Es handelt sich um einen -Schaden auf dieser einen Maschine, nicht um eine Eigenschaft von Containern. +Die häufigste Ursache ist **kein** Schaden an dieser einen Maschine, sondern +systematisch: **Debian 13 in einer LXC auf Proxmox** — siehe den nächsten +Abschnitt. Auf Debian 12 tritt sie nicht auf. + +`install.sh` warnt beim Erstinstall in einem Container von sich aus, wenn +`systemd-journald` nicht läuft, nennt den Drop-in-Befehl und fragt, ob +fortgefahren werden soll. **Notbehelf, solange journald nicht zu retten ist:** die Instanz einmal im Vordergrund laufen lassen — dann geht die Ausgabe direkt ins Terminal, am @@ -610,6 +793,67 @@ Das `cd` ist zwingend: das Paket wird nicht pip-installiert, sondern nach Bestand abarbeiten und dann aussteigen will, hängt `--once` an (siehe [Manueller Lauf](#manueller-lauf-one-shot)). +### Debian 13 in LXC auf Proxmox: journald scheitert (243/CREDENTIALS) + +Verifizierter Befund. Er betrifft **jede** Debian-13-LXC auf Proxmox 8.4, nicht +nur eine einzelne Maschine — und er trifft nicht nur dieses Tool, sondern +alles, was auf dem Container-journal aufsetzt. + +**Symptom im Container:** + +```bash +systemctl status systemd-journald +# ● systemd-journald.service - Journal Service +# Active: failed +# Process: ... (code=exited, status=243/CREDENTIALS) +# Main PID exited, status=243/CREDENTIALS + +journalctl -u pdf-ocr-hotfolder@ +# No journal files were found. +``` + +**Ursache.** Ab systemd 255 (Debian 13 liefert **257**) setzt die +journald-Unit `ImportCredential=journal.*`. Zum Einlesen dieser Credentials +startet systemd den Hilfsprozess `(sd-mkdcreds)`, und der **mountet** dafür. +Genau diesen Mount verbietet das AppArmor-Profil des Proxmox-Hosts. Im Log des +**Hosts** steht dazu: + +``` +apparmor="DENIED" operation="mount" profile="lxc-_" name="/dev/" comm="(sd-mkdcreds)" +``` + +Debian 12 hat systemd 252, kennt `ImportCredential` in dieser Unit nicht und +ist deshalb **nicht** betroffen. Das Upgrade 12 → 13 ist damit der Auslöser, +nicht der Container an sich. + +**Dieselbe Ursache legt weitere Units lahm** — beobachtet bei +`systemd-logind`, `systemd-networkd`, `console-getty` und +`systemd-tmpfiles-setup`. Wer nur journald repariert, hat die übrigen noch vor +sich; ein Blick auf `systemctl --failed` lohnt sich. + +**Abhilfe im Container** (reboot-fest verifiziert) — `ImportCredential` wird +per Drop-in auf leer gesetzt und damit abgeschaltet: + +```bash +sudo mkdir -p /etc/systemd/system/systemd-journald.service.d +printf '[Service]\nImportCredential=\n' | \ + sudo tee /etc/systemd/system/systemd-journald.service.d/no-credentials.conf +sudo systemctl daemon-reload +sudo systemctl restart systemd-journald +sudo journalctl --flush +``` + +Danach `systemctl status systemd-journald` (muss `active (running)` sein) und +`journalctl -u pdf-ocr-hotfolder@` gegenprüfen. Für die anderen +betroffenen Units gilt dasselbe Muster mit deren Unit-Namen. + +> **Der saubere Weg liegt host-seitig.** Das Drop-in kuriert das Symptom im +> Container. Richtig behoben wird es auf dem Proxmox-Host: Update von +> `pve-container`/`lxc-pve` auf eine Fassung mit passenden AppArmor-Regeln — +> oder, als grobes Mittel, `lxc.apparmor.profile: unconfined` in der +> Container-Config, was die AppArmor-Isolation dieses Containers allerdings +> komplett aufgibt. + ### Dienst bricht mitten in der Verarbeitung weg Datei bleibt in `working/`, kein Traceback, `NRestarts` steigt: das ist fast @@ -629,4 +873,5 @@ cd /opt/pdf-ocr-hotfolder && sudo -u pdfocr ./venv/bin/python -m pdf_ocr_hotfold ``` Exit-Code: `0` = alles verarbeitet (auch "nichts da"), `1` = mindestens eine -Datei fehlgeschlagen, `2` = Config- oder Preflight-Fehler. +Datei fehlgeschlagen, `2` = Config- oder Preflight-Fehler. Exit 3 gibt es hier +nicht — der gehört zum Dauerbetrieb, siehe [Exit-Codes](#exit-codes). diff --git a/docs/OS-UPGRADE.md b/docs/OS-UPGRADE.md index 89a307a..0b5f214 100644 --- a/docs/OS-UPGRADE.md +++ b/docs/OS-UPGRADE.md @@ -226,11 +226,11 @@ auch für die Abhängigkeiten, die ocrmypdf 17 zusätzlich mitbringt (`pydantic` ``` 3. Dort `update.sh --rebuild-venv` fahren, damit die Pakete wirklich frisch aufgelöst werden. -4. `pytest` muss grün bleiben (152 Tests). +4. `pytest` muss grün bleiben (254 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. +6. Erst dann committen und auf die produktiven Systeme geben. Der ocrmypdf-Sprung 16 → 17 ist ein **Major-Sprung** und gehört in einen eigenen Vorgang mit eigenem Test, nicht in ein OS-Upgrade. diff --git a/docs/UPDATE.md b/docs/UPDATE.md index 2d68d16..77faf86 100644 --- a/docs/UPDATE.md +++ b/docs/UPDATE.md @@ -28,16 +28,24 @@ sudo ./update.sh --no-smoke-test # ohne Rauchtest durchlaufen es den gespeicherten Pfad aus `/opt/pdf-ocr-hotfolder/.repo_path` — **das Repo muss also liegen bleiben**, das Tool kopiert daraus. +`install.sh` und `update.sh` teilen sich seit 0.7.0 die Datei `lib/common.sh` +(Log-Funktionen, Root-Prüfung, Layout-Pfade, apt-Paketliste, venv-Prüfung). +`update.sh` sourct bevorzugt die Fassung **neben sich** im Repo und fällt auf +die installierte unter `/opt/pdf-ocr-hotfolder/lib/` zurück; fehlt sie +überall, bricht es sofort ab statt mitten im Lauf. Die apt-Paketliste schneidet +es zusätzlich noch einmal per `sed` aus der Repo-Fassung heraus — beim Update +soll die neue Liste gelten, nicht die eventuell ältere installierte Kopie. + ## Was das Skript tut — in dieser Reihenfolge | # | Schritt | Anmerkung | |---|---------|-----------| | 1 | **Instanzen erfassen** | aktiv / kaputt / bewusst gestoppt, siehe [unten](#instanz-erfassung) | -| 2 | **System-Pakete abgleichen** | Liste wird aus `install.sh` extrahiert, `apt-get install` ist idempotent | +| 2 | **System-Pakete abgleichen** | Liste wird aus `lib/common.sh` des Repos extrahiert, `apt-get install` ist idempotent | | 3 | **venv prüfen** | passt sie noch zum System-Python? Läuft **vor** dem Stoppen, damit man es früh sieht | | 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` | +| 6 | **Code kopieren** | `pdf_ocr_hotfolder/`, **`lib/`**, `requirements.txt`, `VERSION`, `config.example.toml`, `.repo_path` | | 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`) | @@ -114,7 +122,7 @@ Vor dem ersten Eingriff auf der Platte schreibt `update.sh` ein Archiv: | Enthalten | Nicht enthalten | |-----------|-----------------| -| `/opt/pdf-ocr-hotfolder/` (Code) | die **venv** (`venv/`, `venv.old-*`) | +| `/opt/pdf-ocr-hotfolder/` (Code **inkl. `lib/`**) | die **venv** (`venv/`, `venv.old-*`) | | `/etc/pdf-ocr-hotfolder/` (alle Instanz-Configs) | die **Datenverzeichnisse** `/var/lib/pdf-ocr-hotfolder/` | | Template-Unit `pdf-ocr-hotfolder@.service` | `__pycache__`, `*.pyc` | | alle Drop-in-Verzeichnisse `…@*.service.d` | | @@ -303,6 +311,15 @@ validiert die `[output]`-Sektion. | **1** | Config nutzbar, aber mit **Warnungen** | Kein Abbruchgrund, der Dienst läuft. Die Warnungen aber **nachziehen** — sie nennen entweder einen Key, dessen Bedeutung sich geändert hat, oder einen Eintrag, der ins Leere läuft (s. [Config-Drift](#config-drift-nach-einem-update)) | | **2** | Config **unbrauchbar** — der Dienst würde nicht starten | Sofort korrigieren. Die Instanz gilt als **nicht** erfolgreich aktualisiert, das Update endet mit Exit 1 | +`--check-config` selbst kennt nur 0/1/2. Der **Dienst** kennt seit 0.7.0 +zusätzlich **Exit 3** (Verzeichnis-Watch gestorben, Neustart erwünscht) — und +die Unit startet bei **Exit 2** absichtlich **nicht** mehr neu +(`RestartPreventExitStatus=2`), die Instanz bleibt sichtbar `failed` stehen. +Für das Update heißt das: eine Instanz mit Config-Fehler verschwindet nicht +mehr in einem stillen 5-Sekunden-Crash-Loop, sondern fällt in der +Zusammenfassung auf. Die vollständige Tabelle: +[INSTALLATION.md](INSTALLATION.md#exit-codes). + Kennt der installierte Code `--check-config` noch nicht (Update von einem Stand vor 0.6.0), erkennt `update.sh` das an der argparse-Meldung, überspringt die Prüfung mit einer Warnung und läuft weiter. @@ -361,6 +378,20 @@ dem Fall mit Exit 2 ab; ab Ghostscript 10.02.1 ist PDF/A unproblematisch. > `requirements.txt` pinnt darum 17.x, und der Preflight prüft beides zusammen. > Hintergrund: [INSTALLATION.md](INSTALLATION.md#ghostscript-bug-auf-debian-12). +### Relative Pfade — Fehler seit 0.7.0 + +Bis 0.6.3 wurde ein relativer Pfad in `[paths]`, `[output].archive_dir` oder +`[upload.folder].target` klaglos angenommen und gegen das `WorkingDirectory` +der Unit aufgelöst, also unter `/opt/pdf-ocr-hotfolder/`. Seit 0.7.0 ist das +ein **Config-Fehler mit Exit 2**: `--check-config` meldet ihn beim Update, und +der Dienst startet nicht. + +Das ist der einzige Fall, in dem ein Update von 0.6.x eine bisher „laufende" +Instanz stoppen kann. Er ist gewollt — eine solche Instanz schrieb an einer +Stelle, an der niemand sie gesucht hat. Abhilfe: den Pfad absolut eintragen +und, falls dort Dateien liegen, den Inhalt von `/opt/pdf-ocr-hotfolder/` +vorher herüberholen. + ### Unbekannte Keys Einträge, die zu keiner Sektion und keinem Key gehören, werden beim Laden diff --git a/install.sh b/install.sh index 6fd9382..00b4c24 100755 --- a/install.sh +++ b/install.sh @@ -12,73 +12,32 @@ set -euo pipefail -RED='\033[0;31m'; GREEN='\033[0;32m'; YELLOW='\033[1;33m'; BLUE='\033[0;34m'; NC='\033[0m' -log_info() { echo -e "${GREEN}[INFO]${NC} $*"; } -log_warn() { echo -e "${YELLOW}[WARN]${NC} $*"; } -log_error() { echo -e "${RED}[ERROR]${NC} $*"; } -log_step() { echo -e "\n${BLUE}==>${NC} $*"; } - -if [ "${EUID}" -ne 0 ]; then - log_error "Bitte als root ausführen: sudo ./install.sh" - exit 1 -fi - -INSTALL_DIR="/opt/pdf-ocr-hotfolder" -CONFIG_DIR="/etc/pdf-ocr-hotfolder" -DATA_ROOT="/var/lib/pdf-ocr-hotfolder" -SERVICE_TEMPLATE="pdf-ocr-hotfolder@.service" -DEFAULT_USER="pdfocr" - SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" REPO_DIR="$SCRIPT_DIR" +# ============================================================ +# Gemeinsame Funktionen (Logging, Pfade, venv-Pruefung, Paketliste) +# ============================================================ +# Relativ zum Skript aufgeloest, nicht zum Arbeitsverzeichnis — install.sh +# wird auch mit absolutem Pfad aufgerufen. Fehlt die Datei, ist hier Schluss; +# ein "command not found" mitten im Lauf waere die schlechtere Nachricht. +COMMON_LIB="$SCRIPT_DIR/lib/common.sh" +if [ ! -r "$COMMON_LIB" ]; then + echo "[ERROR] Gemeinsame Funktionsbibliothek nicht gefunden: $COMMON_LIB" >&2 + echo " install.sh braucht lib/common.sh aus demselben Repo." >&2 + echo " Repo vollstaendig auschecken und erneut ausfuehren." >&2 + exit 1 +fi +# shellcheck source=lib/common.sh +. "$COMMON_LIB" + +require_root "sudo ./install.sh" + if [ ! -f "$REPO_DIR/pdf_ocr_hotfolder/__init__.py" ]; then log_error "Repo-Layout nicht erkannt. install.sh aus dem Repo ausführen." exit 1 fi -# ============================================================ -# System-Pakete — einzige Quelle der Wahrheit -# ============================================================ -# Der folgende Block wird von update.sh aus dieser Datei herausgeschnitten -# (sed auf die BEGIN/END-Marken) und dort ausgewertet, damit ein Update -# neue Pakete nachzieht. Die Marken und der Funktionsname duerfen sich -# deshalb nicht aendern, ohne update.sh anzupassen. -# --- BEGIN apt-packages (wird von update.sh extrahiert) --- -pdf_ocr_apt_packages() { - cat <<'PKGLIST' -python3 -python3-venv -python3-pip -tesseract-ocr -tesseract-ocr-deu -tesseract-ocr-eng -ghostscript -qpdf -unpaper -pngquant -icc-profiles-free -ca-certificates -curl -PKGLIST -} -# --- END apt-packages --- - -# Prueft, ob die venv noch zum aktuellen System-Python passt. -# Zwei Faelle: (1) der Interpreter der venv laeuft gar nicht mehr (toter -# Symlink nach einem Distributions-Upgrade, systemd: 203/EXEC), (2) er laeuft -# noch, ist aber eine andere Version als das System-Python (Debian 12 -> 13). -# Beides heisst: neu bauen. Keine Versionsnummer ist hier hartcodiert. -venv_is_healthy() { - local venv="$1" venv_mm sys_mm - [ -x "$venv/bin/python" ] || return 1 - venv_mm="$("$venv/bin/python" -c 'import sys; print("%d.%d" % sys.version_info[:2])' 2>/dev/null || true)" - [ -n "$venv_mm" ] || return 1 - sys_mm="$(python3 -c 'import sys; print("%d.%d" % sys.version_info[:2])' 2>/dev/null || true)" - [ -n "$sys_mm" ] || return 1 - [ "$venv_mm" = "$sys_mm" ] -} - # ============================================================ # Basis-Installation (idempotent) # ============================================================ @@ -137,12 +96,36 @@ install_base() { read -r -p "LXC-Kompatibilitäts-Drop-in installieren? [J/n]: " LXC_FIX LXC_FIX="${LXC_FIX:-J}" if [[ "$LXC_FIX" =~ ^[JjYy]$ ]]; then - local LXC_DROPIN_DIR="/etc/systemd/system/pdf-ocr-hotfolder@.service.d" mkdir -p "$LXC_DROPIN_DIR" - cp "$REPO_DIR/systemd/lxc-compat.conf" "$LXC_DROPIN_DIR/lxc-compat.conf" + cp "$REPO_DIR/systemd/lxc-compat.conf" "$LXC_DROPIN" systemctl daemon-reload log_info "LXC-Kompatibilitäts-Drop-in installiert ✓" fi + + # Der Dienst loggt ausschliesslich nach journald. Ist journald kaputt, + # gibt es gar keine Logs — das faellt sonst erst bei der ersten + # Fehlersuche auf. Bekannter Fall: Debian 13 (systemd >= 255) in einer + # LXC auf Proxmox 8.4 — das AppArmor-Profil des Hosts blockiert den + # Credential-Mount von (sd-mkdcreds), journald scheitert mit + # 243/CREDENTIALS. Debian 12 (systemd 252) ist nicht betroffen. + if ! systemctl is-active --quiet systemd-journald 2>/dev/null; then + log_warn "ACHTUNG: systemd-journald laeuft nicht." + log_warn "Der Dienst loggt NUR nach journald — es gaebe hier keine Logs." + log_warn "Pruefen: systemctl status systemd-journald" + log_warn "Scheitert es mit 243/CREDENTIALS (Debian 13 in LXC auf" + log_warn "Proxmox), hilft ein Drop-in im Container:" + log_warn " mkdir -p /etc/systemd/system/systemd-journald.service.d" + log_warn " printf '[Service]\\nImportCredential=\\n' > \\" + log_warn " /etc/systemd/system/systemd-journald.service.d/no-credentials.conf" + log_warn " systemctl daemon-reload && systemctl restart systemd-journald" + log_warn "Details: docs/INSTALLATION.md, Abschnitt Troubleshooting." + read -r -p "Trotzdem fortfahren? [J/n]: " JOURNAL_GO + JOURNAL_GO="${JOURNAL_GO:-J}" + if [[ ! "$JOURNAL_GO" =~ ^[JjYy]$ ]]; then + log_error "Abbruch. Erst journald reparieren, dann erneut starten." + exit 1 + fi + fi fi log_step "Default-User '$DEFAULT_USER' prüfen" @@ -161,6 +144,10 @@ install_base() { log_step "Code kopieren" rm -rf "$INSTALL_DIR/pdf_ocr_hotfolder" cp -r "$REPO_DIR/pdf_ocr_hotfolder" "$INSTALL_DIR/" + # lib/ muss mit: update.sh sucht die gemeinsamen Funktionen zuerst neben + # sich und danach in der Installation. + rm -rf "${INSTALL_DIR:?}/lib" + cp -r "$REPO_DIR/lib" "$INSTALL_DIR/" cp "$REPO_DIR/requirements.txt" "$INSTALL_DIR/" cp "$REPO_DIR/VERSION" "$INSTALL_DIR/" cp "$REPO_DIR/config.example.toml" "$INSTALL_DIR/" @@ -173,6 +160,7 @@ install_base() { local VENV_SAVED VENV_SAVED="$INSTALL_DIR/venv.old-$(date +%Y%m%d-%H%M%S)" log_warn "Vorhandene venv passt nicht mehr zum System-Python (Distributions-Upgrade?)." + report_venv_issues log_warn "Sie wird gesichert nach: $VENV_SAVED" mv "$INSTALL_DIR/venv" "$VENV_SAVED" fi @@ -189,7 +177,7 @@ install_base() { log_info "venv ok ✓" log_step "systemd Template-Unit installieren" - cp "$REPO_DIR/systemd/$SERVICE_TEMPLATE" "/etc/systemd/system/$SERVICE_TEMPLATE" + cp "$REPO_DIR/systemd/$SERVICE_TEMPLATE" "$SYSTEMD_DIR/$SERVICE_TEMPLATE" systemctl daemon-reload log_info "Template-Unit installiert ✓" @@ -427,7 +415,7 @@ create_instance() { # Drop-in für abweichenden Service-User if [ "$SVC_USER" != "$DEFAULT_USER" ]; then - local DROPIN_DIR="/etc/systemd/system/pdf-ocr-hotfolder@${INST}.service.d" + local DROPIN_DIR="$SYSTEMD_DIR/pdf-ocr-hotfolder@${INST}.service.d" mkdir -p "$DROPIN_DIR" cat > "$DROPIN_DIR/user.conf" </dev/null || exit 0 +fi +PDF_OCR_COMMON_LOADED=1 + +# ============================================================ +# Ausgabe +# ============================================================ + +RED='\033[0;31m'; GREEN='\033[0;32m'; YELLOW='\033[1;33m'; BLUE='\033[0;34m'; NC='\033[0m' +log_info() { echo -e "${GREEN}[INFO]${NC} $*"; } +log_warn() { echo -e "${YELLOW}[WARN]${NC} $*"; } +log_error() { echo -e "${RED}[ERROR]${NC} $*"; } +log_step() { echo -e "\n${BLUE}==>${NC} $*"; } + +# Bricht ab, wenn nicht root. $1 = der Aufruf, der gemeint ist. +require_root() { + if [ "${EUID:-$(id -u)}" -ne 0 ]; then + log_error "Bitte als root ausfuehren: ${1:-sudo $0}" + exit 1 + fi +} + +# ============================================================ +# Installations-Layout — einzige Quelle der Wahrheit +# ============================================================ + +: "${INSTALL_DIR:=/opt/pdf-ocr-hotfolder}" +: "${CONFIG_DIR:=/etc/pdf-ocr-hotfolder}" +: "${DATA_ROOT:=/var/lib/pdf-ocr-hotfolder}" +: "${SYSTEMD_DIR:=/etc/systemd/system}" +: "${DEFAULT_USER:=pdfocr}" + +SERVICE_TEMPLATE="pdf-ocr-hotfolder@.service" +LXC_DROPIN_DIR="$SYSTEMD_DIR/${SERVICE_TEMPLATE}.d" +# shellcheck disable=SC2034 # wird von install.sh und update.sh genutzt +LXC_DROPIN="$LXC_DROPIN_DIR/lxc-compat.conf" + +# Pfad dieser Datei relativ zum Repo- bzw. Installationsverzeichnis. update.sh +# schneidet damit die Paketliste aus der Repo-Fassung heraus (siehe unten). +# shellcheck disable=SC2034 # wird von update.sh genutzt +COMMON_LIB_REL="lib/common.sh" + +# ============================================================ +# System-Pakete +# ============================================================ +# Der folgende Block wird von update.sh aus DIESER Datei herausgeschnitten +# (sed auf die BEGIN/END-Marken) und dort ausgewertet: beim Update soll die +# Liste aus dem Repo gelten, nicht die vielleicht aeltere, bereits gesourcte +# aus dem Installationsverzeichnis. Die Marken und der Funktionsname duerfen +# sich deshalb nicht aendern, ohne update.sh anzupassen. +# --- BEGIN apt-packages (wird von update.sh extrahiert) --- +pdf_ocr_apt_packages() { + cat <<'PKGLIST' +python3 +python3-venv +python3-pip +tesseract-ocr +tesseract-ocr-deu +tesseract-ocr-eng +ghostscript +qpdf +unpaper +pngquant +icc-profiles-free +ca-certificates +curl +PKGLIST +} +# --- END apt-packages --- + +# ============================================================ +# Python / venv +# ============================================================ + +# major.minor des uebergebenen Interpreters; leer, wenn er nicht laeuft. +py_mm() { + local py="$1" + "$py" -c 'import sys; print("%d.%d" % sys.version_info[:2])' 2>/dev/null || true +} + +# major.minor aus pyvenv.cfg (version = / version_info =); leer, wenn unlesbar. +pyvenv_cfg_mm() { + local cfg="$1/pyvenv.cfg" + [ -f "$cfg" ] || return 0 + sed -n 's/^[[:space:]]*version\(_info\)\?[[:space:]]*=[[:space:]]*\([0-9]\+\.[0-9]\+\).*/\2/p' "$cfg" | head -n1 +} + +# Prueft die venv gegen das aktuelle System-Python. +# Setzt VENV_ISSUES (Array) und gibt 0 zurueck, wenn alles passt. +# +# Drei Faelle fuehren zum Neubau: (1) der Interpreter der venv laeuft gar nicht +# mehr (toter Symlink nach einem Distributions-Upgrade, systemd: 203/EXEC), +# (2) er laeuft noch, ist aber eine andere Version als das System-Python +# (Debian 12 -> 13), (3) pyvenv.cfg und Interpreter widersprechen sich. +# Keine Versionsnummer ist hier hartcodiert. +VENV_ISSUES=() +venv_is_healthy() { + local venv="$1" + local sys_mm venv_mm cfg_mm + VENV_ISSUES=() + + if [ ! -d "$venv" ]; then + VENV_ISSUES+=("venv-Verzeichnis fehlt: $venv") + return 1 + fi + if [ ! -x "$venv/bin/python" ]; then + VENV_ISSUES+=("$venv/bin/python fehlt oder ist nicht ausfuehrbar") + return 1 + fi + + venv_mm="$(py_mm "$venv/bin/python")" + if [ -z "$venv_mm" ]; then + VENV_ISSUES+=("$venv/bin/python laeuft nicht (toter Symlink nach einem Distributions-Upgrade?)") + return 1 + fi + + sys_mm="$(py_mm "$(command -v python3 || echo /usr/bin/python3)")" + if [ -z "$sys_mm" ]; then + VENV_ISSUES+=("System-python3 laeuft nicht — venv-Pruefung nicht moeglich") + return 1 + fi + + if [ "$venv_mm" != "$sys_mm" ]; then + VENV_ISSUES+=("venv haengt an Python $venv_mm, das System liefert Python $sys_mm") + return 1 + fi + + cfg_mm="$(pyvenv_cfg_mm "$venv")" + if [ -n "$cfg_mm" ] && [ "$cfg_mm" != "$venv_mm" ]; then + VENV_ISSUES+=("pyvenv.cfg nennt Python $cfg_mm, der Interpreter meldet $venv_mm") + return 1 + fi + return 0 +} + +# Gibt die von venv_is_healthy gesammelten Befunde als Warnungen aus. +report_venv_issues() { + local issue + for issue in "${VENV_ISSUES[@]:-}"; do + [ -n "$issue" ] || continue + log_warn " - $issue" + done +} diff --git a/pdf_ocr_hotfolder/__init__.py b/pdf_ocr_hotfolder/__init__.py index 71f0e6f..2be1f0c 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.3" +__version__ = "0.7.0" diff --git a/pdf_ocr_hotfolder/__main__.py b/pdf_ocr_hotfolder/__main__.py index 2f017c6..b54c9e9 100644 --- a/pdf_ocr_hotfolder/__main__.py +++ b/pdf_ocr_hotfolder/__main__.py @@ -27,13 +27,32 @@ CHECK_ERROR = 2 def _setup_logging(level: str) -> None: + # stream explizit auf stdout: der Default von basicConfig() ist stderr, + # README und docs/INSTALLATION.md versprechen aber stdout. Für journald + # ist das egal, für den dort beschriebenen Vordergrund-Notbehelf und für + # jeden, der die Ausgabe weiterleitet, nicht. logging.basicConfig( level=getattr(logging, level.upper(), logging.INFO), format="%(asctime)s %(levelname)-7s %(name)s: %(message)s", datefmt="%Y-%m-%d %H:%M:%S", + stream=sys.stdout, ) +def _toml_error_text(cfg_path: Path, exc: tomllib.TOMLDecodeError) -> str: + """Formuliert die Meldung für kaputtes TOML — mit Zeile/Spalte, wenn möglich. + + `TOMLDecodeError.lineno`/`.colno` gibt es erst ab Python 3.14. Auf Debian + 12 (Python 3.11) fehlen die Attribute, dort steht die Position nur im + Meldungstext ("... (at line 3, column 12)") — deshalb `getattr` statt + direktem Zugriff. + """ + lineno = getattr(exc, "lineno", None) + colno = getattr(exc, "colno", None) + pos = f" (Zeile {lineno}, Spalte {colno})" if lineno is not None else "" + return f"{cfg_path} ist kein gültiges TOML{pos}: {exc}" + + def _log_config_warnings(cfg: Config) -> None: """Schreibt Legacy- und Unbekannt-Warnungen beim Dienststart ins Log.""" for warning in config_warnings(cfg): @@ -55,7 +74,7 @@ def check_config(cfg_path: Path) -> int: print(f"FEHLER: {e}", file=sys.stderr) return CHECK_ERROR except tomllib.TOMLDecodeError as e: - print(f"FEHLER: {cfg_path} ist kein gültiges TOML: {e}", file=sys.stderr) + print(f"FEHLER: {_toml_error_text(cfg_path, e)}", file=sys.stderr) return CHECK_ERROR except OSError as e: print(f"FEHLER: {cfg_path} nicht lesbar: {e}", file=sys.stderr) @@ -76,10 +95,19 @@ def check_config(cfg_path: Path) -> int: print(f" ocrmypdf = {detect_ocrmypdf_version() or '(nicht installiert)'}") print(f" Ghostscript = {detect_ghostscript_version() or '(nicht gefunden)'}") + if cfg.verapdf.enabled: + print(f" veraPDF = {cfg.verapdf.binary} (Flavour " + f"{cfg.verapdf.flavour})") + else: + print(" veraPDF = (aus)") + errors: list[str] = [] try: - check_preflight(cfg.ocr.pdfa_level, cfg.ocr.skip_text) - print(" Preflight ok (tesseract, gs vorhanden, Ghostscript-Version " + check_preflight(cfg.ocr.pdfa_level, cfg.ocr.skip_text, + cfg.verapdf.enabled, cfg.verapdf.binary) + print(" Preflight ok (tesseract, gs" + + (", veraPDF" if cfg.verapdf.enabled else "") + + " vorhanden, Ghostscript-Version " "passt zu ocrmypdf + [ocr]-Einstellungen).") except PreflightError as e: errors.append(str(e)) @@ -142,6 +170,18 @@ def main() -> int: except ConfigError as e: print(f"FEHLER: {e}", file=sys.stderr) return 2 + except tomllib.TOMLDecodeError as e: + # Ohne diesen Zweig endet ein Tippfehler in der Config (unbalancierte + # Anführungszeichen o.ä.) beim Dienststart in einem nackten Traceback. + # Dieselbe Behandlung wie in --check-config: verständliche Meldung, + # Exit 2 = Config-Fehler. + print(f"FEHLER: {_toml_error_text(cfg_path, e)}", file=sys.stderr) + print("Der Dienst startet nicht. Config korrigieren und mit " + "--check-config gegenprüfen.", file=sys.stderr) + return 2 + except OSError as e: + print(f"FEHLER: {cfg_path} nicht lesbar: {e}", file=sys.stderr) + return 2 _setup_logging(cfg.log_level) _log_config_warnings(cfg) @@ -156,13 +196,15 @@ def main() -> int: return 1 if errors > 0 else 0 try: - service.run() + # run() liefert 0 bei regulärem Stopp und EXIT_OBSERVER_DEAD, wenn der + # Verzeichnis-Watch gestorben ist — Letzteres muss nach außen + # durchschlagen, sonst startet systemd den Dienst nicht neu. + return service.run() except PreflightError as e: print(f"FEHLER: {e}", file=sys.stderr) return 2 except KeyboardInterrupt: - pass - return 0 + return 0 if __name__ == "__main__": diff --git a/pdf_ocr_hotfolder/config.py b/pdf_ocr_hotfolder/config.py index a1336b7..76da43b 100644 --- a/pdf_ocr_hotfolder/config.py +++ b/pdf_ocr_hotfolder/config.py @@ -131,6 +131,29 @@ def _section(data: dict[str, Any], *keys: str) -> dict[str, Any]: return cur if isinstance(cur, dict) else {} +def _require_absolute(value: str, label: str, cfg_path: Path, + beispiel: str) -> None: + """Weist relative Pfadangaben zurück. + + Ein relativer Pfad wird gegen das Arbeitsverzeichnis des Prozesses + aufgelöst — bei der systemd-Unit also gegen `WorkingDirectory` + (/opt/pdf-ocr-hotfolder). `incoming = "in"` legte damit stillschweigend + /opt/pdf-ocr-hotfolder/in an: der Scanner schreibt woanders hin als der + Dienst schaut, und niemand sieht einen Fehler. Absolute Pfade sind die + einzige sinnvolle Angabe; install.sh erzeugt ohnehin nur solche. + """ + if not value or Path(value).is_absolute(): + return + raise ConfigError( + f"{cfg_path}: {label} = {value!r} ist ein relativer Pfad. Hier sind " + f"nur absolute Pfade zulässig — ein relativer würde gegen das " + f"Arbeitsverzeichnis des Dienstes aufgelöst " + f"(WorkingDirectory, also z.B. /opt/pdf-ocr-hotfolder/{value}) und " + f"nicht gegen das Verzeichnis, in dem die Config liegt. " + f'Bitte absolut angeben, z.B. "{beispiel}".' + ) + + def _require_path(p: dict[str, Any], key: str, cfg_path: Path) -> Path: """Holt einen Pflicht-Pfad aus der [paths]-Sektion. @@ -144,7 +167,10 @@ def _require_path(p: dict[str, Any], key: str, cfg_path: Path) -> Path: f'{key} = "/var/lib/pdf-ocr-hotfolder/{key}" ' f"— siehe config.example.toml." ) - return Path(str(value)) + value = str(value) + _require_absolute(value, f"In der Sektion [paths] der Eintrag '{key}'", + cfg_path, f"/var/lib/pdf-ocr-hotfolder/{key}") + return Path(value) def _unknown_in(data: dict[str, Any], keys: tuple[str, ...], @@ -218,6 +244,13 @@ def load_config(path: str | Path) -> Config: email = EmailNotify(**{k: v for k, v in _section(data, "notify", "email").items() if k in EmailNotify.__annotations__}) + # Dieselbe Regel wie für [paths]: beides sind Verzeichnisse, in die der + # Dienst schreibt, und beide wären relativ aufgelöst schlicht falsch. + _require_absolute(str(output.archive_dir), "[output].archive_dir", path, + "/var/lib/pdf-ocr-hotfolder/archive") + _require_absolute(str(folder.target), "[upload.folder].target", path, + "/srv/scans/fertig") + log_level = _section(data, "logging").get("level", "INFO") return Config( diff --git a/pdf_ocr_hotfolder/processor.py b/pdf_ocr_hotfolder/processor.py index 59309df..c38c2ab 100644 --- a/pdf_ocr_hotfolder/processor.py +++ b/pdf_ocr_hotfolder/processor.py @@ -2,9 +2,11 @@ from __future__ import annotations import logging +import os import shutil import subprocess from dataclasses import dataclass +from datetime import datetime from pathlib import Path from .config import OcrConfig, OutputConfig, VeraPdfConfig @@ -45,6 +47,27 @@ def build_output_name(src_name: str, mode: str, tag: str) -> str: raise ValueError(f"Unbekannter name_mode: {mode!r}") +class VeraPdfUnavailable(RuntimeError): + """veraPDF konnte nicht befragt werden — Programm fehlt, startet nicht, Timeout. + + Ausdrücklich KEIN inhaltliches Urteil über die PDF. Der Unterschied ist + existenziell: ein nicht startbares veraPDF, das wie ein FAIL behandelt + wird, schiebt JEDES OCR-Ergebnis nach error/ und entsorgt das Original + laut [output].original_on_success — bei dessen Default `delete` also + Scan für Scan die Vorlage, während der Dienst als "läuft" dasteht. + """ + + +# veraPDF schreibt mit `--format text` pro Datei eine Zeile, die mit dem +# Urteil beginnt. Steht in der Ausgabe weder PASS noch FAIL, hat veraPDF gar +# nichts geprüft (fehlendes Java, kaputter Wrapper, falsches Flavour) — das +# ist kein "nicht konform", sondern ein fehlendes Urteil. +_VERAPDF_VERDICTS = ("PASS", "FAIL") + +# Sekunden, die veraPDF pro Datei laufen darf +VERAPDF_TIMEOUT = 300 + + @dataclass class ProcessResult: source: Path @@ -52,6 +75,9 @@ class ProcessResult: success: bool error: str = "" verapdf_passed: bool | None = None + # Gesetzt, wenn der Durchlauf erfolgreich war, aber etwas Nennenswertes + # danebenlief (aktuell: das Original ließ sich nicht entsorgen). + warning: str = "" def run_ocr(src: Path, dst: Path, cfg: OcrConfig) -> None: @@ -86,24 +112,65 @@ def run_ocr(src: Path, dst: Path, cfg: OcrConfig) -> None: log.info("OCR done: %s", dst.name) +def resolve_verapdf_binary(binary: str) -> str | None: + """Sucht das veraPDF-Programm und prüft, ob es ausführbar ist. + + Beide Schreibweisen sind zulässig: ein Pfad (`/opt/verapdf/verapdf`, der + Default) wird direkt geprüft, ein nackter Name (`verapdf`) im PATH + gesucht. + + Returns: + Der aufrufbare Pfad oder None. + """ + if not binary: + return None + if os.sep in binary: + p = Path(binary) + return str(p) if p.is_file() and os.access(p, os.X_OK) else None + return shutil.which(binary) + + def run_verapdf(pdf: Path, cfg: VeraPdfConfig) -> bool: - """Validiert PDF/A mit veraPDF (CLI). Gibt True zurück, wenn konform.""" + """Validiert PDF/A mit veraPDF (CLI). Gibt True zurück, wenn konform. + + Returns: + True = konform (PASS), False = nicht konform (FAIL). + + Raises: + VeraPdfUnavailable: veraPDF ließ sich nicht befragen. Das ist kein + FAIL — siehe Klassen-Docstring. + """ if not cfg.enabled: return True - if not Path(cfg.binary).exists(): - log.warning("veraPDF binary nicht gefunden: %s", cfg.binary) - return False + binary = resolve_verapdf_binary(cfg.binary) + if binary is None: + raise VeraPdfUnavailable( + f"[verapdf].binary = {cfg.binary!r} existiert nicht oder ist nicht " + "ausführbar" + ) try: result = subprocess.run( - [cfg.binary, "--flavour", cfg.flavour, "--format", "text", str(pdf)], - capture_output=True, text=True, timeout=300, + [binary, "--flavour", cfg.flavour, "--format", "text", str(pdf)], + capture_output=True, text=True, timeout=VERAPDF_TIMEOUT, ) - ok = result.returncode == 0 and "PASS" in result.stdout - log.info("veraPDF %s: %s", "PASS" if ok else "FAIL", pdf.name) - return ok - except subprocess.TimeoutExpired: - log.error("veraPDF Timeout: %s", pdf.name) - return False + except subprocess.TimeoutExpired as e: + raise VeraPdfUnavailable( + f"veraPDF hat für {pdf.name} nach {VERAPDF_TIMEOUT} s nicht " + "geantwortet" + ) from e + except OSError as e: + raise VeraPdfUnavailable(f"veraPDF ({binary}) nicht startbar: {e}") from e + + if not any(v in result.stdout for v in _VERAPDF_VERDICTS): + ausgabe = (result.stdout + result.stderr).strip().replace("\n", " ") + raise VeraPdfUnavailable( + f"veraPDF ({binary}) hat kein Urteil geliefert " + f"(Exit {result.returncode}): {ausgabe[:300] or '(keine Ausgabe)'}" + ) + + ok = result.returncode == 0 and "PASS" in result.stdout + log.info("veraPDF %s: %s", "PASS" if ok else "FAIL", pdf.name) + return ok def process_pdf( @@ -151,7 +218,24 @@ def process_pdf( vera_ok: bool | None = None if vera_cfg.enabled: - vera_ok = run_verapdf(work_out, vera_cfg) + try: + vera_ok = run_verapdf(work_out, vera_cfg) + except VeraPdfUnavailable as e: + # Kein Urteil über die Datei — also darf auch nichts entsorgt + # werden. Original UND OCR-Ergebnis gehen nach error/; das + # Original bleibt damit unabhängig von + # [output].original_on_success erhalten. + log.error( + "veraPDF nicht aufrufbar (%s) — %s wird NICHT als ungültig " + "gewertet: Original und OCR-Ergebnis liegen in %s, das " + "Original wurde weder gelöscht noch archiviert. " + "[verapdf].binary prüfen (--check-config)", + e, src.name, error_dir, + ) + _move_to_error(work_out, error_dir) + _move_to_error(work_src, error_dir) + return ProcessResult(src, final_out, False, + f"veraPDF nicht aufrufbar: {e}") if not vera_ok: # Das OCR-Ergebnis ist unbrauchbar und wandert nach error/. Das # Original wird aber NICHT bedingungslos gelöscht: es folgt derselben @@ -168,9 +252,26 @@ def process_pdf( "verapdf validation failed", verapdf_passed=False) outgoing_dir.mkdir(parents=True, exist_ok=True) + # Liegt in outgoing/ schon eine Datei desselben Namens (Scanner liefert + # denselben Dateinamen ein zweites Mal, oder das Vorgängerergebnis wurde + # noch nicht abgeholt), würde der move sie kommentarlos überschreiben. + # Stattdessen derselbe Zeitstempel-Ausweg wie im Archiv. + final_out = _collision_free_path(final_out) + if final_out.name != out_name: + log.warning( + "In %s liegt bereits eine Datei %s — das neue OCR-Ergebnis wird " + "als %s abgelegt, damit das ältere nicht überschrieben wird", + outgoing_dir, out_name, final_out.name, + ) shutil.move(str(work_out), str(final_out)) - _dispose_original(work_src, src.name, output_cfg) - return ProcessResult(src, final_out, True, verapdf_passed=vera_ok) + # Scheitert die Entsorgung des Originals (Platte voll, read-only), ist der + # Durchlauf trotzdem gelungen: das fertige PDF liegt bereits in outgoing/. + # Der Fehler darf ihn deshalb nicht entwerten — sonst unterbleibt der + # Upload und das Ergebnis bleibt liegen. Er wird als Warnung + # weitergereicht und landet in der Benachrichtigung. + warning = _dispose_original(work_src, src.name, output_cfg) + return ProcessResult(src, final_out, True, verapdf_passed=vera_ok, + warning=warning) def _is_same_file(a: Path, b: Path) -> bool: @@ -181,41 +282,103 @@ def _is_same_file(a: Path, b: Path) -> bool: return False -def _dispose_original(work_src: Path, original_name: str, cfg: OutputConfig) -> None: +def _collision_free_path(dest: Path) -> Path: + """Weicht einem schon belegten Zielnamen per Zeitstempel-Suffix aus. + + Einheitlich für outgoing/ und Archiv: `scan.pdf` wird zu + `scan_20260923-081500.pdf`. Ist auch der Zeitstempel-Name belegt (zwei + Dateien innerhalb derselben Sekunde, z.B. bei mehreren Workern), wird + zusätzlich hochgezählt — sonst überschriebe der anschließende `move` doch + wieder still. + + Der Rest bleibt unverändert: existiert das Ziel nicht, kommt es + unverändert zurück. + """ + if not dest.exists(): + return dest + ts = datetime.now().strftime("%Y%m%d-%H%M%S") + candidate = dest.with_name(f"{dest.stem}_{ts}{dest.suffix}") + counter = 2 + while candidate.exists(): + candidate = dest.with_name(f"{dest.stem}_{ts}-{counter}{dest.suffix}") + counter += 1 + return candidate + + +def _dispose_original(work_src: Path, original_name: str, cfg: OutputConfig) -> str: """Entsorgt das Original laut [output].original_on_success — löschen oder archivieren. Wird nach erfolgreichem OCR aufgerufen und ebenso, wenn veraPDF die Validierung ablehnt: auch dann soll `archive` das Original erhalten. + + Wirft bewusst NICHT: zum Aufrufzeitpunkt liegt das fertige PDF schon in + outgoing/. Eine Exception von hier würde den gelungenen Durchlauf im + Catch-all des Service in einen Fehler verwandeln — mitsamt + ausgefallenem Upload. + + Returns: + Leerer String = erledigt. Sonst die Fehlermeldung (bereits geloggt). """ if not work_src.exists(): - return + return "" mode = cfg.original_on_success - if mode == "delete": - work_src.unlink(missing_ok=True) - return - if mode == "archive": - if not cfg.archive_dir: - log.error("original_on_success=archive aber archive_dir ist leer — lösche stattdessen") - work_src.unlink(missing_ok=True) - return + if mode == "archive" and cfg.archive_dir: archive = Path(cfg.archive_dir) - archive.mkdir(parents=True, exist_ok=True) - dest = archive / original_name - # Bei Namens-Kollision mit Timestamp umbenennen - if dest.exists(): - from datetime import datetime - ts = datetime.now().strftime("%Y%m%d-%H%M%S") - dest = archive / f"{dest.stem}_{ts}{dest.suffix}" - shutil.move(str(work_src), str(dest)) + try: + archive.mkdir(parents=True, exist_ok=True) + # Bei Namens-Kollision mit Timestamp umbenennen (gleicher Weg wie + # für das Ergebnis in outgoing/) + dest = _collision_free_path(archive / original_name) + shutil.move(str(work_src), str(dest)) + except OSError as e: + return _disposal_failed(work_src, original_name, + f"nicht nach {archive} archiviert", e) log.info("Original archiviert: %s", dest) - return - log.warning("Unbekannter original_on_success=%r — lösche stattdessen", mode) - work_src.unlink(missing_ok=True) + return "" + + if mode == "archive": + log.error("original_on_success=archive aber archive_dir ist leer — " + "lösche stattdessen") + elif mode != "delete": + log.warning("Unbekannter original_on_success=%r — lösche stattdessen", mode) + try: + work_src.unlink(missing_ok=True) + except OSError as e: + return _disposal_failed(work_src, original_name, "nicht gelöscht", e) + return "" + + +def _disposal_failed(work_src: Path, original_name: str, was: str, + exc: OSError) -> str: + """Einheitliche Meldung, wenn das Original nicht entsorgt werden konnte.""" + msg = ( + f"Original {original_name} konnte {was} werden ({exc}). Das OCR-PDF ist " + f"fertig und wird normal ausgeliefert, das Original liegt aber " + f"weiterhin in {work_src.parent} — es wird beim nächsten Start dort " + f"aufgegriffen und ein zweites Mal durch das OCR geschickt. Bitte " + f"{work_src} von Hand aufräumen und die Ursache beheben " + f"(Plattenplatz, Schreibrechte)." + ) + log.error("%s", msg) + return msg def _move_to_error(p: Path, error_dir: Path) -> None: + """Verschiebt eine Datei ins error-Verzeichnis, ohne dort etwas zu überschreiben. + + Scheitert dieselbe `scan.pdf` zweimal, ersetzte die zweite bisher still die + erste — dieselbe Datenverlust-Klasse wie in outgoing/. Deshalb derselbe + Zeitstempel-Ausweg über `_collision_free_path()`. + """ error_dir.mkdir(parents=True, exist_ok=True) + dest = _collision_free_path(error_dir / p.name) + if dest.name != p.name: + log.warning( + "In %s liegt bereits eine Datei %s — die neue wird als %s abgelegt, " + "damit die ältere nicht überschrieben wird", + error_dir, p.name, dest.name, + ) try: - shutil.move(str(p), str(error_dir / p.name)) + shutil.move(str(p), str(dest)) except OSError: log.exception("Konnte %s nicht in error-Verzeichnis verschieben", p) diff --git a/pdf_ocr_hotfolder/service.py b/pdf_ocr_hotfolder/service.py index d0dc1cd..92f958e 100644 --- a/pdf_ocr_hotfolder/service.py +++ b/pdf_ocr_hotfolder/service.py @@ -22,6 +22,7 @@ from .processor import ( ProcessResult, _move_to_error, process_pdf, + resolve_verapdf_binary, ) from .uploaders import notify_email, upload_folder, upload_nextcloud, upload_sftp @@ -32,6 +33,13 @@ class PreflightError(RuntimeError): """Erforderliche externe Binaries fehlen.""" +# Exit-Code, mit dem sich der Dienst bei totem watchdog-Observer beendet. +# Bewusst NICHT 2: die Unit setzt RestartPreventExitStatus=2 für Config- und +# Preflight-Fehler, die ein Neustart nicht heilt. Ein toter Observer soll +# dagegen genau das — neu starten, damit der inotify-Watch neu aufgesetzt wird. +EXIT_OBSERVER_DEAD = 3 + + # Pflicht-Binaries für ocrmypdf _REQUIRED_BINARIES = ("tesseract", "gs") @@ -140,13 +148,47 @@ def check_output_config(mode: str, archive_dir: str, ) -def check_preflight(pdfa_level: str = "", skip_text: bool = False) -> None: +def check_verapdf_binary(enabled: bool, binary: str) -> None: + """Prüft das in [verapdf].binary konfigurierte Programm — wenn aktiviert. + + Ohne diese Prüfung ist ein Tippfehler im Pfad der gefährlichste Fehler des + ganzen Dienstes: `run_verapdf()` findet das Programm für JEDE Datei nicht, + das OCR-Ergebnis wandert nach error/, und `_dispose_original()` löscht bei + `original_on_success = "delete"` (dem Default) das Original. Scan für Scan + verschwinden so die Vorlagen, während die Unit als `active (running)` + dasteht. + """ + if not enabled: + return + if not binary: + raise PreflightError( + "[verapdf].enabled = true, aber [verapdf].binary ist leer. " + "Entweder den Pfad zum veraPDF-Programm eintragen oder " + "[verapdf].enabled = false setzen." + ) + if resolve_verapdf_binary(binary) is None: + raise PreflightError( + f"[verapdf].enabled = true, aber [verapdf].binary = {binary!r} " + "existiert nicht oder ist nicht ausführbar. Der Dienst startet " + "bewusst nicht: ein nicht aufrufbares veraPDF würde sonst jede " + "einzelne PDF als ungültig werten, das OCR-Ergebnis nach error/ " + "schieben und das Original laut [output].original_on_success " + "entsorgen. Pfad korrigieren (chmod +x nicht vergessen) oder " + "[verapdf].enabled = false setzen." + ) + + +def check_preflight(pdfa_level: str = "", skip_text: bool = False, + verapdf_enabled: bool = False, + verapdf_binary: str = "") -> None: """Prüft externe Abhängigkeiten. - Tesseract und Ghostscript müssen im PATH sein - 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`). + - Ist [verapdf].enabled gesetzt, muss auch das dort konfigurierte + Programm vorhanden und ausführbar sein (siehe `check_verapdf_binary`). Wirft PreflightError bei fehlenden Binaries oder unsicherem Ghostscript. """ @@ -161,6 +203,8 @@ def check_preflight(pdfa_level: str = "", skip_text: bool = False) -> None: if reason: raise PreflightError(reason) + check_verapdf_binary(verapdf_enabled, verapdf_binary) + def _gs_block_reason(pdfa_level: str, skip_text: bool) -> str | None: """Liefert die Fehlermeldung, wenn ocrmypdf mit diesem Ghostscript abbricht. @@ -298,11 +342,20 @@ class HotfolderService: # ---- Lifecycle ---- - def run(self) -> None: - check_preflight(self.cfg.ocr.pdfa_level, self.cfg.ocr.skip_text) + def _preflight(self) -> None: + check_preflight(self.cfg.ocr.pdfa_level, self.cfg.ocr.skip_text, + self.cfg.verapdf.enabled, self.cfg.verapdf.binary) check_output_config(self.cfg.output.original_on_success, self.cfg.output.archive_dir, self.cfg.output.name_mode) + + def run(self) -> int: + """Startet den Dienst und läuft, bis gestoppt wird. + + Returns: + 0 bei regulärem Stopp (SIGTERM/SIGINT), sonst `EXIT_OBSERVER_DEAD`. + """ + self._preflight() self.ensure_dirs() self._scan_existing() @@ -315,21 +368,49 @@ class HotfolderService: signal.signal(signal.SIGINT, lambda *_: self._stop.set()) try: - while not self._stop.is_set(): - self._stop.wait(1.0) + return self._wait_loop() finally: self.shutdown() + def _wait_loop(self) -> int: + """Hauptschleife: wartet auf den Stopp und bewacht den Observer. + + Stirbt der watchdog-Observer im Betrieb (erschöpftes + inotify-Watch-Limit, ersetztes oder neu gemountetes Verzeichnis), + blieb die Unit bisher `active (running)` und verarbeitete nichts mehr: + kein Log, keine Mail, niemand merkt es. Für einen Hotfolder ist das + der schlechteste denkbare Zustand. Deshalb wird der Observer + sekündlich mitgeprüft und der Dienst im Ernstfall mit + `EXIT_OBSERVER_DEAD` beendet, damit systemd ihn per + `Restart=on-failure` neu startet und den Watch neu aufsetzt. + """ + while not self._stop.is_set(): + self._stop.wait(1.0) + if self._stop.is_set(): + # Regulärer Stopp — hier darf kein Fehlalarm entstehen, auch + # wenn der Observer planmäßig schon gestoppt wurde. + break + if self._observer is not None and not self._observer.is_alive(): + log.error( + "Der Verzeichnis-Watch auf %s ist gestorben — es werden " + "KEINE neuen Dateien mehr erkannt. Mögliche Ursachen: " + "erschöpftes inotify-Watch-Limit " + "(fs.inotify.max_user_watches), ersetztes oder neu " + "gemountetes Verzeichnis. Der Dienst beendet sich mit " + "Exit %d, damit systemd ihn neu startet und der Watch " + "neu aufgesetzt wird.", + self.cfg.paths.incoming, EXIT_OBSERVER_DEAD, + ) + return EXIT_OBSERVER_DEAD + return 0 + def run_once(self) -> int: """Verarbeitet alle bereits liegenden PDFs (incoming/ + working/) und beendet sich. Returns: Anzahl fehlgeschlagener PDFs (0 = alles ok). """ - 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) + self._preflight() self.ensure_dirs() self._scan_existing() self._executor.shutdown(wait=True) @@ -356,9 +437,32 @@ class HotfolderService: incoming-Datei nach working/ will. """ self._scan_working() + fremd: list[str] = [] for p in sorted(self.cfg.paths.incoming.iterdir()): if _is_pdf(p): self.enqueue(p) + elif p.is_file(): + fremd.append(p.name) + self._report_non_pdf(fremd) + + def _report_non_pdf(self, names: list[str]) -> None: + """Meldet einmalig, wie viele Fremddateien in incoming/ liegen. + + Alles ohne .pdf-Endung wird ignoriert und sammelte sich bisher stumm + an — Scanner-Fehlablagen, abgebrochene Uploads, Thumbnails. Eine + Sammelmeldung beim Start-Scan, keine Zeile pro Datei und nichts im + laufenden Betrieb: das soll auffallen, nicht spammen. + """ + if not names: + return + beispiele = ", ".join(names[:3]) + if len(names) > 3: + beispiele += f", … (+{len(names) - 3} weitere)" + log.warning( + "In %s liegen %d Datei(en) ohne .pdf-Endung — sie werden nicht " + "verarbeitet und bleiben dort liegen: %s", + self.cfg.paths.incoming, len(names), beispiele, + ) def _scan_working(self) -> None: """Greift Dateien auf, die ein harter Stopp in working/ liegen ließ. @@ -563,6 +667,19 @@ class HotfolderService: notify_email(self.cfg.email, subject, body, False) def _notify(self, result: ProcessResult) -> None: + if result.success and result.warning: + # Erfolgreich verarbeitet, aber das Original blieb liegen. Der + # Durchlauf zählt als Erfolg (das PDF ist fertig und ausgeliefert), + # die Mail geht aber als Nicht-Erfolg raus, damit sie auch bei + # [notify.email].on = "errors" zugestellt wird — sonst wäre das + # genau wieder ein stiller Fehlerpfad. + subject = f"[pdf-ocr] OK mit Warnung: {result.source.name}" + body = ( + f"Datei verarbeitet: {result.output}\n\n" + f"ACHTUNG: {result.warning}\n" + ) + notify_email(self.cfg.email, subject, body, False) + return if result.success: subject = f"[pdf-ocr] OK: {result.source.name}" body = f"Datei verarbeitet: {result.output}\n" diff --git a/pdf_ocr_hotfolder/uploaders.py b/pdf_ocr_hotfolder/uploaders.py index d033e29..9b74d77 100644 --- a/pdf_ocr_hotfolder/uploaders.py +++ b/pdf_ocr_hotfolder/uploaders.py @@ -13,6 +13,7 @@ import paramiko import requests from .config import EmailNotify, FolderUpload, NextcloudUpload, SftpUpload +from .processor import _collision_free_path log = logging.getLogger(__name__) @@ -26,6 +27,15 @@ def upload_folder(pdf: Path, cfg: FolderUpload, default_target: Path) -> bool: try: if pdf.resolve() == dest.resolve(): return True + # Gleichnamige Datei im Ziel wurde bisher kommentarlos ersetzt. + # Derselbe Zeitstempel-Ausweg wie in outgoing/, archive/ und error/. + dest = _collision_free_path(dest) + if dest.name != pdf.name: + log.warning( + "In %s liegt bereits eine Datei %s — die Kopie wird als %s " + "abgelegt, damit die ältere nicht überschrieben wird", + target, pdf.name, dest.name, + ) # copyfile statt read_bytes/write_bytes: große PDFs nicht komplett # in den Speicher laden shutil.copyfile(pdf, dest) diff --git a/systemd/pdf-ocr-hotfolder@.service b/systemd/pdf-ocr-hotfolder@.service index 81cb9ec..3c72836 100644 --- a/systemd/pdf-ocr-hotfolder@.service +++ b/systemd/pdf-ocr-hotfolder@.service @@ -11,6 +11,12 @@ WorkingDirectory=/opt/pdf-ocr-hotfolder ExecStart=/opt/pdf-ocr-hotfolder/venv/bin/python -m pdf_ocr_hotfolder --config /etc/pdf-ocr-hotfolder/%i.toml Restart=on-failure RestartSec=5 +# Exit 2 = Konfigurations- oder Preflight-Fehler. Den behebt kein Neustart, +# also nicht endlos im 5-Sekunden-Takt neu starten, sondern stehenbleiben — +# die Instanz steht dann als 'failed' da und faellt beim Nachsehen auf. +# (Restart=on-failure wuerde sonst JEDEN Exit != 0 neu starten; das +# Start-Rate-Limit greift bei RestartSec=5 nie.) +RestartPreventExitStatus=2 KillMode=mixed # Ein laufendes OCR soll beim Stoppen zu Ende laufen duerfen. Bei SIGKILL # bliebe das Original in working/ liegen (wird beim naechsten Start zwar diff --git a/tests/test_dispose_failure.py b/tests/test_dispose_failure.py new file mode 100644 index 0000000..c8a5f3d --- /dev/null +++ b/tests/test_dispose_failure.py @@ -0,0 +1,211 @@ +"""Punkt 4: Ein Archivierungsfehler darf einen Erfolg nicht in einen Fehler kippen. + +Lief der `shutil.move` ins Archiv auf einen OSError (Platte voll, read-only), +flog die Exception NACH dem erfolgreichen Move nach outgoing/. `_process()` +fing sie im Catch-all, zählte einen Fehler — und `_dispatch_uploads()` lief +nie. Das fertige PDF lag da und wurde nie hochgeladen. + +Jetzt: `_dispose_original()` wirft nicht mehr, meldet den Fehler deutlich und +reicht ihn als `ProcessResult.warning` durch. Der Durchlauf zählt als Erfolg +(das PDF ist fertig und wird ausgeliefert), die Benachrichtigung geht aber als +Nicht-Erfolg raus, damit sie auch bei on = "errors" zugestellt wird. +""" +from __future__ import annotations + +import logging +from pathlib import Path +from unittest.mock import patch + +from pdf_ocr_hotfolder.config import FolderUpload, OcrConfig, OutputConfig, VeraPdfConfig +from pdf_ocr_hotfolder.processor import _dispose_original, process_pdf +from pdf_ocr_hotfolder.service import HotfolderService + +ORIGINAL = b"%PDF-1.4 original\n" + + +def _fake_ocr(src: Path, dst: Path, cfg: OcrConfig) -> None: + dst.write_bytes(b"%PDF-1.4 OCRed\n") + + +def _blocked_archive(tmp_path: Path) -> str: + """Ein Archivpfad, dessen mkdir garantiert scheitert (Elternteil = Datei). + + Steht stellvertretend für read-only/volle Platte, ohne mocken zu müssen. + """ + blocker = tmp_path / "blocker" + blocker.write_bytes(b"keine Verzeichnis\n") + return str(blocker / "archiv") + + +def _prepare(tmp_path: Path) -> dict: + dirs = {name: tmp_path / name + for name in ("incoming", "working", "outgoing", "error")} + for d in dirs.values(): + d.mkdir(parents=True, exist_ok=True) + src = dirs["incoming"] / "scan.pdf" + src.write_bytes(ORIGINAL) + return {"src": src, **dirs} + + +# ---------------- _dispose_original wirft nicht mehr ---------------- + +def test_dispose_archive_failure_returns_message(tmp_path: Path) -> None: + work_src = tmp_path / "working" / "scan.pdf" + work_src.parent.mkdir() + work_src.write_bytes(ORIGINAL) + + msg = _dispose_original(work_src, "scan.pdf", + OutputConfig(original_on_success="archive", + archive_dir=_blocked_archive(tmp_path))) + + assert msg + assert "scan.pdf" in msg + # Das Original liegt noch da — nichts wurde verloren + assert work_src.read_bytes() == ORIGINAL + + +def test_dispose_archive_failure_names_working_dir(tmp_path: Path) -> None: + """Die Meldung muss sagen, wo das Original liegen geblieben ist.""" + work_src = tmp_path / "working" / "scan.pdf" + work_src.parent.mkdir() + work_src.write_bytes(ORIGINAL) + + msg = _dispose_original(work_src, "scan.pdf", + OutputConfig(original_on_success="archive", + archive_dir=_blocked_archive(tmp_path))) + + assert str(work_src.parent) in msg + + +def test_dispose_delete_failure_returns_message(tmp_path: Path) -> None: + work_src = tmp_path / "working" / "scan.pdf" + work_src.parent.mkdir() + work_src.write_bytes(ORIGINAL) + + with patch.object(Path, "unlink", side_effect=OSError("read-only")): + msg = _dispose_original(work_src, "scan.pdf", + OutputConfig(original_on_success="delete")) + + assert msg + assert "gelöscht" in msg + + +def test_dispose_success_returns_empty(tmp_path: Path) -> None: + work_src = tmp_path / "working" / "scan.pdf" + work_src.parent.mkdir() + work_src.write_bytes(ORIGINAL) + archive = tmp_path / "archiv" + + assert _dispose_original(work_src, "scan.pdf", + OutputConfig(original_on_success="archive", + archive_dir=str(archive))) == "" + assert (archive / "scan.pdf").read_bytes() == ORIGINAL + + +def test_dispose_missing_file_returns_empty(tmp_path: Path) -> None: + assert _dispose_original(tmp_path / "gibtsnicht.pdf", "scan.pdf", + OutputConfig()) == "" + + +# ---------------- process_pdf bleibt erfolgreich ---------------- + +def _run(env: dict, out_cfg: OutputConfig): + with patch("pdf_ocr_hotfolder.processor.run_ocr", side_effect=_fake_ocr): + return process_pdf( + src=env["src"], + working_dir=env["working"], + outgoing_dir=env["outgoing"], + error_dir=env["error"], + ocr_cfg=OcrConfig(), + vera_cfg=VeraPdfConfig(enabled=False), + output_cfg=out_cfg, + ) + + +def test_archive_failure_keeps_run_successful(tmp_path: Path) -> None: + env = _prepare(tmp_path) + result = _run(env, OutputConfig(name_mode="prefix", name_tag="OCR_", + original_on_success="archive", + archive_dir=_blocked_archive(tmp_path))) + + assert result.success is True + assert result.warning + # Das fertige PDF liegt in outgoing/ und wird normal ausgeliefert + assert (env["outgoing"] / "OCR_scan.pdf").exists() + assert result.output == env["outgoing"] / "OCR_scan.pdf" + # Das Original ist nicht verloren, sondern liegt noch in working/ + assert (env["working"] / "scan.pdf").read_bytes() == ORIGINAL + + +def test_archive_failure_logs_error(tmp_path: Path, caplog) -> None: + env = _prepare(tmp_path) + with caplog.at_level(logging.ERROR, logger="pdf_ocr_hotfolder.processor"): + _run(env, OutputConfig(original_on_success="archive", + archive_dir=_blocked_archive(tmp_path))) + + assert str(env["working"]) in caplog.text + + +def test_successful_run_has_no_warning(tmp_path: Path) -> None: + env = _prepare(tmp_path) + result = _run(env, OutputConfig(original_on_success="delete")) + assert result.success is True + assert result.warning == "" + + +# ---------------- Service: Upload läuft trotzdem ---------------- + +def _run_once(tmp_config, **patches): + stack = [ + patch("pdf_ocr_hotfolder.service.check_preflight", return_value=None), + patch("pdf_ocr_hotfolder.service._wait_until_stable", return_value=True), + patch("pdf_ocr_hotfolder.processor.run_ocr", side_effect=_fake_ocr), + ] + service = HotfolderService(tmp_config) + try: + for p in stack: + p.start() + service.run_once() + finally: + for p in reversed(stack): + p.stop() + service._executor.shutdown(wait=False) + return service + + +def test_upload_still_runs_after_archive_failure(tmp_config, tmp_path) -> None: + """Der Kern des Punktes: das fertige PDF muss trotzdem hochgeladen werden.""" + ziel = tmp_path / "upload-ziel" + tmp_config.folder = FolderUpload(enabled=True, target=str(ziel)) + tmp_config.output = OutputConfig(name_mode="prefix", name_tag="OCR_", + original_on_success="archive", + archive_dir=_blocked_archive(tmp_path)) + (tmp_config.paths.incoming / "scan.pdf").write_bytes(ORIGINAL) + + service = _run_once(tmp_config) + + assert (ziel / "OCR_scan.pdf").exists() + # Der Durchlauf zählt als Erfolg: das PDF ist fertig und ausgeliefert. + assert service.success_count == 1 + assert service.error_count == 0 + + +def test_warning_notification_goes_out_as_error(tmp_config, tmp_path) -> None: + """Die Mail muss auch bei on = 'errors' zugestellt werden.""" + from pdf_ocr_hotfolder.processor import ProcessResult + + service = HotfolderService(tmp_config) + try: + with patch("pdf_ocr_hotfolder.service.notify_email") as mail: + service._notify(ProcessResult( + tmp_path / "scan.pdf", tmp_path / "OCR_scan.pdf", True, + warning="Original konnte nicht archiviert werden", + )) + finally: + service._executor.shutdown(wait=False) + + mail.assert_called_once() + args = mail.call_args[0] + assert "OK mit Warnung" in args[1] + assert "Original konnte nicht archiviert werden" in args[2] + assert args[3] is False # -> wird auch bei on="errors" verschickt diff --git a/tests/test_error_collision.py b/tests/test_error_collision.py new file mode 100644 index 0000000..0d72726 --- /dev/null +++ b/tests/test_error_collision.py @@ -0,0 +1,140 @@ +"""Punkt 2: error/ darf nichts mehr still überschreiben. + +Scheiterte dieselbe `scan.pdf` zweimal, ersetzte die zweite die erste in +error/ — dieselbe Datenverlust-Klasse, die für outgoing/ bereits geschlossen +ist. Betrifft `_move_to_error()` und damit auch `_rescue_to_error()`. +""" +from __future__ import annotations + +import logging +from pathlib import Path +from unittest.mock import patch + +from pdf_ocr_hotfolder.config import OcrConfig, OutputConfig, VeraPdfConfig +from pdf_ocr_hotfolder.processor import _move_to_error, process_pdf +from pdf_ocr_hotfolder.service import HotfolderService + +ERSTE = b"%PDF-1.4 erste\n" +ZWEITE = b"%PDF-1.4 zweite\n" + + +# ---------------- _move_to_error direkt ---------------- + +def test_move_to_error_keeps_existing_file(tmp_path: Path) -> None: + error_dir = tmp_path / "error" + error_dir.mkdir() + (error_dir / "scan.pdf").write_bytes(ERSTE) + + zweite = tmp_path / "scan.pdf" + zweite.write_bytes(ZWEITE) + _move_to_error(zweite, error_dir) + + assert (error_dir / "scan.pdf").read_bytes() == ERSTE + ausweich = list(error_dir.glob("scan_*.pdf")) + assert len(ausweich) == 1 + assert ausweich[0].read_bytes() == ZWEITE + assert not zweite.exists() + + +def test_move_to_error_without_collision_keeps_name(tmp_path: Path) -> None: + error_dir = tmp_path / "error" + src = tmp_path / "scan.pdf" + src.write_bytes(ERSTE) + + _move_to_error(src, error_dir) + + assert (error_dir / "scan.pdf").read_bytes() == ERSTE + assert list(error_dir.iterdir()) == [error_dir / "scan.pdf"] + + +def test_move_to_error_logs_warning_on_collision(tmp_path: Path, caplog) -> None: + error_dir = tmp_path / "error" + error_dir.mkdir() + (error_dir / "scan.pdf").write_bytes(ERSTE) + src = tmp_path / "scan.pdf" + src.write_bytes(ZWEITE) + + with caplog.at_level(logging.WARNING, logger="pdf_ocr_hotfolder.processor"): + _move_to_error(src, error_dir) + + assert "scan.pdf" in caplog.text + assert "überschrieben" in caplog.text + + +def test_move_to_error_creates_dir(tmp_path: Path) -> None: + src = tmp_path / "scan.pdf" + src.write_bytes(ERSTE) + error_dir = tmp_path / "tief" / "error" + _move_to_error(src, error_dir) + assert (error_dir / "scan.pdf").exists() + + +def test_move_to_error_survives_oserror(tmp_path: Path, caplog) -> None: + """Ein fehlgeschlagener Move darf weiterhin nur geloggt werden.""" + src = tmp_path / "scan.pdf" + src.write_bytes(ERSTE) + error_dir = tmp_path / "error" + + with patch("pdf_ocr_hotfolder.processor.shutil.move", + side_effect=OSError("read-only")): + _move_to_error(src, error_dir) # darf nicht werfen + + assert src.exists() + + +# ---------------- über process_pdf: zweimal dieselbe Datei kaputt ---------------- + +def _prepare(tmp_path: Path) -> dict: + dirs = {name: tmp_path / name + for name in ("incoming", "working", "outgoing", "error")} + for d in dirs.values(): + d.mkdir(parents=True, exist_ok=True) + return dirs + + +def _run_failing_ocr(dirs: dict, src: Path): + with patch("pdf_ocr_hotfolder.processor.run_ocr", + side_effect=RuntimeError("ocr kaputt")): + return process_pdf( + src=src, + working_dir=dirs["working"], + outgoing_dir=dirs["outgoing"], + error_dir=dirs["error"], + ocr_cfg=OcrConfig(), + vera_cfg=VeraPdfConfig(enabled=False), + output_cfg=OutputConfig(), + ) + + +def test_same_name_failing_twice_keeps_both(tmp_path: Path) -> None: + dirs = _prepare(tmp_path) + + for inhalt in (ERSTE, ZWEITE): + src = dirs["incoming"] / "scan.pdf" + src.write_bytes(inhalt) + result = _run_failing_ocr(dirs, src) + assert not result.success + + dateien = sorted(p.read_bytes() for p in dirs["error"].iterdir()) + assert len(dateien) == 2 + assert sorted([ERSTE, ZWEITE]) == dateien + + +# ---------------- _rescue_to_error erbt den Schutz ---------------- + +def test_rescue_to_error_keeps_existing_file(tmp_config) -> None: + """Der Rettungspfad nach einer unerwarteten Exception ebenso.""" + (tmp_config.paths.error / "boom.pdf").write_bytes(ERSTE) + src = tmp_config.paths.incoming / "boom.pdf" + src.write_bytes(ZWEITE) + + service = HotfolderService(tmp_config) + try: + service._rescue_to_error(src) + finally: + service._executor.shutdown(wait=False) + + assert (tmp_config.paths.error / "boom.pdf").read_bytes() == ERSTE + ausweich = list(tmp_config.paths.error.glob("boom_*.pdf")) + assert len(ausweich) == 1 + assert ausweich[0].read_bytes() == ZWEITE diff --git a/tests/test_incoming_non_pdf.py b/tests/test_incoming_non_pdf.py new file mode 100644 index 0000000..8f6dbd9 --- /dev/null +++ b/tests/test_incoming_non_pdf.py @@ -0,0 +1,88 @@ +"""Punkt 6: Fremddateien in incoming/ verschwinden nicht mehr lautlos. + +Alles ohne .pdf-Endung wurde kommentarlos ignoriert und sammelte sich an. +Jetzt gibt es beim Start-Scan genau EINE Sammelmeldung — kein Spam im +laufenden Betrieb, keine Zeile pro Datei. +""" +from __future__ import annotations + +import logging +from unittest.mock import patch + +from pdf_ocr_hotfolder.service import HotfolderService + +LOGGER = "pdf_ocr_hotfolder.service" + + +def _scan(tmp_config, caplog) -> str: + service = HotfolderService(tmp_config) + try: + with caplog.at_level(logging.WARNING, logger=LOGGER), \ + patch.object(HotfolderService, "enqueue"): + service._scan_existing() + finally: + service._executor.shutdown(wait=False) + return caplog.text + + +def test_non_pdf_files_are_reported(tmp_config, caplog) -> None: + (tmp_config.paths.incoming / "notizen.txt").write_text("x") + (tmp_config.paths.incoming / "bild.jpg").write_bytes(b"x") + (tmp_config.paths.incoming / "scan.pdf").write_bytes(b"%PDF-1.4\n") + + text = _scan(tmp_config, caplog) + + assert "2 Datei(en) ohne .pdf-Endung" in text + assert "notizen.txt" in text + assert "bild.jpg" in text + assert "scan.pdf" not in text + + +def test_single_aggregate_line(tmp_config, caplog) -> None: + """Eine Sammelmeldung, nicht eine pro Datei.""" + for i in range(7): + (tmp_config.paths.incoming / f"datei{i}.txt").write_text("x") + + text = _scan(tmp_config, caplog) + + assert text.count("ohne .pdf-Endung") == 1 + assert "7 Datei(en)" in text + # Nur die ersten drei werden namentlich genannt + assert "+4 weitere" in text + + +def test_no_message_without_foreign_files(tmp_config, caplog) -> None: + (tmp_config.paths.incoming / "scan.pdf").write_bytes(b"%PDF-1.4\n") + assert "ohne .pdf-Endung" not in _scan(tmp_config, caplog) + + +def test_empty_incoming_is_quiet(tmp_config, caplog) -> None: + assert "ohne .pdf-Endung" not in _scan(tmp_config, caplog) + + +def test_directories_are_not_counted(tmp_config, caplog) -> None: + """Ein Unterverzeichnis ist keine liegengebliebene Fremddatei.""" + (tmp_config.paths.incoming / "unterordner").mkdir() + assert "ohne .pdf-Endung" not in _scan(tmp_config, caplog) + + +def test_uppercase_pdf_is_not_foreign(tmp_config, caplog) -> None: + """`_is_pdf()` ist case-insensitiv — SCAN.PDF ist eine PDF.""" + (tmp_config.paths.incoming / "SCAN.PDF").write_bytes(b"%PDF-1.4\n") + assert "ohne .pdf-Endung" not in _scan(tmp_config, caplog) + + +def test_no_spam_during_runtime(tmp_config, caplog) -> None: + """Im laufenden Betrieb bleibt enqueue() für Fremddateien stumm.""" + fremd = tmp_config.paths.incoming / "notizen.txt" + fremd.write_text("x") + + service = HotfolderService(tmp_config) + try: + with caplog.at_level(logging.DEBUG, logger=LOGGER): + for _ in range(5): + service.enqueue(fremd) + finally: + service._executor.shutdown(wait=False) + + assert caplog.text == "" diff --git a/tests/test_log_stream.py b/tests/test_log_stream.py new file mode 100644 index 0000000..0bbb471 --- /dev/null +++ b/tests/test_log_stream.py @@ -0,0 +1,117 @@ +"""Log-Ziel: der Dienst loggt nach stdout, nicht nach stderr. + +README und docs/INSTALLATION.md versprechen stdout. `logging.basicConfig()` +ohne `stream=` nimmt aber stderr. Für journald ist das egal, für den in der +Doku beschriebenen Vordergrund-Notbehelf und für jede Weiterleitung der +Ausgabe nicht. + +Gleichzeitig muss die Trennung in `--check-config` bleiben: Infos und +Warnungen nach stdout, Fehler nach stderr. +""" +from __future__ import annotations + +import io +import logging +import sys +from contextlib import contextmanager +from pathlib import Path +from unittest.mock import patch + +from pdf_ocr_hotfolder.__main__ import _setup_logging, main + + +@contextmanager +def _fresh_root_logger(): + """Root-Logger wie beim echten Dienststart: ohne Handler. + + `logging.basicConfig()` tut nichts, solange der Root-Logger Handler hat — + und pytest hängt seinen Capture-Handler dort ein, nachdem die Fixtures + gelaufen sind. Deshalb erst hier, direkt um den Aufruf herum, leeren. + """ + root = logging.getLogger() + saved_handlers, saved_level = root.handlers[:], root.level + root.handlers = [] + try: + yield root + finally: + for h in root.handlers: + h.close() + root.handlers = saved_handlers + root.level = saved_level + + +def test_setup_logging_uses_stdout() -> None: + with _fresh_root_logger() as root: + _setup_logging("INFO") + streams = [h.stream for h in root.handlers + if isinstance(h, logging.StreamHandler)] + assert streams, "kein StreamHandler konfiguriert" + assert all(s is sys.stdout for s in streams) + assert not any(s is sys.stderr for s in streams) + + +def test_log_records_land_on_stdout(monkeypatch) -> None: + """Ein echter Log-Satz muss im stdout-Puffer stehen, nicht im stderr.""" + out, err = io.StringIO(), io.StringIO() + monkeypatch.setattr(sys, "stdout", out) + monkeypatch.setattr(sys, "stderr", err) + + with _fresh_root_logger(): + _setup_logging("INFO") + logging.getLogger("pdf_ocr_hotfolder.test").warning("Testmeldung 4711") + + assert "Testmeldung 4711" in out.getvalue() + assert "Testmeldung 4711" not in err.getvalue() + + +def test_setup_logging_respects_level() -> None: + with _fresh_root_logger() as root: + _setup_logging("WARNING") + assert root.level == logging.WARNING + + +def test_setup_logging_falls_back_on_garbage_level() -> None: + """Ein Tippfehler in [logging].level darf den Start nicht verhindern.""" + with _fresh_root_logger() as root: + _setup_logging("LAUT") + assert root.level == logging.INFO + + +# ---------------- Trennung in --check-config ---------------- + +def _cfg(tmp_path: Path) -> Path: + cfg = tmp_path / "cfg.toml" + cfg.write_text(f""" +[paths] +incoming = "{tmp_path / 'in'}" +outgoing = "{tmp_path / 'out'}" +working = "{tmp_path / 'work'}" +error = "{tmp_path / 'err'}" +""") + return cfg + + +def test_check_config_keeps_info_on_stdout(tmp_path: Path, monkeypatch, + capsys) -> None: + monkeypatch.setattr(sys, "argv", + ["pdf-ocr-hotfolder", "--config", str(_cfg(tmp_path)), + "--check-config"]) + with patch("pdf_ocr_hotfolder.service.shutil.which", + return_value="/usr/bin/fake"): + assert main() == 0 + captured = capsys.readouterr() + assert "Config sauber" in captured.out + assert captured.err == "" + + +def test_check_config_keeps_errors_on_stderr(tmp_path: Path, monkeypatch, + capsys) -> None: + cfg = tmp_path / "cfg.toml" + cfg.write_text('[ocr]\nlanguages = "deu"\n') + monkeypatch.setattr(sys, "argv", + ["pdf-ocr-hotfolder", "--config", str(cfg), + "--check-config"]) + assert main() == 2 + captured = capsys.readouterr() + assert "FEHLER" in captured.err + assert "FEHLER" not in captured.out diff --git a/tests/test_observer_watchdog.py b/tests/test_observer_watchdog.py new file mode 100644 index 0000000..bbf10ee --- /dev/null +++ b/tests/test_observer_watchdog.py @@ -0,0 +1,199 @@ +"""Punkt 7: Ein toter watchdog-Observer muss auffallen. + +Die Hauptschleife wartete nur auf `_stop` und fragte nie `is_alive()`. Stirbt +der Observer im Betrieb (erschöpftes inotify-Watch-Limit, ersetztes oder neu +gemountetes Verzeichnis), blieb die Unit `active (running)` und verarbeitete +nichts mehr — kein Log, keine Mail, niemand merkt es. + +Jetzt endet der Dienst mit `EXIT_OBSERVER_DEAD` (3), damit systemd ihn per +`Restart=on-failure` neu startet. Bewusst nicht 2: die Unit setzt +`RestartPreventExitStatus=2` für Config-/Preflight-Fehler. +""" +from __future__ import annotations + +import logging +import sys +import threading +from unittest.mock import MagicMock, patch + +from pdf_ocr_hotfolder.service import EXIT_OBSERVER_DEAD, HotfolderService + + +class _NoSleepEvent(threading.Event): + """Event, dessen wait() nicht schläft — hält die Tests schnell.""" + + def wait(self, timeout: float | None = None) -> bool: # noqa: D102 + return self.is_set() + + +class _StopAfter(_NoSleepEvent): + """Setzt sich nach n Warteschritten selbst — simuliert ein SIGTERM.""" + + def __init__(self, n: int) -> None: + super().__init__() + self._left = n + + def wait(self, timeout: float | None = None) -> bool: + self._left -= 1 + if self._left <= 0: + self.set() + return self.is_set() + + +def _service(tmp_config, stop: threading.Event, alive) -> HotfolderService: + service = HotfolderService(tmp_config) + service._stop = stop + observer = MagicMock() + if isinstance(alive, list): + observer.is_alive.side_effect = alive + else: + observer.is_alive.return_value = alive + service._observer = observer + return service + + +def _wait_loop(service: HotfolderService) -> int: + try: + return service._wait_loop() + finally: + service._executor.shutdown(wait=False) + + +# ---------------- _wait_loop ---------------- + +def test_dead_observer_returns_exit_code(tmp_config) -> None: + service = _service(tmp_config, _NoSleepEvent(), alive=False) + assert _wait_loop(service) == EXIT_OBSERVER_DEAD + + +def test_exit_code_is_not_2(tmp_config) -> None: + """2 ist für Config-/Preflight-Fehler reserviert (RestartPreventExitStatus).""" + assert EXIT_OBSERVER_DEAD != 2 + assert EXIT_OBSERVER_DEAD != 0 + + +def test_dead_observer_logs_clearly(tmp_config, caplog) -> None: + service = _service(tmp_config, _NoSleepEvent(), alive=False) + with caplog.at_level(logging.ERROR, logger="pdf_ocr_hotfolder.service"): + _wait_loop(service) + + text = caplog.text + assert str(tmp_config.paths.incoming) in text + assert "KEINE neuen Dateien" in text + assert "inotify" in text + + +def test_observer_is_checked_repeatedly(tmp_config) -> None: + """Der Observer wird nicht nur einmal beim Start geprüft.""" + service = _service(tmp_config, _NoSleepEvent(), + alive=[True, True, True, False]) + assert _wait_loop(service) == EXIT_OBSERVER_DEAD + assert service._observer.is_alive.call_count == 4 + + +def test_regular_stop_returns_zero(tmp_config) -> None: + """SIGTERM/SIGINT bei lebendem Observer: kein Fehlalarm.""" + service = _service(tmp_config, _StopAfter(3), alive=True) + assert _wait_loop(service) == 0 + + +def test_stop_wins_over_dead_observer(tmp_config) -> None: + """Beim planmäßigen Stoppen darf ein gestoppter Observer nichts auslösen. + + `shutdown()` stoppt den Observer — läuft die Schleife danach noch einen + Takt, wäre das sonst ein Fehlalarm mit Exit 3 beim normalen Beenden. + """ + stop = _NoSleepEvent() + stop.set() + service = _service(tmp_config, stop, alive=False) + assert _wait_loop(service) == 0 + service._observer.is_alive.assert_not_called() + + +def test_missing_observer_does_not_crash(tmp_config) -> None: + service = HotfolderService(tmp_config) + service._stop = _StopAfter(2) + service._observer = None + assert _wait_loop(service) == 0 + + +# ---------------- run() / main() reichen den Code durch ---------------- + +def test_run_returns_exit_code(tmp_config) -> None: + observer = MagicMock() + observer.is_alive.return_value = False + service = HotfolderService(tmp_config) + service._stop = _NoSleepEvent() + try: + with patch("pdf_ocr_hotfolder.service.Observer", return_value=observer), \ + patch("pdf_ocr_hotfolder.service.check_preflight"): + assert service.run() == EXIT_OBSERVER_DEAD + finally: + service._executor.shutdown(wait=False) + # Auch im Fehlerfall wird sauber heruntergefahren + observer.stop.assert_called_once() + + +def test_run_returns_zero_on_regular_stop(tmp_config) -> None: + observer = MagicMock() + observer.is_alive.return_value = True + service = HotfolderService(tmp_config) + service._stop = _StopAfter(2) + try: + with patch("pdf_ocr_hotfolder.service.Observer", return_value=observer), \ + patch("pdf_ocr_hotfolder.service.check_preflight"): + assert service.run() == 0 + finally: + service._executor.shutdown(wait=False) + + +def test_main_passes_exit_code_through(tmp_path, tmp_config, monkeypatch) -> None: + from pdf_ocr_hotfolder.__main__ import 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}" +""") + monkeypatch.setattr(sys, "argv", + ["pdf-ocr-hotfolder", "--config", str(cfg_file)]) + with patch.object(HotfolderService, "run", return_value=EXIT_OBSERVER_DEAD): + assert main() == EXIT_OBSERVER_DEAD + + +def test_main_returns_zero_on_regular_stop(tmp_path, tmp_config, monkeypatch) -> None: + from pdf_ocr_hotfolder.__main__ import 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}" +""") + monkeypatch.setattr(sys, "argv", + ["pdf-ocr-hotfolder", "--config", str(cfg_file)]) + with patch.object(HotfolderService, "run", return_value=0): + assert main() == 0 + + +def test_main_returns_zero_on_keyboard_interrupt(tmp_path, tmp_config, + monkeypatch) -> None: + from pdf_ocr_hotfolder.__main__ import 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}" +""") + monkeypatch.setattr(sys, "argv", + ["pdf-ocr-hotfolder", "--config", str(cfg_file)]) + with patch.object(HotfolderService, "run", side_effect=KeyboardInterrupt): + assert main() == 0 diff --git a/tests/test_outgoing_collision.py b/tests/test_outgoing_collision.py new file mode 100644 index 0000000..917be2a --- /dev/null +++ b/tests/test_outgoing_collision.py @@ -0,0 +1,185 @@ +"""Namens-Kollision in outgoing/ darf kein Ergebnis mehr überschreiben. + +`process_pdf()` beendete mit `shutil.move(work_out, final_out)`. Lag dort +bereits eine Datei desselben Namens (Scanner liefert denselben Dateinamen ein +zweites Mal, oder das Vorgängerergebnis wurde noch nicht abgeholt), war das +ältere Ergebnis kommentarlos weg. Jetzt gilt derselbe Zeitstempel-Ausweg wie +im Archiv. +""" +from __future__ import annotations + +import logging +from pathlib import Path +from unittest.mock import patch + +import pytest + +from pdf_ocr_hotfolder.config import OcrConfig, OutputConfig, VeraPdfConfig +from pdf_ocr_hotfolder.processor import _collision_free_path, process_pdf + +OLD = b"%PDF-1.4 altes ergebnis\n" +ORIGINAL = b"%PDF-1.4 original\n" + + +def _fake_ocr(src: Path, dst: Path, cfg: OcrConfig) -> None: + dst.write_bytes(b"%PDF-1.4 OCRed\n" + src.read_bytes()) + + +def _prepare(tmp_path: Path) -> dict: + dirs = {name: tmp_path / name + for name in ("incoming", "working", "outgoing", "error", "archive")} + for d in dirs.values(): + d.mkdir(parents=True, exist_ok=True) + src = dirs["incoming"] / "scan.pdf" + src.write_bytes(ORIGINAL) + return {"src": src, **dirs} + + +def _run(env: dict, out_cfg: OutputConfig): + with patch("pdf_ocr_hotfolder.processor.run_ocr", side_effect=_fake_ocr): + return process_pdf( + src=env["src"], + working_dir=env["working"], + outgoing_dir=env["outgoing"], + error_dir=env["error"], + ocr_cfg=OcrConfig(), + vera_cfg=VeraPdfConfig(enabled=False), + output_cfg=out_cfg, + ) + + +# ---------------- Kollision in outgoing/ ---------------- + +def test_existing_result_is_not_overwritten(tmp_path: Path) -> None: + """Beide Dateien müssen hinterher existieren.""" + env = _prepare(tmp_path) + (env["outgoing"] / "OCR_scan.pdf").write_bytes(OLD) + + result = _run(env, OutputConfig(name_mode="prefix", name_tag="OCR_", + original_on_success="delete")) + + assert result.success + # Altes Ergebnis unverändert + assert (env["outgoing"] / "OCR_scan.pdf").read_bytes() == OLD + # Neues Ergebnis unter Zeitstempel-Namen daneben + neu = [p for p in env["outgoing"].glob("OCR_scan_*.pdf")] + assert len(neu) == 1 + assert neu[0].read_bytes() == b"%PDF-1.4 OCRed\n" + ORIGINAL + assert len(list(env["outgoing"].iterdir())) == 2 + + +def test_result_output_points_to_written_file(tmp_path: Path) -> None: + """ProcessResult.output muss den TATSÄCHLICH geschriebenen Pfad tragen. + + Sonst melden Uploads und die E-Mail-Benachrichtigung die falsche (nämlich + die fremde, ältere) Datei. + """ + env = _prepare(tmp_path) + (env["outgoing"] / "OCR_scan.pdf").write_bytes(OLD) + + result = _run(env, OutputConfig(name_mode="prefix", name_tag="OCR_", + original_on_success="delete")) + + assert result.output.exists() + assert result.output.name != "OCR_scan.pdf" + assert result.output.parent == env["outgoing"] + assert result.output.read_bytes() != OLD + + +def test_collision_logs_warning_with_both_names(tmp_path: Path, caplog) -> None: + env = _prepare(tmp_path) + (env["outgoing"] / "OCR_scan.pdf").write_bytes(OLD) + + with caplog.at_level(logging.WARNING, logger="pdf_ocr_hotfolder.processor"): + result = _run(env, OutputConfig(name_mode="prefix", name_tag="OCR_", + original_on_success="delete")) + + text = caplog.text + assert "OCR_scan.pdf" in text + assert result.output.name in text + assert "überschrieben" in text + + +def test_no_collision_keeps_plain_name(tmp_path: Path, caplog) -> None: + """Ohne Kollision bleibt alles wie bisher — kein Suffix, keine Warnung.""" + env = _prepare(tmp_path) + + with caplog.at_level(logging.WARNING, logger="pdf_ocr_hotfolder.processor"): + result = _run(env, OutputConfig(name_mode="prefix", name_tag="OCR_", + original_on_success="delete")) + + assert result.output == env["outgoing"] / "OCR_scan.pdf" + assert result.output.exists() + assert "überschrieben" not in caplog.text + + +def test_collision_with_name_mode_none(tmp_path: Path) -> None: + """name_mode='none': Ergebnis heißt wie das Original — Kollision ist dort + der Normalfall, nicht die Ausnahme.""" + env = _prepare(tmp_path) + (env["outgoing"] / "scan.pdf").write_bytes(OLD) + + result = _run(env, OutputConfig(name_mode="none", name_tag="", + original_on_success="delete")) + + assert result.success + assert (env["outgoing"] / "scan.pdf").read_bytes() == OLD + assert result.output.name.startswith("scan_") + assert result.output.suffix == ".pdf" + + +def test_original_is_still_disposed_after_collision(tmp_path: Path) -> None: + """Der Ausweichname darf die Entsorgung des Originals nicht aushebeln.""" + env = _prepare(tmp_path) + (env["outgoing"] / "OCR_scan.pdf").write_bytes(OLD) + + _run(env, OutputConfig(name_mode="prefix", name_tag="OCR_", + original_on_success="archive", + archive_dir=str(env["archive"]))) + + assert list(env["working"].iterdir()) == [] + assert (env["archive"] / "scan.pdf").read_bytes() == ORIGINAL + + +# ---------------- _collision_free_path ---------------- + +def test_collision_free_path_passes_through_free_name(tmp_path: Path) -> None: + dest = tmp_path / "frei.pdf" + assert _collision_free_path(dest) == dest + + +def test_collision_free_path_appends_timestamp(tmp_path: Path) -> None: + dest = tmp_path / "belegt.pdf" + dest.write_bytes(b"x") + out = _collision_free_path(dest) + assert out != dest + assert out.name.startswith("belegt_") + assert out.suffix == ".pdf" + assert not out.exists() + + +def test_collision_free_path_counts_up_within_same_second(tmp_path: Path) -> None: + """Zwei Ergebnisse in derselben Sekunde (mehrere Worker) kollidieren sonst + erneut — und der move überschriebe wieder still.""" + dest = tmp_path / "belegt.pdf" + dest.write_bytes(b"x") + + first = _collision_free_path(dest) + first.write_bytes(b"y") + with patch("pdf_ocr_hotfolder.processor.datetime") as dt: + # Zeitstempel einfrieren: erzwingt denselben Namen wie `first` + dt.now.return_value.strftime.return_value = first.stem.split("_", 1)[1] + second = _collision_free_path(dest) + + assert second != first + assert not second.exists() + assert second.suffix == ".pdf" + + +@pytest.mark.parametrize("name", ["ohne_extension", "zwei.punkte.pdf"]) +def test_collision_free_path_keeps_extension(tmp_path: Path, name: str) -> None: + dest = tmp_path / name + dest.write_bytes(b"x") + out = _collision_free_path(dest) + assert out.suffix == dest.suffix + assert out.name != dest.name diff --git a/tests/test_relative_paths.py b/tests/test_relative_paths.py new file mode 100644 index 0000000..5d36335 --- /dev/null +++ b/tests/test_relative_paths.py @@ -0,0 +1,111 @@ +"""Punkt 5: Relative Pfade in der Config sind ein Fehler, keine stille Annahme. + +`incoming = "in"` legte das Verzeichnis unter dem WorkingDirectory des +Dienstes an (/opt/pdf-ocr-hotfolder/in) statt dort, wo der Scanner ablegt — +ohne Warnung. Der Dienst schaute dann dauerhaft ins Leere. +""" +from __future__ import annotations + +import sys +from pathlib import Path + +import pytest + +from pdf_ocr_hotfolder.config import ConfigError, load_config + +_ABS = { + "incoming": "/var/lib/pdf-ocr-hotfolder/incoming", + "outgoing": "/var/lib/pdf-ocr-hotfolder/outgoing", + "working": "/var/lib/pdf-ocr-hotfolder/working", + "error": "/var/lib/pdf-ocr-hotfolder/error", +} + + +def _write(tmp_path: Path, paths: dict[str, str], extra: str = "") -> Path: + cfg = tmp_path / "config.toml" + zeilen = "\n".join(f'{k} = "{v}"' for k, v in paths.items()) + cfg.write_text(f"[paths]\n{zeilen}\n{extra}") + return cfg + + +@pytest.mark.parametrize("key", list(_ABS)) +def test_relative_path_key_is_rejected(tmp_path: Path, key: str) -> None: + paths = dict(_ABS) + paths[key] = "in" + with pytest.raises(ConfigError) as exc: + load_config(_write(tmp_path, paths)) + msg = str(exc.value) + assert key in msg + assert "absolut" in msg.lower() + assert "WorkingDirectory" in msg + + +def test_dot_relative_path_is_rejected(tmp_path: Path) -> None: + """Auch './scans' und '../scans' sind relativ.""" + paths = dict(_ABS, incoming="./scans") + with pytest.raises(ConfigError, match="incoming"): + load_config(_write(tmp_path, paths)) + + +def test_absolute_paths_load(tmp_path: Path) -> None: + cfg = load_config(_write(tmp_path, _ABS)) + assert cfg.paths.incoming == Path(_ABS["incoming"]) + + +def test_relative_archive_dir_is_rejected(tmp_path: Path) -> None: + cfg = _write(tmp_path, _ABS, + '\n[output]\noriginal_on_success = "archive"\n' + 'archive_dir = "archiv"\n') + with pytest.raises(ConfigError) as exc: + load_config(cfg) + assert "archive_dir" in str(exc.value) + + +def test_relative_upload_target_is_rejected(tmp_path: Path) -> None: + cfg = _write(tmp_path, _ABS, + '\n[upload.folder]\nenabled = true\ntarget = "fertig"\n') + with pytest.raises(ConfigError) as exc: + load_config(cfg) + assert "target" in str(exc.value) + + +def test_empty_archive_dir_and_target_are_fine(tmp_path: Path) -> None: + """Leer heißt 'nicht gesetzt' und bleibt erlaubt (das ist der Default).""" + cfg = load_config(_write(tmp_path, _ABS, + '\n[output]\narchive_dir = ""\n' + '\n[upload.folder]\ntarget = ""\n')) + assert cfg.output.archive_dir == "" + assert cfg.folder.target == "" + + +def test_absolute_archive_dir_and_target_load(tmp_path: Path) -> None: + cfg = load_config(_write(tmp_path, _ABS, + '\n[output]\narchive_dir = "/srv/archiv"\n' + '\n[upload.folder]\ntarget = "/srv/fertig"\n')) + assert cfg.output.archive_dir == "/srv/archiv" + assert cfg.folder.target == "/srv/fertig" + + +# ---------------- CLI ---------------- + +def test_main_returns_2_on_relative_path(tmp_path: Path, monkeypatch, capsys) -> None: + cfg = _write(tmp_path, dict(_ABS, incoming="in")) + monkeypatch.setattr(sys, "argv", + ["pdf-ocr-hotfolder", "--config", str(cfg), "--once"]) + from pdf_ocr_hotfolder.__main__ import main + assert main() == 2 + err = capsys.readouterr().err + assert "FEHLER" in err + assert "incoming" in err + + +def test_check_config_returns_2_on_relative_path(tmp_path: Path, monkeypatch, + capsys) -> None: + from pdf_ocr_hotfolder.__main__ import CHECK_ERROR, main + + cfg = _write(tmp_path, dict(_ABS, outgoing="raus")) + monkeypatch.setattr(sys, "argv", + ["pdf-ocr-hotfolder", "--config", str(cfg), + "--check-config"]) + assert main() == CHECK_ERROR + assert "outgoing" in capsys.readouterr().err diff --git a/tests/test_startup_toml_error.py b/tests/test_startup_toml_error.py new file mode 100644 index 0000000..78360cc --- /dev/null +++ b/tests/test_startup_toml_error.py @@ -0,0 +1,110 @@ +"""Kaputtes TOML beim NORMALEN Dienststart (nicht nur bei --check-config). + +`--check-config` fing `tomllib.TOMLDecodeError` schon immer ab, der Startpfad +in `main()` aber nicht: ein Tippfehler in der Instanz-Config ergab einen +nackten Traceback. Zusammen mit `Restart=on-failure` in der Unit lief die +Instanz damit in einen Neustart-Loop. +""" +from __future__ import annotations + +import sys +from pathlib import Path + +import pytest + +from pdf_ocr_hotfolder.__main__ import main + +# Verschiedene Arten, eine TOML kaputt zu machen +BROKEN_TOMLS = [ + pytest.param('[paths]\nincoming = "/tmp/in\n', id="unbalancierte-quotes"), + pytest.param("[paths\nincoming = \n", id="unvollstaendige-sektion"), + pytest.param('[paths]\nincoming "/tmp/in"\n', id="fehlendes-gleich"), + pytest.param('[paths]\nincoming = "/a"\n[paths]\nincoming = "/b"\n', + id="doppelte-sektion"), +] + + +def _run(monkeypatch, cfg: Path, *extra_args: str) -> int: + monkeypatch.setattr( + sys, "argv", + ["pdf-ocr-hotfolder", "--config", str(cfg), *extra_args], + ) + return main() + + +@pytest.mark.parametrize("content", BROKEN_TOMLS) +def test_broken_toml_returns_2_on_normal_start( + tmp_path: Path, monkeypatch, capsys, content: str) -> None: + """Dienststart ohne --once: Exit 2, keine Exception nach außen.""" + cfg = tmp_path / "instanz.toml" + cfg.write_text(content) + + assert _run(monkeypatch, cfg) == 2 + + err = capsys.readouterr().err + assert "FEHLER" in err + assert "TOML" in err + assert str(cfg) in err + + +@pytest.mark.parametrize("content", BROKEN_TOMLS) +def test_broken_toml_returns_2_with_once( + tmp_path: Path, monkeypatch, capsys, content: str) -> None: + """Auch --once darf nicht mit Traceback aussteigen.""" + cfg = tmp_path / "instanz.toml" + cfg.write_text(content) + + assert _run(monkeypatch, cfg, "--once") == 2 + assert "TOML" in capsys.readouterr().err + + +def test_broken_toml_message_names_position( + tmp_path: Path, monkeypatch, capsys) -> None: + """Die Meldung muss dem Kunden sagen, WO es klemmt. + + Zeile/Spalte kommen ab Python 3.14 aus den Exception-Attributen, darunter + stecken sie im Meldungstext von tomllib. Beide Wege müssen in der Ausgabe + landen. + """ + cfg = tmp_path / "instanz.toml" + cfg.write_text('[paths]\nincoming = "/tmp/in"\nworking = "/tmp/w\n') + + assert _run(monkeypatch, cfg) == 2 + + err = capsys.readouterr().err.lower() + assert "zeile 3" in err or "line 3" in err + + +def test_broken_toml_start_and_check_config_agree( + tmp_path: Path, monkeypatch, capsys) -> None: + """Startpfad und --check-config liefern denselben Exit-Code und Text.""" + cfg = tmp_path / "instanz.toml" + cfg.write_text('[paths]\nincoming = "/tmp/in\n') + + assert _run(monkeypatch, cfg) == 2 + start_err = capsys.readouterr().err + + assert _run(monkeypatch, cfg, "--check-config") == 2 + check_err = capsys.readouterr().err + + marker = f"{cfg} ist kein gültiges TOML" + assert marker in start_err + assert marker in check_err + + +def test_unreadable_config_returns_2(tmp_path: Path, monkeypatch, capsys) -> None: + """Config existiert, ist aber nicht lesbar → Exit 2 statt Traceback.""" + cfg = tmp_path / "instanz.toml" + cfg.write_text('[paths]\nincoming = "/tmp/in"\n') + cfg.chmod(0o000) + try: + # Als root greifen Dateirechte nicht — dann ist der Test gegenstandslos + try: + cfg.open("rb").close() + pytest.skip("Datei trotz chmod 000 lesbar (root?)") + except PermissionError: + pass + assert _run(monkeypatch, cfg) == 2 + assert "nicht lesbar" in capsys.readouterr().err + finally: + cfg.chmod(0o644) diff --git a/tests/test_upload_folder.py b/tests/test_upload_folder.py index 6bc8302..551a5ca 100644 --- a/tests/test_upload_folder.py +++ b/tests/test_upload_folder.py @@ -64,3 +64,71 @@ def test_upload_folder_reports_failure(tmp_path: Path) -> None: assert upload_folder(src, FolderUpload(enabled=True, target=str(tmp_path / "ziel")), tmp_path / "out") is False + + +# ---------------- Punkt 3: kein stilles Überschreiben im Ziel ---------------- + +def test_upload_folder_does_not_overwrite_existing(tmp_path: Path) -> None: + """Gleichnamige Datei im abweichenden target wurde bisher still ersetzt.""" + src = tmp_path / "out" / "OCR_scan.pdf" + src.parent.mkdir() + src.write_bytes(b"%PDF-1.4 neu\n") + target = tmp_path / "ziel" + target.mkdir() + (target / "OCR_scan.pdf").write_bytes(b"%PDF-1.4 alt\n") + + assert upload_folder(src, FolderUpload(enabled=True, target=str(target)), + tmp_path / "out") is True + + assert (target / "OCR_scan.pdf").read_bytes() == b"%PDF-1.4 alt\n" + neu = list(target.glob("OCR_scan_*.pdf")) + assert len(neu) == 1 + assert neu[0].read_bytes() == b"%PDF-1.4 neu\n" + + +def test_upload_folder_logs_warning_on_collision(tmp_path: Path, caplog) -> None: + import logging + + src = tmp_path / "out" / "OCR_scan.pdf" + src.parent.mkdir() + src.write_bytes(b"%PDF-1.4 neu\n") + target = tmp_path / "ziel" + target.mkdir() + (target / "OCR_scan.pdf").write_bytes(b"%PDF-1.4 alt\n") + + with caplog.at_level(logging.WARNING, logger="pdf_ocr_hotfolder.uploaders"): + upload_folder(src, FolderUpload(enabled=True, target=str(target)), + tmp_path / "out") + + assert "OCR_scan.pdf" in caplog.text + assert "überschrieben" in caplog.text + + +def test_upload_folder_without_collision_logs_nothing(tmp_path: Path, caplog) -> None: + import logging + + src = tmp_path / "out" / "OCR_scan.pdf" + src.parent.mkdir() + src.write_bytes(b"%PDF-1.4\n") + + with caplog.at_level(logging.WARNING, logger="pdf_ocr_hotfolder.uploaders"): + upload_folder(src, FolderUpload(enabled=True, target=str(tmp_path / "ziel")), + tmp_path / "out") + + assert "überschrieben" not in caplog.text + assert (tmp_path / "ziel" / "OCR_scan.pdf").exists() + + +def test_upload_folder_self_target_is_not_renamed(tmp_path: Path) -> None: + """Default (leeres target -> outgoing/): der resolve()-Kurzschluss greift. + + Ohne ihn würde die Datei hier gegen sich selbst kollidieren und eine + Zeitstempel-Kopie neben sich selbst erzeugen. + """ + out = tmp_path / "out" + out.mkdir() + src = out / "OCR_scan.pdf" + src.write_bytes(b"%PDF-1.4\n") + + assert upload_folder(src, FolderUpload(enabled=True, target=""), out) is True + assert list(out.iterdir()) == [src] diff --git a/tests/test_verapdf_preflight.py b/tests/test_verapdf_preflight.py new file mode 100644 index 0000000..9e320dc --- /dev/null +++ b/tests/test_verapdf_preflight.py @@ -0,0 +1,290 @@ +"""Punkt 1: veraPDF-Binary wird geprüft — sonst vernichtet es die Originale. + +Mit `[verapdf].enabled = true` und falschem Pfad lieferte `run_verapdf()` für +JEDE Datei False: OCR-Ergebnis nach error/, Original laut +`original_on_success = "delete"` gelöscht. Ein Tippfehler im Pfad vernichtete +so Scan für Scan die Vorlagen, während die Unit als "läuft" dastand. + +Zwei Absicherungen: +1. Der Preflight lässt den Dienst gar nicht erst starten (Exit 2). +2. `run_verapdf()` unterscheidet "nicht konform" (False) von "nicht + aufrufbar" (`VeraPdfUnavailable`) — Letzteres entsorgt nichts. +""" +from __future__ import annotations + +import subprocess +import sys +from pathlib import Path +from unittest.mock import patch + +import pytest + +from pdf_ocr_hotfolder.config import OcrConfig, OutputConfig, VeraPdfConfig +from pdf_ocr_hotfolder.processor import ( + VeraPdfUnavailable, + process_pdf, + resolve_verapdf_binary, + run_verapdf, +) +from pdf_ocr_hotfolder.service import ( + HotfolderService, + PreflightError, + check_preflight, + check_verapdf_binary, +) + +ORIGINAL = b"%PDF-1.4 original\n" + + +def _executable(tmp_path: Path, name: str = "verapdf") -> Path: + """Legt eine echte, ausführbare Datei an (wird nie wirklich aufgerufen).""" + b = tmp_path / name + b.write_text("#!/bin/sh\nexit 0\n") + b.chmod(0o755) + return b + + +# ---------------- check_verapdf_binary ---------------- + +def test_disabled_verapdf_ignores_binary() -> None: + """Solange veraPDF aus ist, darf ein unsinniger Pfad nichts blockieren.""" + check_verapdf_binary(False, "/gibt/es/nicht/verapdf") + + +def test_existing_executable_passes(tmp_path: Path) -> None: + check_verapdf_binary(True, str(_executable(tmp_path))) + + +def test_missing_binary_raises(tmp_path: Path) -> None: + with pytest.raises(PreflightError) as exc: + check_verapdf_binary(True, str(tmp_path / "tippfehler")) + msg = str(exc.value) + assert "verapdf" in msg.lower() + assert "tippfehler" in msg + + +def test_non_executable_binary_raises(tmp_path: Path) -> None: + """Vorhanden, aber ohne x-Bit: genauso tödlich wie gar nicht vorhanden.""" + b = tmp_path / "verapdf" + b.write_text("#!/bin/sh\n") + b.chmod(0o644) + with pytest.raises(PreflightError, match="ausführbar"): + check_verapdf_binary(True, str(b)) + + +def test_empty_binary_raises() -> None: + with pytest.raises(PreflightError, match=r"\[verapdf\].binary"): + check_verapdf_binary(True, "") + + +def test_resolve_finds_binary_in_path(tmp_path: Path, monkeypatch) -> None: + """Ein nackter Name wird im PATH gesucht, nicht nur ein absoluter Pfad.""" + _executable(tmp_path, "verapdf") + monkeypatch.setenv("PATH", str(tmp_path)) + assert resolve_verapdf_binary("verapdf") == str(tmp_path / "verapdf") + + +def test_resolve_returns_none_for_empty() -> None: + assert resolve_verapdf_binary("") is None + + +# ---------------- check_preflight reicht die veraPDF-Prüfung durch ---------------- + +def test_check_preflight_checks_verapdf(tmp_path: Path) -> None: + with patch("pdf_ocr_hotfolder.service.shutil.which", return_value="/usr/bin/fake"): + with pytest.raises(PreflightError, match="verapdf"): + check_preflight(verapdf_enabled=True, + verapdf_binary=str(tmp_path / "weg")) + + +def test_check_preflight_ok_with_verapdf(tmp_path: Path) -> None: + with patch("pdf_ocr_hotfolder.service.shutil.which", return_value="/usr/bin/fake"): + check_preflight(verapdf_enabled=True, + verapdf_binary=str(_executable(tmp_path))) + + +def test_run_once_aborts_on_broken_verapdf(tmp_config, tmp_path: Path) -> None: + """Der Dienst startet nicht — statt Datei für Datei Originale zu löschen.""" + tmp_config.verapdf = VeraPdfConfig(enabled=True, + binary=str(tmp_path / "gibtsnicht")) + service = HotfolderService(tmp_config) + try: + with patch("pdf_ocr_hotfolder.service.shutil.which", + return_value="/usr/bin/fake"): + with pytest.raises(PreflightError, match="verapdf"): + service.run_once() + finally: + service._executor.shutdown(wait=False) + + +def test_check_config_returns_2_for_broken_verapdf(tmp_path, tmp_config, + monkeypatch, capsys) -> None: + """--check-config meldet Exit 2 (der Updater wertet das aus).""" + 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}" + +[verapdf] +enabled = true +binary = "{tmp_path / 'nicht-da'}" +""") + monkeypatch.setattr(sys, "argv", + ["pdf-ocr-hotfolder", "--config", str(cfg_file), + "--check-config"]) + with patch("pdf_ocr_hotfolder.service.shutil.which", return_value="/usr/bin/fake"): + assert main() == CHECK_ERROR + assert "nicht-da" in capsys.readouterr().err + + +def test_check_config_ok_with_working_verapdf(tmp_path, tmp_config, + monkeypatch) -> None: + from pdf_ocr_hotfolder.__main__ import CHECK_OK, 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}" + +[verapdf] +enabled = true +binary = "{_executable(tmp_path)}" +""") + monkeypatch.setattr(sys, "argv", + ["pdf-ocr-hotfolder", "--config", str(cfg_file), + "--check-config"]) + with patch("pdf_ocr_hotfolder.service.shutil.which", return_value="/usr/bin/fake"): + assert main() == CHECK_OK + + +# ---------------- run_verapdf: Urteil vs. Nicht-Aufrufbarkeit ---------------- + +def _completed(returncode: int, stdout: str = "", stderr: str = ""): + return subprocess.CompletedProcess([], returncode, stdout, stderr) + + +def test_run_verapdf_pass(tmp_path: Path) -> None: + cfg = VeraPdfConfig(enabled=True, binary=str(_executable(tmp_path))) + with patch("pdf_ocr_hotfolder.processor.subprocess.run", + return_value=_completed(0, "PASS /tmp/x.pdf\n")): + assert run_verapdf(tmp_path / "x.pdf", cfg) is True + + +def test_run_verapdf_fail_is_a_verdict(tmp_path: Path) -> None: + """Echtes FAIL bleibt False — das ist ein inhaltliches Urteil.""" + cfg = VeraPdfConfig(enabled=True, binary=str(_executable(tmp_path))) + with patch("pdf_ocr_hotfolder.processor.subprocess.run", + return_value=_completed(1, "FAIL /tmp/x.pdf\n")): + assert run_verapdf(tmp_path / "x.pdf", cfg) is False + + +def test_run_verapdf_missing_binary_raises(tmp_path: Path) -> None: + """Fehlendes Programm ist KEIN FAIL mehr, sondern ein Fehler.""" + cfg = VeraPdfConfig(enabled=True, binary=str(tmp_path / "weg")) + with pytest.raises(VeraPdfUnavailable, match="weg"): + run_verapdf(tmp_path / "x.pdf", cfg) + + +def test_run_verapdf_timeout_raises(tmp_path: Path) -> None: + cfg = VeraPdfConfig(enabled=True, binary=str(_executable(tmp_path))) + with patch("pdf_ocr_hotfolder.processor.subprocess.run", + side_effect=subprocess.TimeoutExpired("verapdf", 300)): + with pytest.raises(VeraPdfUnavailable, match="nicht geantwortet"): + run_verapdf(tmp_path / "x.pdf", cfg) + + +def test_run_verapdf_oserror_raises(tmp_path: Path) -> None: + cfg = VeraPdfConfig(enabled=True, binary=str(_executable(tmp_path))) + with patch("pdf_ocr_hotfolder.processor.subprocess.run", + side_effect=OSError("Exec format error")): + with pytest.raises(VeraPdfUnavailable, match="nicht startbar"): + run_verapdf(tmp_path / "x.pdf", cfg) + + +def test_run_verapdf_without_verdict_raises(tmp_path: Path) -> None: + """Startet der Wrapper nicht durch (fehlendes Java), steht kein Urteil da. + + Exit != 0 ohne PASS/FAIL in der Ausgabe darf nicht als "nicht konform" + durchgehen — genau so würde der Original-Löschpfad wieder aufgehen. + """ + cfg = VeraPdfConfig(enabled=True, binary=str(_executable(tmp_path))) + with patch("pdf_ocr_hotfolder.processor.subprocess.run", + return_value=_completed(127, "", "java: command not found")): + with pytest.raises(VeraPdfUnavailable, match="kein Urteil"): + run_verapdf(tmp_path / "x.pdf", cfg) + + +def test_run_verapdf_disabled_returns_true(tmp_path: Path) -> None: + assert run_verapdf(tmp_path / "x.pdf", VeraPdfConfig(enabled=False)) is True + + +# ---------------- process_pdf: nicht aufrufbares veraPDF entsorgt nichts ---------------- + +def _fake_ocr(src: Path, dst: Path, cfg: OcrConfig) -> None: + dst.write_bytes(b"%PDF-1.4 OCRed\n") + + +def _prepare(tmp_path: Path) -> dict: + dirs = {name: tmp_path / name + for name in ("incoming", "working", "outgoing", "error", "archive")} + for d in dirs.values(): + d.mkdir(parents=True, exist_ok=True) + src = dirs["incoming"] / "scan.pdf" + src.write_bytes(ORIGINAL) + return {"src": src, **dirs} + + +def _run_unavailable(env: dict, out_cfg: OutputConfig): + with patch("pdf_ocr_hotfolder.processor.run_ocr", side_effect=_fake_ocr), \ + patch("pdf_ocr_hotfolder.processor.run_verapdf", + side_effect=VeraPdfUnavailable("Binary weg")): + return process_pdf( + src=env["src"], + working_dir=env["working"], + outgoing_dir=env["outgoing"], + error_dir=env["error"], + ocr_cfg=OcrConfig(), + vera_cfg=VeraPdfConfig(enabled=True), + output_cfg=out_cfg, + ) + + +def test_unavailable_verapdf_keeps_original_despite_delete(tmp_path: Path) -> None: + """Der gefährliche Fall: original_on_success='delete' darf nicht greifen.""" + env = _prepare(tmp_path) + result = _run_unavailable(env, OutputConfig(name_mode="prefix", + name_tag="OCR_", + original_on_success="delete")) + + assert not result.success + # Original ist NICHT weg, sondern in error/ gesichert + gesichert = env["error"] / "scan.pdf" + assert gesichert.exists() + assert gesichert.read_bytes() == ORIGINAL + assert not (env["working"] / "scan.pdf").exists() + # Kein fertiges Ergebnis in outgoing/ + assert list(env["outgoing"].iterdir()) == [] + + +def test_unavailable_verapdf_result_is_not_a_fail_verdict(tmp_path: Path) -> None: + """verapdf_passed bleibt None: es gab kein Urteil, nur einen Fehler.""" + env = _prepare(tmp_path) + result = _run_unavailable(env, OutputConfig(original_on_success="delete")) + assert result.verapdf_passed is None + assert "veraPDF" in result.error + + +def test_unavailable_verapdf_also_keeps_ocr_result(tmp_path: Path) -> None: + env = _prepare(tmp_path) + _run_unavailable(env, OutputConfig(name_mode="prefix", name_tag="OCR_", + original_on_success="delete")) + assert (env["error"] / "__ocr_OCR_scan.pdf").exists() + assert list(env["working"].iterdir()) == [] diff --git a/update.sh b/update.sh index b49624e..e04ead1 100755 --- a/update.sh +++ b/update.sh @@ -18,17 +18,40 @@ # set -Eeuo pipefail -RED='\033[0;31m'; GREEN='\033[0;32m'; YELLOW='\033[1;33m'; BLUE='\033[0;34m'; NC='\033[0m' -log_info() { echo -e "${GREEN}[INFO]${NC} $*"; } -log_warn() { echo -e "${YELLOW}[WARN]${NC} $*"; } -log_error() { echo -e "${RED}[ERROR]${NC} $*"; } -log_step() { echo -e "\n${BLUE}==>${NC} $*"; } +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + +# ============================================================ +# Gemeinsame Funktionen (Logging, Pfade, venv-Pruefung, Paketliste) +# ============================================================ +# Relativ zum Skript aufgeloest, nicht zum Arbeitsverzeichnis — update.sh wird +# auch mit absolutem Pfad aufgerufen. Zuerst neben diesem Skript suchen (Repo), +# danach in der Installation, wohin install.sh/update.sh die Datei mitkopieren. +# Fehlt sie ueberall, ist hier Schluss: ein "command not found" mitten im Lauf +# waere die schlechtere Nachricht. +COMMON_LIB="" +for _pdf_ocr_cand in \ + "$SCRIPT_DIR/lib/common.sh" \ + "${INSTALL_DIR:-/opt/pdf-ocr-hotfolder}/lib/common.sh" +do + if [ -r "$_pdf_ocr_cand" ]; then + COMMON_LIB="$_pdf_ocr_cand" + break + fi +done +unset _pdf_ocr_cand +if [ -z "$COMMON_LIB" ]; then + echo "[ERROR] Gemeinsame Funktionsbibliothek lib/common.sh nicht gefunden." >&2 + echo " Gesucht in: $SCRIPT_DIR/lib/ und ${INSTALL_DIR:-/opt/pdf-ocr-hotfolder}/lib/" >&2 + echo " update.sh aus dem Repo ausfuehren (dort liegt lib/common.sh)." >&2 + # shellcheck disable=SC2317 # 'exit' greift nur, wenn das Skript nicht gesourct wurde + return 1 2>/dev/null || exit 1 +fi +# shellcheck source=lib/common.sh +. "$COMMON_LIB" # Pfade sind ueberschreibbar, damit die Funktionen dieses Skripts isoliert # gegen eine Fake-Umgebung getestet werden koennen (siehe LIB_ONLY unten). -: "${INSTALL_DIR:=/opt/pdf-ocr-hotfolder}" -: "${CONFIG_DIR:=/etc/pdf-ocr-hotfolder}" -: "${SYSTEMD_DIR:=/etc/systemd/system}" +# INSTALL_DIR/CONFIG_DIR/SYSTEMD_DIR und die Unit-Namen kommen aus lib/common.sh. : "${BACKUP_DIR:=/var/backups/pdf-ocr-hotfolder}" : "${TAR_ROOT:=/}" : "${VERIFY_WAIT:=6}" @@ -37,17 +60,14 @@ log_step() { echo -e "\n${BLUE}==>${NC} $*"; } # 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" -LXC_DROPIN="$LXC_DROPIN_DIR/lxc-compat.conf" REBUILD_VENV=0 # per --rebuild-venv erzwungen VENV_REBUILT=0 # wurde tatsaechlich neu gebaut? APT_WARN=0 # apt-Sync hat gemeckert LXC_SYNCED=0 BACKUP_FILE="" -PRIMARY_USER="pdfocr" +PRIMARY_USER="$DEFAULT_USER" TOUCHED=0 # 1, sobald auf der Platte etwas getauscht wurde SMOKE_TEST=1 # per --no-smoke-test abschaltbar @@ -156,61 +176,9 @@ die() { log_error "$*"; abort_handler 1 "${BASH_LINENO[0]:-?}"; } # ============================================================ # Python / venv # ============================================================ - -# major.minor des uebergebenen Interpreters; leer, wenn er nicht laeuft. -py_mm() { - local py="$1" - "$py" -c 'import sys; print("%d.%d" % sys.version_info[:2])' 2>/dev/null || true -} - -# major.minor aus pyvenv.cfg (version = / version_info =); leer, wenn unlesbar. -pyvenv_cfg_mm() { - local cfg="$1/pyvenv.cfg" - [ -f "$cfg" ] || return 0 - sed -n 's/^[[:space:]]*version\(_info\)\?[[:space:]]*=[[:space:]]*\([0-9]\+\.[0-9]\+\).*/\2/p' "$cfg" | head -n1 -} - -# Prueft die venv gegen das aktuelle System-Python. -# Setzt VENV_ISSUES (Array) und gibt 0 zurueck, wenn alles passt. -VENV_ISSUES=() -venv_is_healthy() { - local venv="$1" - local sys_mm venv_mm cfg_mm - VENV_ISSUES=() - - if [ ! -d "$venv" ]; then - VENV_ISSUES+=("venv-Verzeichnis fehlt: $venv") - return 1 - fi - if [ ! -x "$venv/bin/python" ]; then - VENV_ISSUES+=("$venv/bin/python fehlt oder ist nicht ausfuehrbar") - return 1 - fi - - venv_mm="$(py_mm "$venv/bin/python")" - if [ -z "$venv_mm" ]; then - VENV_ISSUES+=("$venv/bin/python laeuft nicht (toter Symlink nach einem Distributions-Upgrade?)") - return 1 - fi - - sys_mm="$(py_mm "$(command -v python3 || echo /usr/bin/python3)")" - if [ -z "$sys_mm" ]; then - VENV_ISSUES+=("System-python3 laeuft nicht — venv-Pruefung nicht moeglich") - return 1 - fi - - if [ "$venv_mm" != "$sys_mm" ]; then - VENV_ISSUES+=("venv haengt an Python $venv_mm, das System liefert Python $sys_mm") - return 1 - fi - - cfg_mm="$(pyvenv_cfg_mm "$venv")" - if [ -n "$cfg_mm" ] && [ "$cfg_mm" != "$venv_mm" ]; then - VENV_ISSUES+=("pyvenv.cfg nennt Python $cfg_mm, der Interpreter meldet $venv_mm") - return 1 - fi - return 0 -} +# +# py_mm(), pyvenv_cfg_mm() und venv_is_healthy() stehen in lib/common.sh — +# install.sh braucht dieselbe Pruefung. # Installiert die Requirements und uebersetzt pip-Fehler in eine Ansage, mit # der man etwas anfangen kann (typisch: Pin passt nicht mehr zum Python). @@ -388,21 +356,26 @@ rebuild_venv() { # System-Pakete # ============================================================ -# Holt die Paketliste aus install.sh (einzige Quelle) und installiert sie. +# Holt die Paketliste aus lib/common.sh (einzige Quelle) und installiert sie. # apt-get install ist idempotent; bereits vorhandene Pakete bleiben unberuehrt. # Nachinstallierte Tesseract-Sprachpakete werden NICHT angefasst (kein purge, # kein autoremove). +# +# Die Liste wird bewusst noch einmal aus der REPO-Fassung herausgeschnitten: +# gesourct wurde evtl. die aeltere Kopie aus der Installation, beim Update +# soll aber die neue Liste aus dem Repo gelten. sync_system_packages() { - local block pkgs + local block pkgs repo_lib log_step "System-Pakete abgleichen" - block="$(sed -n '/^# --- BEGIN apt-packages/,/^# --- END apt-packages/p' "$REPO_DIR/install.sh" 2>/dev/null || true)" - if [ -z "$block" ] || ! printf '%s' "$block" | grep -q 'pdf_ocr_apt_packages()'; then - log_warn "Paketliste in install.sh nicht gefunden — System-Pakete werden nicht abgeglichen." + repo_lib="$REPO_DIR/$COMMON_LIB_REL" + block="$(sed -n '/^# --- BEGIN apt-packages/,/^# --- END apt-packages/p' "$repo_lib" 2>/dev/null || true)" + if [ -n "$block" ] && printf '%s' "$block" | grep -q 'pdf_ocr_apt_packages()'; then + eval "$block" + else + log_warn "Paketliste in $repo_lib nicht gefunden — es gilt die geladene Fassung." APT_WARN=1 - return 0 fi - eval "$block" if ! declare -F pdf_ocr_apt_packages >/dev/null; then log_warn "pdf_ocr_apt_packages() liess sich nicht laden — uebersprungen." APT_WARN=1 @@ -984,7 +957,7 @@ create_backup() { BACKUP_FILE="$tmp" log_info "Backup: $tmp ($(du -h "$tmp" 2>/dev/null | awk '{print $1}'))" log_info " Enthalten: Code, $CONFIG_DIR, Template-Unit, Drop-ins, pip-freeze.txt" - log_info " NICHT enthalten: venv und die Datenverzeichnisse (/var/lib/pdf-ocr-hotfolder)" + log_info " NICHT enthalten: venv und die Datenverzeichnisse ($DATA_ROOT)" log_warn " Achtung: die Instanz-Configs enthalten Klartext-Passwoerter" log_warn " (SMTP/Nextcloud/SFTP) — Archiv ist deshalb root-only (0600)." rotate_backups @@ -1057,12 +1030,9 @@ while [ "$#" -gt 0 ]; do shift done -if [ "${EUID}" -ne 0 ]; then - log_error "Bitte als root ausfuehren: sudo ./update.sh" - exit 1 -fi +require_root "sudo ./update.sh" -SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +# SCRIPT_DIR steht schon ganz oben (fuer das Sourcen von lib/common.sh). if [ -f "$SCRIPT_DIR/pdf_ocr_hotfolder/__init__.py" ]; then REPO_DIR="$SCRIPT_DIR" elif [ -f "$INSTALL_DIR/.repo_path" ]; then @@ -1099,11 +1069,11 @@ report_instances # --- Eigentuemer des Codes merken (vor dem venv-Neubau) --- if [ -d "$INSTALL_DIR/venv" ]; then - PRIMARY_USER="$(stat -c '%U' "$INSTALL_DIR/venv" 2>/dev/null || echo pdfocr)" + PRIMARY_USER="$(stat -c '%U' "$INSTALL_DIR/venv" 2>/dev/null || echo "$DEFAULT_USER")" else - PRIMARY_USER="$(stat -c '%U' "$INSTALL_DIR" 2>/dev/null || echo pdfocr)" + PRIMARY_USER="$(stat -c '%U' "$INSTALL_DIR" 2>/dev/null || echo "$DEFAULT_USER")" fi -[ "$PRIMARY_USER" = "root" ] && PRIMARY_USER="pdfocr" +[ "$PRIMARY_USER" = "root" ] && PRIMARY_USER="$DEFAULT_USER" # --- System-Pakete (auch beim Update, nicht nur bei install.sh) --- sync_system_packages @@ -1118,9 +1088,7 @@ elif venv_is_healthy "$INSTALL_DIR/venv"; then log_info "venv ok ✓ (Python $(py_mm "$INSTALL_DIR/venv/bin/python"))" else log_warn "venv passt nicht mehr:" - for issue in "${VENV_ISSUES[@]}"; do - log_warn " - $issue" - done + report_venv_issues log_warn "Typische Ursache: Debian-Major-Upgrade (z.B. 12 -> 13). Die venv haengt" log_warn "am alten Interpreter, systemd quittiert das mit 203/EXEC." log_warn "-> venv wird neu gebaut." @@ -1145,6 +1113,10 @@ log_step "Code aktualisieren" TOUCHED=1 rm -rf "$INSTALL_DIR/pdf_ocr_hotfolder" cp -r "$REPO_DIR/pdf_ocr_hotfolder" "$INSTALL_DIR/" +# lib/ muss mit: ein spaeterer Lauf aus dem Installationsverzeichnis heraus +# sucht die gemeinsamen Funktionen genau dort. +rm -rf "${INSTALL_DIR:?}/lib" +cp -r "$REPO_DIR/lib" "$INSTALL_DIR/" cp "$REPO_DIR/requirements.txt" "$INSTALL_DIR/" cp "$REPO_DIR/VERSION" "$INSTALL_DIR/" cp "$REPO_DIR/config.example.toml" "$INSTALL_DIR/" @@ -1168,7 +1140,7 @@ rm -f "$DEP_BEFORE" "$DEP_AFTER" install_units log_step "Berechtigungen setzen" -# Eigentuemer des Codes bleibt der primaere User (pdfocr); Instanzen laufen +# Eigentuemer des Codes bleibt der primaere User (Vorgabe: pdfocr); Instanzen laufen # ggf. als anderer User, lesen aber nur den Code. chown -R "$PRIMARY_USER":"$PRIMARY_USER" "$INSTALL_DIR" log_info "Eigentuemer: $PRIMARY_USER"