diff --git a/AI_AGENT_BRIEFING.md b/AI_AGENT_BRIEFING.md index a37b41c..62ede18 100644 --- a/AI_AGENT_BRIEFING.md +++ b/AI_AGENT_BRIEFING.md @@ -1,8 +1,15 @@ # AI Agent Briefing — PDF OCR Hotfolder **Zuletzt aktualisiert:** 2026-09-22 -**Version:** 0.5.0 -**Status:** Multi-Instanz-Betrieb, Preflight-Checks und Fehlerzählung vorhanden, Test-Suite grün (95 pytest-Tests). Ein Produktiv-Einsatz ist im Repo (README/CHANGELOG) nicht dokumentiert — die bisherigen Fixes stammen aus Issues #1–#6, nicht aus einem belegten Dauerbetrieb. +**Version:** 0.6.0 +**Status:** Multi-Instanz-Betrieb, Preflight-Checks, Fehlerzählung, Wiederaufnahme aus `working/` und ein abgesicherter Updater (venv-Rebuild, Backup, Verifikation). Test-Suite grün (135 pytest-Tests). Ein Produktiv-Einsatz ist im Repo (README/CHANGELOG) nicht dokumentiert — die bisherigen Fixes stammen aus Issues #1–#6 und aus Vorbereitungen auf Debian 13, nicht aus einem belegten Dauerbetrieb. + +> **Betriebsabläufe stehen nicht hier**, sondern in: +> [README.md](README.md) (Einstieg, Layout, Config-Überblick) · +> [docs/INSTALLATION.md](docs/INSTALLATION.md) (Erstinstallation, Instanzen, Konfigurationsreferenz) · +> [docs/UPDATE.md](docs/UPDATE.md) (Update, Backup, Rollback, `--check-config`) · +> [docs/OS-UPGRADE.md](docs/OS-UPGRADE.md) (Debian-Major-Upgrade, venv-Rebuild, Pins). +> Dieses Briefing beschreibt **wie der Code aufgebaut ist und warum** — Schritt-für-Schritt-Anleitungen gehören in die drei Dokumente, nicht hierher. ## 🎯 Projektziel @@ -14,32 +21,40 @@ Eingehende gescannte PDFs werden automatisch durch OCR (ocrmypdf + Tesseract) in pdf-ocr-hotfolder/ ├── pdf_ocr_hotfolder/ │ ├── __init__.py # Versionsstring (__version__) -│ ├── __main__.py # CLI (argparse: --config, --once, --version); Exit 0/1/2 -│ ├── config.py # TOML-Loader, Dataclasses, ConfigError -│ ├── service.py # HotfolderService (watchdog + ThreadPool), Preflight, Zähler +│ ├── __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 │ └── uploaders.py # folder, nextcloud (WebDAV), sftp, E-Mail-Notify -├── tests/ # pytest-Suite (95 Tests, ocrmypdf wird gemockt) +├── tests/ # pytest-Suite (135 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_error_counting.py │ ├── test_ghostscript_version.py │ ├── test_ocr_timeout.py │ ├── test_once_exit_code.py │ ├── test_output_naming.py │ ├── test_preflight.py +│ ├── test_resume_working.py # Wiederaufnahme + __ocr_-Fragmente │ └── test_upload_folder.py ├── systemd/ -│ ├── pdf-ocr-hotfolder@.service # Template-Unit (Instanz = %i) +│ ├── pdf-ocr-hotfolder@.service # Template-Unit (Instanz = %i), TimeoutStopSec=300 │ └── lxc-compat.conf # Drop-in-Vorlage: Hardening für LXC abschalten +├── docs/ +│ ├── INSTALLATION.md # Erstinstallation + Konfigurationsreferenz +│ ├── 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 # Update aus Repo -├── requirements.txt +├── update.sh # Updater (--help, --rebuild-venv), ~860 Zeilen +├── requirements.txt # feste Pins (ocrmypdf 16.x!) ├── VERSION ├── CHANGELOG.md -└── README.md +├── README.md +└── AI_AGENT_BRIEFING.md ``` ## 🔧 Stack @@ -56,6 +71,12 @@ pdf-ocr-hotfolder/ | Tests | `pytest` | | Service | systemd (Template-Unit) | +Die vier Python-Deps sind in `requirements.txt` **fest gepinnt** (`==`), damit ein +Update nicht ungefragt einen Major-Sprung einzieht (ocrmypdf 16 → 17 würde alle +Instanzen auf einmal reißen). Geprüft gegen Python 3.11 (Debian 12) und 3.13 +(Debian 13), Wheels für beide vorhanden. Anheben nur mit Testmaschine — +[docs/OS-UPGRADE.md](docs/OS-UPGRADE.md#pins-in-requirementstxt). + ## 🖥️ Installations-Layout (Multi-Instanz) | Pfad | Inhalt | @@ -67,7 +88,7 @@ pdf-ocr-hotfolder/ | `/etc/systemd/system/pdf-ocr-hotfolder@.service.d/lxc-compat.conf` | Drop-in für Container (optional) | | `/etc/systemd/system/pdf-ocr-hotfolder@.service.d/user.conf` | Drop-in für abweichenden User (optional) | | `/var/lib/pdf-ocr-hotfolder//{incoming,working,outgoing,error}/` | Daten pro Instanz | -| `/var/backups/pdf-ocr-hotfolder/` | Update-Backups | +| `/var/backups/pdf-ocr-hotfolder/` | Update-Backups (0600, letzte 5) | Ein eigenes Logverzeichnis gibt es **nicht** (seit 0.4.1 auch nicht mehr vom Installer angelegt): `_setup_logging()` nutzt `logging.basicConfig()` ohne @@ -86,75 +107,87 @@ journalctl -u 'pdf-ocr-hotfolder@*' --since today # alle Instanzen, heute - Existierende User (lokal oder AD via SSSD/Winbind) werden übernommen, primäre Gruppe via `id -gn` ermittelt - Bei AD-Usern mit lokaler UID werden Datei-Berechtigungen über die UID gesetzt — transparent -## 🗂️ Instanz-Management +## 🗂️ Instanz-Management (Kurzfassung) -`install.sh` ist gleichzeitig **Installer und Instanz-Manager**: +`install.sh` ist gleichzeitig **Installer und Instanz-Manager**. Der komplette +Ablauf inklusive aller fünf Abfragen steht in +[docs/INSTALLATION.md](docs/INSTALLATION.md#die-abfragen-pro-instanz). Für die +Arbeit am Code zählt: -- Erster Lauf: Basis-Install + erste Instanz anlegen (Pflicht) -- Folgender Lauf: Basis-Install wird übersprungen (erkannt an `venv` + Template-Unit), bestehende Instanzen werden gelistet, weitere Instanzen können ergänzt werden -- Eingaben pro Instanz (seit 0.5.0 fünf statt drei): - 1. Name (`[a-z0-9][a-z0-9-]*`) - 2. Basis-Pfad (default `/var/lib/pdf-ocr-hotfolder/`) - 3. Service-User (default `pdfocr`) - 4. **OCR-Sprachen** (default `deu+eng`) — Format `^[a-z]{3}(_[A-Za-z]+)?(\+…)*$`, - bei Unsinn wird erneut gefragt. Jeder Code wird gegen `tesseract --list-langs` - geprüft; fehlt einer, bietet der Installer `tesseract-ocr-` an - (Unterstrich → Bindestrich, `chi_sim` → `tesseract-ocr-chi-sim`). Ablehnung - oder fehlgeschlagene Installation → Warnung, dass OCR mit dieser Sprache - **pro Datei** scheitert, und die Sprach-Abfrage beginnt von vorn (kein - harter Abbruch). Ist `tesseract` nicht aufrufbar, wird die Prüfung - übersprungen und die Eingabe unverändert übernommen. - 5. **Original nach erfolgreichem OCR archivieren?** (default **nein** → - `original_on_success = "delete"`). Bei ja wird der Archiv-Pfad abgefragt - (Vorschlag `$BASE/archive`, absoluter Pfad Pflicht), angelegt und auf - `$SVC_USER:$SVC_GROUP` gechownt — innerhalb von `$BASE` erledigt das - bestehende `chown -R` das schon, nur ein Archiv **außerhalb** bekommt ein - eigenes `chown -R`. -- **Sprachen sind bewusst instanz-lokal**, nicht global: ein Hotfolder - `buchhaltung` läuft mit `deu`, ein Hotfolder `export` mit `deu+eng+fra`. - `LANGS`/`ORIG_MODE`/`ARCHIVE_DIR` sind `local` in `create_instance()` — jeder - Durchlauf fragt neu, `deu+eng` ist nur der vorgeschlagene Default. Die Liste - gehört eng gehalten: jede zusätzliche Sprache kostet Laufzeit **und** - Erkennungsqualität. -- Basis-Install prüft zusätzlich die Ghostscript-Version und bietet auf Debian 12 bookworm-backports an; erkennt Container (`systemd-detect-virt --container`) und bietet das LXC-Drop-in an -- `.toml` wird aus `config.example.toml` per `sed` generiert. Substituiert - werden die vier `[paths]`-Zeilen **sowie** (seit 0.5.0) `[ocr].languages`, +- 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`). +- 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. +- Sprachprüfung gegen `tesseract --list-langs`, fehlende Pakete werden als + `tesseract-ocr-` angeboten (Unterstrich → Bindestrich). Ablehnung führt + nicht zum Abbruch, sondern zurück zur Sprach-Abfrage. +- Das Archiv-Verzeichnis darf **nicht** `incoming/`/`outgoing/`/`working/`/`error/` + sein — im Eingang würde das Original endlos neu aufgegriffen. +- `.toml` wird aus `config.example.toml` per `sed` erzeugt. Substituiert + werden die vier `[paths]`-Zeilen **sowie** `[ocr].languages`, `[output].original_on_success` und `[output].archive_dir`. Die Ausdrücke sind am Zeilenanfang verankert (`^key[[:space:]]*=`), damit die deutschen - Kommentarzeilen über den Keys nicht getroffen werden (im Beispiel steht z.B. - `"archive" : Original wird in archive_dir verschoben` als Kommentar); - 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; erst wenn das passt, nennt die Zusammenfassung Sprachen - und Archiv-Verzeichnis. -- Instanz wird sofort `enable --now` gestartet + 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 + 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. +- 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). -Manuelles Löschen einer Instanz: -```bash -systemctl disable --now pdf-ocr-hotfolder@ -rm /etc/pdf-ocr-hotfolder/.toml -rm -rf /etc/systemd/system/pdf-ocr-hotfolder@.service.d -systemctl daemon-reload -# Datenverzeichnis /var/lib/pdf-ocr-hotfolder/ manuell aufräumen -``` +## 🔄 Update-Verhalten (Kurzfassung) -## 🔄 Update-Verhalten +Vollständig: [docs/UPDATE.md](docs/UPDATE.md). Für die Arbeit am Skript wichtig: -`update.sh`: -1. Findet das Repo (eigenes Verzeichnis oder `/opt/pdf-ocr-hotfolder/.repo_path`) -2. Ermittelt alle **aktiven** `pdf-ocr-hotfolder@*.service` Units und stoppt sie -3. Backup nach `/var/backups/pdf-ocr-hotfolder/` (tar.gz, ohne venv/`__pycache__`) -4. Kopiert Code + requirements + VERSION + config.example aus dem Repo -5. `pip install --upgrade` im venv -6. Aktualisiert Template-Unit + `daemon-reload` -7. Setzt den Code-Eigentümer auf den User, dem `venv` gehört (default `pdfocr`) -8. Startet alle zuvor aktiven Instanzen wieder, Exit 1 wenn eine nicht mehr hochkommt - -Config-Dateien werden **nie** überschrieben. Das Repo muss erhalten bleiben — `update.sh` kopiert daraus. +- `update.sh` hat `--help` und `--rebuild-venv`, läuft mit `set -Eeuo pipefail` + und hat ab dem Stoppen der Instanzen einen **ERR/INT/TERM-Trap**: er sagt, ob + auf der Platte schon getauscht wurde (`TOUCHED`), startet die vorher laufenden + Instanzen wieder und nennt Backup + Rollback-Befehl. +- Reihenfolge: Instanzen erfassen → apt-Sync → venv-Health → stoppen → Backup → + Code → Deps/venv → Units → chown → `--check-config` → starten + verifizieren → + Zusammenfassung (Soll gegen Ist, Exit 1 bei Regression/Config-Fehler). +- **Instanz-Erfassung** deckt `list-units --all` (inkl. `activating`/`failed`), + `list-unit-files` und die Configs unter `/etc/pdf-ocr-hotfolder/` ab. Drei + Gruppen: `PREV_OK`, `PREV_BROKEN`, `PREV_STOPPED` — bewusst gestoppte bleiben + gestoppt. +- **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. +- **`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. + `pip_install_requirements()` übersetzt pip-Fehler in eine Ansage mit dem + gescheiterten Paketnamen und dem Hinweis "requirements.txt anheben". +- **apt-Sync** läuft auch beim Update (`sync_system_packages()`), ist idempotent + und fasst nachinstallierte Sprachpakete nicht an (kein purge/autoremove). + Fehlschläge setzen nur `APT_WARN`, sie brechen nicht ab. +- **Backup** (`create_backup()`): Code, `/etc/pdf-ocr-hotfolder/`, Template-Unit, + alle Drop-ins und ein `pip-freeze.txt` der alten venv. **Ohne** venv und + **ohne** Datenverzeichnisse. `umask 077` + `chmod 600 root:root`, weil die + Configs Klartext-Passwörter enthalten. Rotation: letzte `BACKUP_KEEP` = 5. +- **LXC-Drop-in** wird beim Update aus dem Repo nachgezogen, **falls es + installiert ist** — sonst würde ein neu ergänzter Hardening-Schalter in der + Template-Unit alle Container-Instanzen reißen (Issue #4 redux). +- Configs unter `/etc/pdf-ocr-hotfolder/` werden **nie** überschrieben. Das Repo + 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. ## ⚙️ Konfiguration (Überblick) -Vollständiges Beispiel mit Kommentaren: `config.example.toml`. Sektionen: +Key-für-Key-Referenz: [docs/INSTALLATION.md](docs/INSTALLATION.md#konfigurationsreferenz). +Vollständiges Beispiel mit Kommentaren: `config.example.toml`. | Sektion | Zweck | |---------|-------| @@ -168,7 +201,31 @@ Vollständiges Beispiel mit Kommentaren: `config.example.toml`. Sektionen: | `[notify.email]` | `enabled`, SMTP-Daten, `from_addr`, `to_addrs`, `on` = `always`/`errors`/`never` | | `[logging]` | `level` = DEBUG/INFO/WARNING/ERROR | -Unbekannte Keys in einer Sektion werden beim Laden **still verworfen** (`config.py` filtert gegen die Dataclass-Annotationen) — Tippfehler in Key-Namen fallen also nicht auf. +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), +`unknown_key_warnings()` macht Meldungen daraus. Ein Tippfehler fällt damit auf. + +### Warnungen statt Überraschungen + +`config.py` kennt zwei Warnungsquellen, beide über `config_warnings()` gebündelt +— der Text steht **nur dort**, weil ihn sowohl der Dienststart +(`_log_config_warnings()` → `log.warning`) als auch `--check-config` ausgibt: + +- `legacy_warnings()`: `[ocr].timeout >= LEGACY_TIMEOUT_THRESHOLD` (900) deutet + auf den alten Gesamt-Timeout-Wert 1800 hin (Richtwert + `RECOMMENDED_PAGE_TIMEOUT` = 300); gesetztes `pdfa_level` weist auf den + Ghostscript-Bug hin. +- `unknown_key_warnings()`: siehe oben. + +### `--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. ## 🔄 Verarbeitungs-Flow @@ -176,24 +233,43 @@ Unbekannte Keys in einer Sektion werden beim Laden **still verworfen** (`config. 1. `check_preflight()` — `tesseract` und `gs` müssen im PATH sein; ist `pdfa_level` gesetzt, wird zusätzlich die Ghostscript-Version gegen den 10.0.0–10.02.0-Bug geprüft 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/` + +**Wiederaufnahme aus `working/` (`_scan_working()`):** +`process_pdf()` verschiebt das Original vor dem OCR nach `working/`. Wird der +Dienst dort abgeschossen (SIGKILL nach `TimeoutStopSec`), blieb es früher +liegen und wurde nie wieder angefasst — stiller Datenverlust. Jetzt: + +- Dateien mit dem Präfix `OCR_TEMP_PREFIX` (`__ocr_`) sind **unvollständige + Fragmente** des abgebrochenen ocrmypdf-Laufs: als Eingabe unbrauchbar, als + Ergebnis wertlos → werden **gelöscht** (mit `log.warning`). +- Echte PDFs werden **an Ort und Stelle** wiederaufgenommen; `process_pdf()` + erkennt das über `_is_same_file()` und verschiebt nicht erneut. +- Liegt in `incoming/` eine gleichnamige, andere Datei, bekommt die + wiederaufgenommene per `_free_resume_name()` einen Zeitstempel angehängt — + sonst würden beide dieselbe working- und outgoing-Datei beanspruchen. +- Liegt in `working/` bereits eine **andere** Datei desselben Namens, bricht + `process_pdf()` für die neue ab und lässt sie in `incoming/` liegen, statt den + laufenden Vorgang stillschweigend zu überschreiben. **Pro Datei:** -1. `watchdog` triggert auf `created`/`moved`/`closed` in `incoming/` (beim Start greift `_scan_existing()` bereits liegende PDFs auf) +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/` -4. `ocrmypdf.ocr()` als **Library-Call** (kein Subprozess-Start pro PDF) +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()`: `prefix`/`suffix`/`none` + `name_tag` — das harte `OCR_`-Präfix aus 0.1.0 ist nur noch der Default) +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) 8. Aktive Upload-Targets ausführen (folder/nextcloud/sftp) 9. E-Mail-Notify je nach `[notify.email].on` -**Fehlerbehandlung (Stand 0.4.1):** +**Fehlerbehandlung:** | Fehlerfall | Zählt als Fehler | Wo liegt die Datei danach | |------------|------------------|----------------------------| | Stabilitäts-Check läuft in den Timeout | ja | bleibt in `incoming/`, wird beim nächsten Lauf erneut versucht | | Datei verschwindet vor der Verarbeitung | nein | — | +| 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) | | Beliebige Exception aus `process_pdf()` (z.B. `shutil.move` nach `outgoing/`) | ja | `_rescue_to_error()` sucht in `incoming/` und `working/` und verschiebt nach `error/` | @@ -213,11 +289,14 @@ Der Service läuft in allen Fällen weiter (kein `exit 1` wie im alten Bash-Tool ## ⚠️ Fallstricke -- **Ghostscript 10.0.0–10.02.0 zerschießt OCR.** Das ist der Debian-12-Default. In Kombination aus `[ocr].pdfa_level` + `skip_text = true` blockiert ocrmypdf komplett (Issue #3). Deshalb ist `pdfa_level = ""` der sichere Default, und der Preflight bricht mit Exit 2 ab, wenn `pdfa_level` gesetzt **und** die GS-Version betroffen ist. Abhilfe: Ghostscript ≥ 10.02.1 aus bookworm-backports (der Installer bietet das an). -- **`[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. 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. -- **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. -- **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). -- **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`. Beim Debuggen nicht versehentlich in ein Ticket oder Log kopieren. +- **Ghostscript 10.0.0–10.02.0 zerschießt OCR.** Das ist der Debian-12-Default. In Kombination aus `[ocr].pdfa_level` + `skip_text = true` blockiert ocrmypdf komplett (Issue #3). Deshalb ist `pdfa_level = ""` der sichere Default, der Preflight bricht mit Exit 2 ab, wenn `pdfa_level` gesetzt **und** die GS-Version betroffen ist, und `--check-config` warnt bei gesetztem `pdfa_level` grundsätzlich. Abhilfe: Ghostscript ≥ 10.02.1 aus bookworm-backports (der Installer bietet das an). +- **`[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`). +- **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. +- **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). ## 🛠️ Entwicklung @@ -233,17 +312,21 @@ python -m pdf_ocr_hotfolder --config /tmp/config.toml Tests (aus dem Repo-Root, `pytest.ini` setzt `testpaths = tests`): ```bash -pytest # aktuell 95 Tests +pytest # aktuell 135 Tests ``` `ocrmypdf` muss dafür **nicht** installiert sein: der Import in `processor.py` ist lazy, und `tests/test_ocr_timeout.py` schiebt ein Dummy-Modul in `sys.modules`. Die übrigen Tests mocken `process_pdf` bzw. arbeiten nur auf Config-Ebene. ## 📋 Roadmap / TODO -- [x] Tests (`pytest`) für `processor` und `uploaders` — 95 Tests -- [ ] 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()`). +- [x] Tests (`pytest`) für `processor` und `uploaders` — 135 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. - [ ] Prometheus-Metriken (verarbeitete PDFs, Fehlerquote, Laufzeit) - [ ] CLI-Subkommandos: `pdf-ocr-hotfolder reprocess ` +- [ ] Instanz-Löschung in `install.sh` statt als Handarbeit - [ ] Optional: S3/MinIO Upload-Target - [ ] Docker-Image für Setups ohne systemd @@ -251,6 +334,7 @@ pytest # aktuell 95 Tests - **Repo:** https://gitea.sonith.de/sonith_ug/pdf-ocr-hotfolder - **Owner:** sonith_ug +- **SSH-User ist `gitea`, nicht `git`:** `gitea@gitea.sonith.de:sonith_ug/pdf-ocr-hotfolder.git` - **Versionierung:** Semver (PATCH bei jedem Build, MINOR bei Features, MAJOR manuell) - **Tags:** `v{VERSION}`, automatischer Push nach Commit diff --git a/CHANGELOG.md b/CHANGELOG.md index dfe3595..e959358 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,120 @@ # Changelog +## [0.6.0] - 2026-09-22 + +### Added +- **Wiederaufnahme aus `working/` beim Start.** `process_pdf()` verschiebt das + Original vor dem OCR nach `working/`. Wurde der Dienst dort hart abgeschossen + (SIGKILL nach `TimeoutStopSec`), blieb die Datei liegen und wurde **nie wieder + angefasst** — stiller Datenverlust. `_scan_working()` greift sie jetzt beim + Start auf (vor `incoming/`), das OCR laeuft fuer sie neu. Liegt in `incoming/` + eine gleichnamige, andere Datei, bekommt die wiederaufgenommene einen + Zeitstempel angehaengt, damit sich beide nicht ueberschreiben. Liegt in + `working/` bereits eine andere Datei desselben Namens, bricht `process_pdf()` + fuer die neue ab und laesst sie in `incoming/` liegen, statt den laufenden + Vorgang stillschweigend zu ueberschreiben. +- **Unvollstaendige OCR-Fragmente werden geloescht.** Die Zwischendatei, in die + ocrmypdf schreibt, traegt jetzt das Praefix `__ocr_` (`OCR_TEMP_PREFIX`). + Bleibt so eine Datei nach einem harten Stopp in `working/` liegen, ist sie als + Eingabe unbrauchbar und als Ergebnis wertlos — sie wird beim Start mit einer + Warnung entfernt, damit sie niemand fuer ein fertiges PDF haelt. +- **`--check-config`**: prueft eine Instanz-Config, ohne irgendetwas zu + verarbeiten (hat Vorrang vor `--once`). Zeigt die vier Pfade inkl. Hinweis auf + noch fehlende Verzeichnisse, Sprachen, Seiten-Timeout und PDF/A-Level, faehrt + Preflight und `[output]`-Validierung und gibt alle Warnungen aus. + Exit **0** = sauber, **1** = nur Warnungen, **2** = Fehler (Dienst wuerde nicht + starten). +- **Legacy-Warnungen fuer `[ocr].timeout` und `[ocr].pdfa_level`.** Ein + `timeout >= 900` stammt fast sicher aus einer Config vor 0.4.0, wo der Wert ein + wirkungsloses Gesamt-Timeout mit Default 1800 war — seither sind es Sekunden + **pro Seite** (Richtwert 300). Ein gesetztes `pdfa_level` weist auf den + Ghostscript-Bug hin. Die Texte stehen nur in `config.py` + (`legacy_warnings()`), weil sie sowohl beim Dienststart ins Log gehen als auch + von `--check-config` ausgegeben werden. +- **Unbekannte Config-Keys werden gemeldet** statt still verworfen. + `_collect_unknown_keys()` sammelt Tippfehler (`[ocr].langauges`), Optionen aus + aelteren Versionen, unbekannte Sektionen und unbekannte Upload-/Notify-Targets + in `Config.unknown_keys`; die Meldung nennt den vollen Pfad. Warnung, kein + Fehler — der Dienst startet, der Eintrag tut nur nichts. +- **`TimeoutStopSec=300` in der Template-Unit**: ein laufendes OCR darf beim + Stoppen zu Ende laufen. Ein `systemctl stop` kann dadurch pro Instanz bis zu + 5 Minuten dauern — das ist gewollt, ein SIGKILL wuerde den Durchlauf kosten. +- **Feste Pins in `requirements.txt`** (`ocrmypdf==16.13.0`, `watchdog==6.0.0`, + `requests==2.33.1`, `paramiko==4.0.0`). Ohne Pins zieht ein + `pip install --upgrade` beim Update ungefragt einen Major-Sprung ein; ocrmypdf + 16 -> 17 wuerde alle Instanzen auf einmal reissen. Geprueft gegen Python 3.11 + (Debian 12) und 3.13 (Debian 13), Wheels fuer beide vorhanden. +- 40 neue Tests (Wiederaufnahme aus `working/`, `--check-config`, + Config-Warnungen). Suite jetzt **135 Tests**. + +### Changed +- **Die Betriebsdoku ist in drei Dokumente aufgeteilt.** Der README ist wieder + der Einstieg (Kurzbeschreibung, Features, Schnellstart, Verzeichnis-Layout, + Config-Ueberblick) und verlinkt: + - `docs/INSTALLATION.md` — Erstinstallation, Basis-Install vs. Instanz-Anlage, + die Abfragen pro Instanz, Multi-Instanz-Betrieb, LXC (Error 226/NAMESPACE), + Ghostscript auf Debian 12, Instanz manuell loeschen und die vollstaendige + **Konfigurationsreferenz**. + - `docs/UPDATE.md` — Ablauf von `update.sh`, was es nicht anfasst, + Backup-Inhalt/-Rechte/-Rotation, Rollback und dessen Grenzen, + `--check-config` mit den Exit-Codes und Config-Drift. + - `docs/OS-UPGRADE.md` — Debian-Major-Upgrade als eigener Ablauf. + `AI_AGENT_BRIEFING.md` bleibt der Agent-Kontext (Aufbau und Begruendungen) und + verweist fuer Ablaeufe auf die drei Dokumente, statt sie zu wiederholen. + Nichts wird doppelt gepflegt. +- **`update.sh` komplett ueberarbeitet** (`--help`, `--rebuild-venv`, + `set -Eeuo pipefail`): + - **venv-Health-Check und Neubau.** Geprueft werden Existenz, Lauffaehigkeit + des Interpreters, `major.minor` gegen das System-Python und `pyvenv.cfg`. + Passt etwas nicht — typisch nach einem Debian-Major-Upgrade, systemd meldet + dann `203/EXEC` —, wird die venv neu gebaut, auch ohne `--rebuild-venv`. Der + Neubau ist ganz oder gar nicht: alte venv weg sichern, neu bauen, + Requirements installieren, **erst bei Erfolg** die alte loeschen; scheitert + etwas, wird zurueckgerollt und hart abgebrochen. Scheitert pip an einem Pin, + nennt das Skript das gescheiterte Paket und den naechsten Schritt + ("requirements.txt anheben"). + - **apt-Sync auch beim Update.** Die Paketliste wird aus `install.sh` + extrahiert (einzige Quelle, Marken `BEGIN/END apt-packages`) und + installiert; nachinstallierte Tesseract-Sprachpakete bleiben unangetastet + (kein purge, kein autoremove). Fehlschlaege warnen nur. + - **Haertere Verifikation.** Nach dem Start prueft `verify_unit()` nicht nur + `is-active`, sondern auch `is-failed` und den Restart-Zaehler — ein + Crash-Loop galt bei `Type=simple` bisher als Erfolg. Die Zusammenfassung + stellt Soll gegen Ist und meldet eine **Regression** namentlich. + - **Vollstaendiges Backup.** Gesichert werden Code, alle Instanz-Configs, die + Template-Unit, alle Drop-ins und ein `pip-freeze.txt` der alten venv — ohne + venv und ohne Datenverzeichnisse. Weil die Configs Klartext-Passwoerter + enthalten, wird das Archiv mit `umask 077` erzeugt und auf `0600 root:root` + gesetzt, das Verzeichnis auf `700`. Rotation: die letzten 5 Archive bleiben. + - **ERR-Trap.** Bricht das Update ab (Fehler, Strg-C, `kill`), sagt das Skript, + ob auf der Platte schon getauscht wurde, startet die vorher laufenden + Instanzen wieder und nennt Backup-Datei und Rollback-Befehl. + - **Instanz-Erfassung** deckt jetzt auch `activating` und `failed` ab (ueber + `list-units --all`, `list-unit-files` und die vorhandenen Configs). Vorher + kaputte Instanzen werden mitgestartet, gelten aber erst als Erfolg, wenn sie + danach wirklich laufen; bewusst gestoppte bleiben gestoppt. + - **Config-Pruefung vor dem Start**: `--check-config` je Instanz, Exit 2 zaehlt + als Fehler (Update-Exit 1), Exit 1 wird als Warnung samt Nachstell-Befehl + ausgegeben. Kennt der installierte Code das Flag noch nicht, wird die + Pruefung uebersprungen und das Update laeuft weiter. +- **`install.sh` repariert eine kaputte venv.** Bisher reichte das blosse + Vorhandensein von `venv/`, um den Basis-Install zu ueberspringen — nach einem + Distributions-Upgrade hat der Installer damit gar nichts repariert. Jetzt wird + die venv gegen das System-Python geprueft und bei Drift nach + `venv.old-` gesichert und neu gebaut. +- Die apt-Paketliste steht als **einzige Quelle** in `install.sh` in der Funktion + `pdf_ocr_apt_packages()` zwischen den Marken `# --- BEGIN apt-packages` / + `# --- END apt-packages`. `update.sh` schneidet den Block heraus und wertet ihn + aus — Marken und Funktionsname duerfen sich nicht ohne Anpassung aendern. + +### Fixed +- **Dateien in `working/` gingen nach einem harten Stopp still verloren.** Siehe + Wiederaufnahme oben — der Fall trat bei jedem SIGKILL waehrend eines OCR-Laufs + auf, also auch bei einem Update ohne `TimeoutStopSec`. +- **Tippfehler in Config-Keys fielen nicht auf.** `load_config()` filterte + stumm gegen die Dataclass-Annotationen; `[ocr].langauges` lief damit + wirkungslos mit. Jetzt gibt es eine Warnung mit vollem Key-Pfad. + ## [0.5.0] - 2026-09-22 ### Added diff --git a/README.md b/README.md index 15e43dd..27fac62 100644 --- a/README.md +++ b/README.md @@ -2,40 +2,43 @@ Verwandelt eingehende gescannte PDFs automatisch in **durchsuchbare PDFs** (PDF/A optional) per OCR. Hauptanwendung: Kunden-Scanner schiebt PDF in einen Ordner — Sekunden später liegt die OCR-Version im Ausgang oder wird in Nextcloud / per SFTP weitergeleitet. +## Dokumentation + +| Dokument | Inhalt | +|----------|--------| +| **[docs/INSTALLATION.md](docs/INSTALLATION.md)** | Erstinstallation, Instanzen anlegen, LXC, Ghostscript, **Konfigurationsreferenz**, Troubleshooting | +| **[docs/UPDATE.md](docs/UPDATE.md)** | Update mit `update.sh`: Ablauf, Backup, Rollback, `--check-config`, Config-Drift | +| **[docs/OS-UPGRADE.md](docs/OS-UPGRADE.md)** | Debian-Major-Upgrade (12 → 13): venv neu bauen, Pins anheben | + +Weiter: [CHANGELOG.md](CHANGELOG.md) · [AI_AGENT_BRIEFING.md](AI_AGENT_BRIEFING.md) · [config.example.toml](config.example.toml) + ## Features - 🔍 **OCR via ocrmypdf + Tesseract** (Library-Call, kein Subprozess-Overhead) - 📂 **Hotfolder via watchdog** — reagiert auf `created`, `moved`, `closed` Events - 🧠 **Stabilitäts-Erkennung**: wartet bis Scanner fertig geschrieben hat - 🔁 **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 - ☁️ **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 +- ⚙️ Saubere systemd-Integration mit auto-Restart, **Multi-Instanz** über eine Template-Unit +- 🩺 **`--check-config`** prüft eine Instanz-Config ohne etwas zu verarbeiten ## Schnellstart ```bash -git clone https://gitea.sonith.de/sonith_ug/pdf-ocr-hotfolder.git +git clone gitea@gitea.sonith.de:sonith_ug/pdf-ocr-hotfolder.git cd pdf-ocr-hotfolder sudo ./install.sh ``` -Der Installer: -1. Installiert einmalig Code + venv + systemd-Template-Unit -2. Fragt **pro Instanz** ab: - - Instanz-Name - - Basis-Pfad für die Daten - - Service-User - - **OCR-Sprachen** (Tesseract, Default `deu+eng`) — fehlende Sprachpakete - (`tesseract-ocr-`) werden erkannt und auf Wunsch nachinstalliert - - **Original nach erfolgreichem OCR archivieren?** (Default nein = löschen; - bei ja zusätzlich der Archiv-Pfad, vorgeschlagen `/archive`) -3. Legt so viele Hotfolder-Instanzen an, wie du willst (`Weitere Instanz anlegen? [j/N]`) - -Bei jedem erneuten Aufruf erkennt der Installer bestehende Instanzen und fragt nur nach neuen. +Der Installer legt einmalig Code, venv und die systemd-Template-Unit an und +fragt danach **pro Instanz** Name, Basis-Pfad, Service-User, OCR-Sprachen und +die Original-Behandlung ab. Bei jedem erneuten Aufruf erkennt er bestehende +Instanzen und fragt nur nach neuen. Test: @@ -46,126 +49,56 @@ journalctl -u pdf-ocr-hotfolder@ -f Nach wenigen Sekunden liegt das OCR-PDF im `outgoing/`-Ordner der Instanz. -## Multi-Instanz-Betrieb +Alle Details zu den Abfragen, zum Multi-Instanz-Betrieb und zu den Fallstricken +(LXC, Ghostscript): **[docs/INSTALLATION.md](docs/INSTALLATION.md)**. -Das Tool arbeitet komplett **instanzbasiert** über eine systemd Template-Unit `pdf-ocr-hotfolder@.service`. Jede Instanz hat: - -- eigene Config-Datei: `/etc/pdf-ocr-hotfolder/.toml` -- eigene Datenverzeichnisse: `/var/lib/pdf-ocr-hotfolder//{incoming,working,outgoing,error}/` -- eigene systemd-Unit: `pdf-ocr-hotfolder@.service` -- optional eigenen Service-User (via Drop-in `/etc/systemd/system/pdf-ocr-hotfolder@.service.d/user.conf`) -- **eigene OCR-Sprachen und eigene Original-Behandlung** (löschen oder archivieren) - -### Sprachen pro Instanz - -Die Tesseract-Sprachen werden bewusst **je Instanz** abgefragt, nicht global: -Hotfolder haben unterschiedliche Post. Ein Buchhaltungs-Hotfolder sieht nur -deutsche Belege, ein Export-Hotfolder internationale Korrespondenz: - -```toml -# /etc/pdf-ocr-hotfolder/buchhaltung.toml -languages = "deu" - -# /etc/pdf-ocr-hotfolder/export.toml -languages = "deu+eng+fra" -``` - -**Die Liste so eng wie möglich halten.** Jede zusätzliche Sprache kostet -Laufzeit *und* Erkennungsqualität: Tesseract muss mehr Modelle gegeneinander -abwägen und verwechselt dabei Wörter, die in der einen Sprache eindeutig wären. -`deu+eng+fra` auf reinen Deutsch-Scans ist also kein Sicherheitsnetz, sondern -ein Rückschritt. - -Beispiel für 3 Hotfolder: - -```bash -sudo ./install.sh -# → legt z.B. kunde-a, kunde-b, buchhaltung an - -systemctl status 'pdf-ocr-hotfolder@*' -journalctl -u pdf-ocr-hotfolder@kunde-a -f -``` - -Manuell eine weitere Instanz anlegen geht auch — einfach `install.sh` erneut starten, er fragt wieder nach. +Update: `git pull && sudo ./update.sh` — siehe **[docs/UPDATE.md](docs/UPDATE.md)**. +Nach einem Debian-Major-Upgrade: **[docs/OS-UPGRADE.md](docs/OS-UPGRADE.md)**. ## Verzeichnisse | Pfad | Zweck | |------|-------| | `/opt/pdf-ocr-hotfolder/` | Code + venv (für alle Instanzen gemeinsam) | -| `/etc/pdf-ocr-hotfolder/.toml` | Config pro Instanz | +| `/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) | | `/var/lib/pdf-ocr-hotfolder//working` | Arbeitsverzeichnis während OCR | | `/var/lib/pdf-ocr-hotfolder//outgoing` | Ausgang (fertige PDFs) | | `/var/lib/pdf-ocr-hotfolder//error` | Fehlgeschlagene PDFs | -| `/var/backups/pdf-ocr-hotfolder/` | Update-Backups | +| `/var/backups/pdf-ocr-hotfolder/` | Update-Backups (0600, letzte 5) | -## Konfiguration +Ein eigenes Logverzeichnis gibt es nicht — der Dienst loggt nach stdout und +damit ins journal. -Vollständiges Beispiel: [`config.example.toml`](config.example.toml). Wichtigste Sektionen: +## Konfiguration im Überblick -Der Installer fragt `[ocr].languages`, `[output].original_on_success` und -`[output].archive_dir` pro Instanz ab und schreibt sie direkt in die -Instanz-Config — die Werte unten sind nur die Beispiel-Defaults. +Jede Instanz hat ihre eigene TOML unter `/etc/pdf-ocr-hotfolder/.toml`. +Vollständiges, kommentiertes Beispiel: [`config.example.toml`](config.example.toml). +Key-für-Key-Referenz: [docs/INSTALLATION.md](docs/INSTALLATION.md#konfigurationsreferenz). -### `[ocr]` -```toml -languages = "deu+eng" # Tesseract-Sprachen (Installer fragt pro Instanz) -jobs = 4 # Threads pro PDF -skip_text = true # bereits OCR-haltige Seiten überspringen -pdfa_level = "" # "1", "2", "3" oder "" für reines PDF (Default "" wegen Ghostscript-Bug, s.u.) -deskew = true -max_workers = 2 # parallele PDFs -timeout = 300 # max. Sekunden pro SEITE (Tesseract), 0 = ocrmypdf-Default +| Sektion | Zweck | +|---------|-------| +| `[paths]` | `incoming`, `outgoing`, `working`, `error` — **Pflicht** | +| `[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 | +| `[upload.folder]` / `[upload.nextcloud]` / `[upload.sftp]` | Upload-Ziele, beliebig viele gleichzeitig | +| `[notify.email]` | SMTP-Benachrichtigung: `always` \| `errors` \| `never` | +| `[logging]` | `level` = DEBUG/INFO/WARNING/ERROR | + +Die Instanz-Configs enthalten **Klartext-Passwörter** (SMTP, Nextcloud, SFTP) — +deshalb `640 root:` und beim Debuggen nicht in Tickets kopieren. + +Config prüfen, ohne etwas zu verarbeiten: + +```bash +sudo /opt/pdf-ocr-hotfolder/venv/bin/python -m pdf_ocr_hotfolder \ + --check-config --config /etc/pdf-ocr-hotfolder/.toml ``` -### `[output]` -```toml -# Dateiname im outgoing/: -# "prefix" → OCR_scan.pdf -# "suffix" → scan_OCR.pdf (vor der Extension) -# "none" → scan.pdf (unverändert) -name_mode = "prefix" -name_tag = "OCR_" - -# Nach erfolgreichem OCR mit dem Original: -# "delete" → löschen -# "archive" → in archive_dir verschieben -# Beides fragt der Installer beim Anlegen der Instanz ab: -original_on_success = "delete" -archive_dir = "" # absoluter Pfad, Pflicht bei "archive" -``` - -### `[upload.nextcloud]` -```toml -enabled = true -url = "https://cloud.example.com" -username = "scanuser" -password = "app-password" -remote_path = "Scans/Inbox" -``` - -### `[upload.sftp]` -```toml -enabled = true -host = "sftp.example.com" -username = "scanuser" -key_file = "/etc/pdf-ocr-hotfolder/sftp_key" -remote_path = "/uploads" -``` - -### `[notify.email]` -```toml -enabled = true -smtp_host = "smtp.example.com" -smtp_port = 587 -smtp_user = "alerts@example.com" -smtp_password = "secret" -from_addr = "PDF OCR " -to_addrs = ["admin@example.com"] -on = "errors" # always | errors | never -``` +Exit 0 = sauber, 1 = Warnungen, 2 = Fehler. Details: +[docs/UPDATE.md](docs/UPDATE.md#config-prüfung-per---check-config). ## Service-Verwaltung @@ -178,80 +111,11 @@ journalctl -u pdf-ocr-hotfolder@kunde-a -f # Alle Instanzen sudo systemctl status 'pdf-ocr-hotfolder@*' sudo systemctl restart 'pdf-ocr-hotfolder@*' +journalctl -u 'pdf-ocr-hotfolder@*' --since today ``` -### Logs - -Der Dienst schreibt **kein eigenes Logfile** — alles geht nach stdout und damit -ins journal: - -```bash -journalctl -u pdf-ocr-hotfolder@ -f # eine Instanz mitlesen -journalctl -u 'pdf-ocr-hotfolder@*' --since today # alle Instanzen, heute -``` - -## Update - -```bash -cd /pfad/zum/repo -git pull -sudo ./update.sh -``` - -`update.sh`: -1. Stoppt alle laufenden Instanzen -2. Sichert den alten Code nach `/var/backups/pdf-ocr-hotfolder/` -3. Aktualisiert Code + venv + systemd-Template-Unit in `/opt/pdf-ocr-hotfolder/` -4. Startet alle zuvor laufenden Instanzen neu - -Config-Dateien unter `/etc/pdf-ocr-hotfolder/` werden **nie** überschrieben. -Das Repo muss bestehen bleiben — `update.sh` kopiert daraus. - -## Manueller Lauf (One-Shot) - -Bestehende PDFs einer Instanz einmalig verarbeiten und beenden: - -```bash -sudo -u pdfocr /opt/pdf-ocr-hotfolder/venv/bin/python -m pdf_ocr_hotfolder \ - --config /etc/pdf-ocr-hotfolder/kunde-a.toml --once -``` - -## Troubleshooting - -### Tesseract findet die Sprache nicht -```bash -sudo apt install tesseract-ocr-deu tesseract-ocr-eng -``` - -### "PriorOcrFoundError" -ocrmypdf erkennt bereits vorhandenen OCR-Text. `skip_text = true` in der Config setzen. - -### Berechtigungsprobleme bei AD-User -Service-User braucht **rw** auf alle vier Verzeichnisse unter `/var/lib/pdf-ocr-hotfolder/`. Bei AD-User mit lokaler UID: -```bash -sudo chown -R DOMAIN\\scanuser:DOMAIN\\scangroup /var/lib/pdf-ocr-hotfolder -``` - -### LXC/Container: Error 226/NAMESPACE -In LXC-Containern schlagen systemd-Hardening-Optionen fehl. Der Installer erkennt Container automatisch und bietet ein Drop-in an. Manuell: -```bash -sudo mkdir -p /etc/systemd/system/pdf-ocr-hotfolder@.service.d/ -sudo cp /opt/pdf-ocr-hotfolder/systemd/lxc-compat.conf \ - /etc/systemd/system/pdf-ocr-hotfolder@.service.d/ -sudo systemctl daemon-reload -sudo systemctl restart 'pdf-ocr-hotfolder@*' -``` - -### Ghostscript PDF/A-Bug auf Debian 12 -GS 10.00.0–10.02.0 (Debian 12 Default) zerstört OCR bei `pdfa_level` + `skip_text=true`. Der Installer bietet automatisch bookworm-backports an. Manuell: -```bash -echo 'deb http://deb.debian.org/debian bookworm-backports main' | \ - sudo tee /etc/apt/sources.list.d/bookworm-backports.list -sudo apt update && sudo apt install -t bookworm-backports ghostscript -``` - -### veraPDF-Validierung schlägt immer fehl -veraPDF binary prüfen (`[verapdf].binary`). Wenn nicht zwingend gebraucht: `enabled = false`. +Ein laufendes OCR darf beim Stoppen zu Ende laufen (`TimeoutStopSec=300`) — ein +`stop` kann deshalb pro Instanz bis zu 5 Minuten dauern. ## Architektur @@ -275,11 +139,24 @@ veraPDF binary prüfen (`[verapdf].binary`). Wenn nicht zwingend gebraucht: `ena └────────────┘ └────────────┘ └────────────┘ ``` +Beim Start wird `working/` zuerst durchsucht: was ein harter Stopp dort liegen +ließ, wird wiederaufgenommen; unvollständige OCR-Fragmente (`__ocr_*`) werden +gelöscht. + +## Tests + +```bash +pytest # 135 Tests +``` + +`ocrmypdf` muss dafür nicht installiert sein — der Import ist lazy und wird in +den Tests gemockt. + ## Lizenz MIT — © Sonith UG --- -**Version:** 0.5.0 +**Version:** 0.6.0 **Repo:** https://gitea.sonith.de/sonith_ug/pdf-ocr-hotfolder diff --git a/VERSION b/VERSION index 8f0916f..a918a2a 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.5.0 +0.6.0 diff --git a/config.example.toml b/config.example.toml index fe85aa9..ac9f0f0 100644 --- a/config.example.toml +++ b/config.example.toml @@ -1,5 +1,5 @@ # PDF OCR Hotfolder — Konfiguration -# Speichern als /etc/pdf-ocr-hotfolder/config.toml +# Vorlage — install.sh erzeugt daraus pro Instanz /etc/pdf-ocr-hotfolder/.toml [paths] # Eingangsverzeichnis: hier landen gescannte PDFs diff --git a/docs/INSTALLATION.md b/docs/INSTALLATION.md new file mode 100644 index 0000000..4b1b8c5 --- /dev/null +++ b/docs/INSTALLATION.md @@ -0,0 +1,464 @@ +# Installation + +Erstinstallation und Anlage von Hotfolder-Instanzen mit `install.sh`. + +Verwandte Dokumente: [README](../README.md) · [Update](UPDATE.md) · [Debian-Major-Upgrade](OS-UPGRADE.md) + +--- + +## Voraussetzungen + +| Punkt | Anforderung | +|-------|-------------| +| Betriebssystem | Debian 12 (bookworm) oder Debian 13 — systemd wird vorausgesetzt | +| Python | 3.11+ (wegen `tomllib` aus der stdlib); kommt aus der Distribution | +| 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: + +``` +python3 python3-venv python3-pip +tesseract-ocr tesseract-ocr-deu tesseract-ocr-eng +ghostscript qpdf unpaper pngquant icc-profiles-free +ca-certificates curl +``` + +Weitere Tesseract-Sprachpakete installiert der Installer bei Bedarf pro Instanz +nach (siehe [OCR-Sprachen](#3-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 +anhebt: [OS-UPGRADE.md](OS-UPGRADE.md#pins-in-requirementstxt). + +--- + +## Installation + +```bash +git clone gitea@gitea.sonith.de:sonith_ug/pdf-ocr-hotfolder.git +cd pdf-ocr-hotfolder +sudo ./install.sh +``` + +`install.sh` ist **Installer und Instanz-Manager in einem** und idempotent — +jeder weitere Aufruf überspringt, was schon steht. + +### Basis-Install vs. Instanz-Anlage + +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 | +| **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 +System-Python (typisch nach einem Distributions-Upgrade), läuft der Basis-Install +**zur Reparatur erneut**: die alte venv wird nach `venv.old-` +weggesichert und neu gebaut. Für den geplanten Weg über ein Debian-Major-Upgrade +ist aber `update.sh --rebuild-venv` gedacht, siehe [OS-UPGRADE.md](OS-UPGRADE.md). + +Beim Erstlauf ist mindestens **eine** Instanz Pflicht. Danach fragt der +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. + +### 1. Instanz-Name + +``` +Instanz-Name (nur a-z, 0-9, -): +``` + +Muster `^[a-z0-9][a-z0-9-]*$`. Der Name wird zum Unit-Suffix +(`pdf-ocr-hotfolder@.service`) und zum Config-Dateinamen +(`/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 Daten [/var/lib/pdf-ocr-hotfolder/]: +``` + +Darunter entstehen `incoming/`, `outgoing/`, `working/`, `error/` und werden auf +den Service-User gechownt. + +### 3. Service-User + +``` +Service-User [pdfocr]: +``` + +- **Existiert der User** (lokal oder als **AD-User via SSSD/Winbind**), wird er + übernommen; die primäre Gruppe ermittelt der Installer per `id -gn`. +- **Existiert er nicht**, bietet der Installer an, ihn lokal als System-User + anzulegen. Wird das abgelehnt, bricht die Instanz-Anlage ab — der User muss + dann erst über AD/SSSD bereitstehen. +- Ist der gewählte User **nicht** `pdfocr`, legt der Installer das Drop-in + `/etc/systemd/system/pdf-ocr-hotfolder@.service.d/user.conf` mit + `User=`/`Group=` an. + +Bei AD-Usern mit lokaler UID werden die Datei-Berechtigungen über die UID +gesetzt — das läuft transparent. + +### 4. OCR-Sprachen + +``` +Tesseract-Sprachen [deu+eng]: +``` + +Die Sprachen werden bewusst **je Instanz** abgefragt: ein Hotfolder +`buchhaltung` sieht nur deutsche Belege, ein Hotfolder `export` +internationale Korrespondenz. + +```toml +# /etc/pdf-ocr-hotfolder/buchhaltung.toml +languages = "deu" + +# /etc/pdf-ocr-hotfolder/export.toml +languages = "deu+eng+fra" +``` + +**Die Liste so eng wie möglich halten.** Jede zusätzliche Sprache kostet +Laufzeit *und* Erkennungsqualität: Tesseract wägt mehr Modelle gegeneinander ab +und verwechselt dabei Wörter, die in einer Sprache eindeutig wären. +`deu+eng+fra` auf reinen Deutsch-Scans ist kein Sicherheitsnetz, sondern ein +Rückschritt. + +Was der Installer damit macht: + +1. **Format prüfen** — Sprachcodes mit `+` verbunden + (`^[a-z]{3}(_[A-Za-z]+)?(\+…)*$`), also `deu`, `deu+eng`, `chi_sim+eng`. + Bei Unsinn wird erneut gefragt, nicht abgebrochen. +2. **Jeden Code gegen `tesseract --list-langs` prüfen.** Fehlt eine + Sprachdatei, bietet er das passende apt-Paket an: `tesseract-ocr-`, + Unterstrich wird zum Bindestrich (`chi_sim` → `tesseract-ocr-chi-sim`). +3. **Lehnt man ab oder scheitert die Installation**, warnt er, dass OCR mit + dieser Sprache **bei jeder Datei** scheitern würde, und fragt die Sprachen + erneut ab — die fehlende Sprache kann man dann einfach weglassen. +4. Ist `tesseract` gar nicht aufrufbar, wird die Prüfung übersprungen und die + Eingabe unverändert übernommen. + +### 5. Original archivieren? + +``` +Original nach erfolgreichem OCR archivieren? [j/N]: +``` + +- **Nein** (Default) → `original_on_success = "delete"`, das Original wird nach + erfolgreichem OCR gelöscht. +- **Ja** → `original_on_success = "archive"`, danach: + +``` +Archiv-Verzeichnis [/archive]: +``` + +Der Pfad muss **absolut** sein und darf **nicht** `incoming/`, `outgoing/`, +`working/` oder `error/` der Instanz sein — im Eingang würde das Original sonst +endlos neu aufgegriffen, in den übrigen kollidiert es mit der Verarbeitung. Das +Verzeichnis wird angelegt und auf den Service-User gechownt; innerhalb des +Basis-Pfads erledigt das bestehende `chown -R` das mit, ein Archiv **außerhalb** +bekommt ein eigenes. + +### Was danach passiert + +Die Instanz-Config entsteht per `sed` aus `config.example.toml`. Substituiert +werden die vier `[paths]`-Zeilen sowie `[ocr].languages`, +`[output].original_on_success` und `[output].archive_dir`. Anschließend liest +der Installer diese drei Keys aus der erzeugten Datei zurück und vergleicht sie +mit der Eingabe; erst wenn das passt, nennt die Zusammenfassung Sprachen und +Archiv-Verzeichnis. Sonst gibt es eine Warnung mit der Bitte, die Config von +Hand nachzuziehen. + +Die Config bekommt `chmod 640` und `chown root:`, das +Verzeichnis `/etc/pdf-ocr-hotfolder` selbst `750 root:pdfocr` — in den +Instanz-Configs stehen **Klartext-Passwörter** für SMTP, Nextcloud und SFTP. + +Zum Schluss: `daemon-reload` und `systemctl enable --now +pdf-ocr-hotfolder@.service`. + +### Test + +```bash +cp irgendein-scan.pdf /var/lib/pdf-ocr-hotfolder//incoming/ +journalctl -u pdf-ocr-hotfolder@ -f +``` + +Nach wenigen Sekunden liegt das OCR-PDF im `outgoing/` der Instanz. + +--- + +## Multi-Instanz-Betrieb + +Das Tool arbeitet komplett instanzbasiert über die systemd-Template-Unit +`pdf-ocr-hotfolder@.service`. Jede Instanz hat eigene Config, eigene +Datenverzeichnisse, eigene Unit, optional eigenen Service-User — und eigene +OCR-Sprachen und Original-Behandlung. + +```bash +sudo ./install.sh # legt z.B. kunde-a, kunde-b, buchhaltung an + +systemctl status 'pdf-ocr-hotfolder@*' +journalctl -u pdf-ocr-hotfolder@kunde-a -f +``` + +Der Code unter `/opt/pdf-ocr-hotfolder/` (inkl. venv) ist für **alle** Instanzen +gemeinsam. Ein Update trifft damit immer alle Instanzen auf einmal — siehe +[UPDATE.md](UPDATE.md). + +Das vollständige Verzeichnis-Layout steht im +[README](../README.md#verzeichnisse). + +--- + +## LXC/Container: Error 226/NAMESPACE + +In LXC-Containern schlagen die systemd-Hardening-Optionen der Unit +(`PrivateTmp`, `ProtectSystem`, `ProtectKernelTunables`, …) fehl; systemd +quittiert das mit `Error 226/NAMESPACE`. + +Der Installer erkennt Container über `systemd-detect-virt --container` und +bietet das Drop-in automatisch an. Manuell: + +```bash +sudo mkdir -p /etc/systemd/system/pdf-ocr-hotfolder@.service.d/ +sudo cp /opt/pdf-ocr-hotfolder/systemd/lxc-compat.conf \ + /etc/systemd/system/pdf-ocr-hotfolder@.service.d/ +sudo systemctl daemon-reload +sudo systemctl restart 'pdf-ocr-hotfolder@*' +``` + +Das Drop-in setzt alle betroffenen Hardening-Schalter auf `false`. Es liegt auf +Template-Ebene (`pdf-ocr-hotfolder@.service.d/`) und gilt damit für alle +Instanzen. Ist es installiert, zieht `update.sh` es bei jedem Update aus dem +Repo nach, damit ein neu ergänzter Hardening-Schalter nicht alle +Container-Instanzen reißt. + +--- + +## Ghostscript-Bug auf Debian 12 + +Ghostscript 10.0.0 bis einschließlich 10.02.0 — der **Debian-12-Default** — +zerschießt OCR in der Kombination `[ocr].pdfa_level` + `skip_text = true`: +ocrmypdf blockiert komplett. + +Deshalb: + +- `pdfa_level = ""` ist der sichere Default (kein PDF/A-Output). +- Der Preflight beim Dienststart bricht mit **Exit 2** ab, wenn `pdfa_level` + gesetzt **und** die installierte Ghostscript-Version betroffen ist. +- `--check-config` meldet ein gesetztes `pdfa_level` als Warnung (siehe + [UPDATE.md](UPDATE.md#config-prüfung-per---check-config)). + +Der Installer erkennt betroffene Versionen und bietet auf Debian 12 +bookworm-backports an. Manuell: + +```bash +echo 'deb http://deb.debian.org/debian bookworm-backports main' | \ + sudo tee /etc/apt/sources.list.d/bookworm-backports.list +sudo apt update && sudo apt install -t bookworm-backports ghostscript +``` + +Ab Ghostscript 10.02.1 ist alles in Ordnung; PDF/A kann dann eingeschaltet +werden. + +--- + +## Instanz manuell löschen + +Der Installer legt Instanzen an, löscht aber keine. Von Hand: + +```bash +sudo systemctl disable --now pdf-ocr-hotfolder@ +sudo rm /etc/pdf-ocr-hotfolder/.toml +sudo rm -rf /etc/systemd/system/pdf-ocr-hotfolder@.service.d +sudo systemctl daemon-reload +# Datenverzeichnis /var/lib/pdf-ocr-hotfolder/ manuell aufräumen +``` + +Das Datenverzeichnis bleibt **bewusst** liegen: dort können noch unverarbeitete +PDFs in `incoming/`, Fehlerfälle in `error/` oder Originale im Archiv liegen. +Erst hineinsehen, dann löschen. + +Solange die Config unter `/etc/pdf-ocr-hotfolder/` liegt, zählt `update.sh` die +Instanz weiter mit — auch wenn sie gestoppt ist. + +--- + +## Konfigurationsreferenz + +Vollständiges, kommentiertes Beispiel: [`config.example.toml`](../config.example.toml). +Jede Instanz hat ihre eigene Kopie unter `/etc/pdf-ocr-hotfolder/.toml`. + +Unbekannte Keys werden beim Laden ignoriert, aber **gemeldet** — beim +Dienststart im Log und von `--check-config`. Ein Tippfehler wie +`[ocr].langauges` fällt damit auf. + +### `[paths]` — Pflicht + +| Key | Bedeutung | +|-----|-----------| +| `incoming` | Eingang, hier schreibt der Scanner hinein | +| `outgoing` | Ausgang, fertige OCR-PDFs | +| `working` | Arbeitsverzeichnis während der Verarbeitung | +| `error` | fehlgeschlagene PDFs | + +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. + +### `[ocr]` + +| Key | Default | Bedeutung | +|-----|---------|-----------| +| `languages` | `"deu+eng"` | Tesseract-Sprachen; der Installer fragt sie pro Instanz ab | +| `jobs` | `4` | Threads, die ocrmypdf innerhalb **einer** PDF nutzt | +| `skip_text` | `true` | Seiten, die schon Text haben, nicht neu OCRen | +| `oversample` | `300` | Auflösung für gerasterte Seiten | +| `pdfa_level` | `""` | `"1"`, `"2"`, `"3"` oder leer für reines PDF — leer wegen des [Ghostscript-Bugs](#ghostscript-bug-auf-debian-12) | +| `deskew` | `true` | schiefe Scans begradigen | +| `clean` | `false` | Hintergrund säubern (unpaper) | +| `max_workers` | `2` | wie viele PDFs **parallel** verarbeitet werden | +| `timeout` | `300` | max. Sekunden, die Tesseract **pro Seite** laufen darf; `0` = kein eigenes Limit | + +**`timeout` ist ein Seiten-Timeout, kein Gesamt-Timeout.** Der Wert geht als +`tesseract_timeout` an ocrmypdf; ein Dokument-Timeout kennt ocrmypdf nicht. Wer +noch den alten Default `1800` aus einer Config vor 0.4.0 stehen hat, gibt +Tesseract 30 Minuten **je Seite** — Richtwert ist 300. Läuft eine Seite in den +Timeout, landet sie ohne Textebene im Ergebnis, die übrigen Seiten laufen +weiter. Ein durchgereichtes `0` würde ocrmypdf dazu bringen, OCR **still zu +überspringen**, deshalb wird `0` (oder negativ) gar nicht erst übergeben und der +ocrmypdf-Default greift. Siehe auch +[Config-Drift](UPDATE.md#config-drift-nach-einem-update). + +### `[output]` + +| Key | Default | Bedeutung | +|-----|---------|-----------| +| `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 | + +Ein Tippfehler in `name_mode` oder `original_on_success` führt beim Start zum +Abbruch mit **Exit 2**, nicht erst bei der ersten Datei. + +### `[verapdf]` + +| Key | Default | Bedeutung | +|-----|---------|-----------| +| `enabled` | `false` | PDF/A-Validierung per veraPDF-CLI | +| `binary` | `/opt/verapdf/verapdf` | Pfad zum veraPDF-Binary | +| `flavour` | `"1b"` | PDF/A-Flavour | + +veraPDF startet eine JVM und ist entsprechend teuer — nur einschalten, wenn die +Validierung wirklich gebraucht wird. Bei FAIL wandert das OCR-Ergebnis nach +`error/`; das Original folgt `original_on_success` (bei `archive` bleibt es also +erhalten). + +### `[upload.folder]` / `[upload.nextcloud]` / `[upload.sftp]` + +Beliebig viele Ziele gleichzeitig aktivierbar. Sind alle aus, bleibt das fertige +PDF einfach in `outgoing/` liegen. + +| Sektion | Keys | +|---------|------| +| `[upload.folder]` | `enabled`, `target` — leer heißt `[paths].outgoing`, dann No-op | +| `[upload.nextcloud]` | `enabled`, `url`, `username`, `password`, `remote_path`, `verify_ssl` | +| `[upload.sftp]` | `enabled`, `host`, `port`, `username`, `key_file`, `password`, `remote_path` | + +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. + +### `[notify.email]` + +| Key | Default | Bedeutung | +|-----|---------|-----------| +| `enabled` | `false` | E-Mail-Benachrichtigung an/aus | +| `smtp_host`, `smtp_port`, `smtp_user`, `smtp_password`, `use_starttls` | — | SMTP-Zugang | +| `from_addr` | — | Absender | +| `to_addrs` | `[]` | Empfängerliste | +| `on` | `"errors"` | `always` \| `errors` \| `never` | + +### `[logging]` + +| Key | Default | Bedeutung | +|-----|---------|-----------| +| `level` | `"INFO"` | `DEBUG` \| `INFO` \| `WARNING` \| `ERROR` | + +Der Dienst schreibt **kein eigenes Logfile** — alles geht nach stdout und damit +ins journal. + +--- + +## Troubleshooting + +### Tesseract findet die Sprache nicht + +```bash +sudo apt install tesseract-ocr-deu tesseract-ocr-eng +``` + +Danach `[ocr].languages` prüfen. Der Installer nimmt einem das beim Anlegen +einer Instanz ab, ein nachträglich in die Config geschriebener Sprachcode wird +aber nicht geprüft — `--check-config` zeigt die eingestellten Sprachen an. + +### "PriorOcrFoundError" + +ocrmypdf erkennt bereits vorhandenen OCR-Text. `skip_text = true` in der Config +setzen (Default). + +### Berechtigungsprobleme bei AD-User + +Der Service-User braucht **rw** auf alle vier Verzeichnisse der Instanz (und auf +das Archiv, falls konfiguriert): + +```bash +sudo chown -R DOMAIN\\scanuser:DOMAIN\\scangroup /var/lib/pdf-ocr-hotfolder/ +``` + +### veraPDF-Validierung schlägt immer fehl + +`[verapdf].binary` prüfen. Wenn die Validierung nicht zwingend gebraucht wird: +`enabled = false`. + +### Dienst startet nicht (Exit 2) + +Exit 2 heißt immer: Config oder Preflight. Die Ursache steht im journal und +ausführlicher in: + +```bash +sudo /opt/pdf-ocr-hotfolder/venv/bin/python -m pdf_ocr_hotfolder \ + --check-config --config /etc/pdf-ocr-hotfolder/.toml +``` + +### Dienst startet nicht (203/EXEC) + +Der Interpreter der venv ist weg — fast immer nach einem Distributions-Upgrade. +Siehe [OS-UPGRADE.md](OS-UPGRADE.md). + +--- + +## Manueller Lauf (One-Shot) + +Bestehende PDFs einer Instanz einmalig verarbeiten und beenden — greift auch +Dateien auf, die in `working/` liegen geblieben sind: + +```bash +sudo -u pdfocr /opt/pdf-ocr-hotfolder/venv/bin/python -m pdf_ocr_hotfolder \ + --config /etc/pdf-ocr-hotfolder/kunde-a.toml --once +``` + +Exit-Code: `0` = alles verarbeitet (auch "nichts da"), `1` = mindestens eine +Datei fehlgeschlagen, `2` = Config- oder Preflight-Fehler. diff --git a/docs/OS-UPGRADE.md b/docs/OS-UPGRADE.md new file mode 100644 index 0000000..96e6066 --- /dev/null +++ b/docs/OS-UPGRADE.md @@ -0,0 +1,219 @@ +# Debian-Major-Upgrade + +Wie der Hotfolder ein Distributions-Upgrade übersteht (12 → 13, später 13 → 14). + +Verwandte Dokumente: [README](../README.md) · [Installation](INSTALLATION.md) · [Update](UPDATE.md) + +--- + +## Warum das ein eigener Ablauf ist + +Die venv unter `/opt/pdf-ocr-hotfolder/venv/` hängt an der **Python-Version der +Distribution**. Ein `apt full-upgrade` von Debian 12 auf 13 tauscht Python 3.11 +gegen 3.13 aus. Danach zeigt `venv/bin/python` auf einen Interpreter, den es so +nicht mehr gibt — systemd quittiert den Start jeder Instanz mit `203/EXEC`, und +auch wenn der Interpreter noch existiert, passen die installierten Pakete nicht +mehr zum System-Python. + +**Die venv muss nach dem Sprung neu gebaut werden.** Ein normales +`sudo ./update.sh` genügt dafür nicht sicher genug — es gibt den ausdrücklichen +Schalter `--rebuild-venv`. + +--- + +## Der Ablauf + +### 1. Vorher updaten + +```bash +cd /pfad/zum/repo +git pull +sudo ./update.sh +``` + +Das bringt die Installation auf den aktuellen Stand und erzeugt vor allem ein +**frisches Backup** inklusive `pip-freeze.txt` — die Liste der Paketversionen, +die auf dem alten System liefen. Inhalt und Ort des Backups: +[UPDATE.md](UPDATE.md#backup). + +### 2. Instanzen stoppen + +```bash +sudo systemctl stop 'pdf-ocr-hotfolder@*' +``` + +Während des Upgrades darf kein OCR laufen: Ghostscript, Tesseract und die +Python-Pakete werden mitten im Betrieb ausgetauscht. + +> **Geduld.** Die Unit hat `TimeoutStopSec=300`, damit ein laufendes OCR sauber +> zu Ende kommt. **Ein Stop kann pro Instanz bis zu 5 Minuten dauern** — bei +> mehreren Instanzen entsprechend länger. Nicht mit `kill -9` nachhelfen; ein +> harter Stopp lässt das Original in `working/` liegen (der Dienst nimmt es beim +> nächsten Start zwar wieder auf, aber der Durchlauf ist verloren). + +Prüfen, dass wirklich alles steht: + +```bash +systemctl status 'pdf-ocr-hotfolder@*' +``` + +### 3. Distribution upgraden + +Der übliche Debian-Weg — Sources auf das neue Release umstellen, dann: + +```bash +sudo apt update +sudo apt full-upgrade +sudo reboot +``` + +Die Instanzen sind `enabled` und starten nach dem Reboot mit; mit der alten venv +scheitern sie (`203/EXEC`). Das ist erwartet und wird im nächsten Schritt +behoben. + +### 4. venv neu bauen + +```bash +cd /pfad/zum/repo +git pull +sudo ./update.sh --rebuild-venv +``` + +`--rebuild-venv` erzwingt den Neubau. Der Rest des Updates läuft wie gewohnt +(siehe [UPDATE.md](UPDATE.md#was-das-skript-tut--in-dieser-reihenfolge)) — die +System-Pakete werden dabei ebenfalls abgeglichen, auch `python3-venv` für das +neue Python. + +Der Neubau ist **ganz oder gar nicht**: + +1. Die alte venv wird nach `venv.old-` verschoben. +2. `python3 -m venv` baut neu. +3. Die Requirements werden installiert. +4. **Erst bei Erfolg** wird die alte venv gelöscht. + +### 5. Wenn ein Pin nicht mehr passt + +Scheitert `pip install` an einer gepinnten Version, bricht das Skript ab und +sagt genau, was los ist: + +- die letzten 25 Zeilen der pip-Ausgabe, +- das **gescheiterte Paket** ("Gescheitertes Paket: …"), +- die Diagnose: "eine in requirements.txt fest gepinnte Version gibt es für + Python \ nicht (mehr)", +- den nächsten Schritt: **requirements.txt anheben** und + `update.sh --rebuild-venv` erneut laufen lassen. + +**Die alte venv ist dann zurückgerollt** — es liegt keine halb gefüllte venv +herum. Sie hängt zwar weiterhin am alten Interpreter und die Instanzen laufen +damit nicht (das sagt das Skript auch), aber der Zustand ist eindeutig. + +Also: + +```bash +# im Repo, auf einer Testmaschine +vim requirements.txt # Version des genannten Pakets anheben +pytest # Suite muss grün bleiben +git commit -am 'requirements: auf anheben' +git push + +# auf dem Zielsystem +git pull +sudo ./update.sh --rebuild-venv +``` + +### 6. `--rebuild-venv` vergessen? + +Halb so wild: `update.sh` prüft die venv auch ohne den Schalter und baut sie bei +Versions-Drift von selbst neu. Geprüft wird + +- ob das Verzeichnis und `venv/bin/python` überhaupt existieren, +- ob der Interpreter der venv noch **läuft** (toter Symlink nach dem Upgrade), +- ob seine `major.minor` zum System-`python3` passt, +- ob `pyvenv.cfg` dieselbe Version nennt wie der Interpreter. + +Stimmt eines davon nicht, nennt das Skript den Grund und baut neu. +`--rebuild-venv` ist also nicht der einzige, aber der **ausdrückliche** Weg — +und der, den man nach einem Distributions-Upgrade nimmt, statt sich auf die +Erkennung zu verlassen. + +--- + +## Danach prüfen + +**Laufen alle Instanzen?** + +```bash +systemctl status 'pdf-ocr-hotfolder@*' +``` + +`update.sh` hat das schon verifiziert (Wartezeit, `is-failed`, Crash-Loop) und +in der Zusammenfassung Soll gegen Ist gestellt — ein Exit 0 heißt, dass jede +Instanz, die vorher lief, auch wieder läuft. + +**Sind die Configs sauber?** + +```bash +for f in /etc/pdf-ocr-hotfolder/*.toml; do + sudo /opt/pdf-ocr-hotfolder/venv/bin/python -m pdf_ocr_hotfolder \ + --check-config --config "$f" +done +``` + +Exit 0/1/2 und was bei Warnungen zu tun ist: +[UPDATE.md](UPDATE.md#config-prüfung-per---check-config). + +**Läuft eine echte PDF durch?** + +```bash +cp test.pdf /var/lib/pdf-ocr-hotfolder//incoming/ +journalctl -u pdf-ocr-hotfolder@ -f +``` + +Im `outgoing/` muss das OCR-PDF liegen, im Journal steht `OCR done`. Das ist der +einzige Test, der die neue venv **und** die neuen System-Binaries (Tesseract, +Ghostscript) wirklich anfasst — `systemctl status` sagt darüber nichts. + +**Ghostscript-Version auf dem neuen Release ansehen:** + +```bash +gs --version +``` + +Auf Debian 13 ist der Ghostscript-Bug aus Debian 12 kein Thema mehr; ein +bookworm-backports-Eintrag unter `/etc/apt/sources.list.d/` gehört nach dem +Upgrade entfernt. Hintergrund: +[INSTALLATION.md](INSTALLATION.md#ghostscript-bug-auf-debian-12). + +--- + +## Pins in `requirements.txt` + +Die Python-Abhängigkeiten sind **bewusst fest gepinnt**: + +``` +ocrmypdf==16.13.0 +watchdog==6.0.0 +requests==2.33.1 +paramiko==4.0.0 +``` + +Ohne Pins würde ein `pip install --upgrade` bei jedem Update ungefragt eine neue +Major-Version ziehen — ein Sprung von ocrmypdf **16 auf 17** reißt sonst alle +Instanzen auf einmal, und zwar im Moment des Updates, nicht zu einem Zeitpunkt, +den man sich ausgesucht hat. + +Die aktuellen Pins sind gegen Python 3.11 (Debian 12) und 3.13 (Debian 13) +geprüft; für beide gibt es fertige Wheels, es wird nichts kompiliert. + +**Beim Anheben:** + +1. **Testmaschine benutzen** — nie direkt auf dem produktiven Hotfolder. +2. Dort `update.sh --rebuild-venv` fahren, damit die Pakete wirklich frisch + aufgelöst werden. +3. `pytest` muss grün bleiben (135 Tests). +4. Eine echte PDF durchschieben — die Test-Suite mockt ocrmypdf, ein Major-Sprung + fällt dort also nicht auf. +5. Erst dann committen und auf die produktiven Systeme geben. + +Der ocrmypdf-Sprung 16 → 17 ist ein **Major-Sprung** und gehört in einen eigenen +Vorgang mit eigenem Test, nicht in ein OS-Upgrade. diff --git a/docs/UPDATE.md b/docs/UPDATE.md new file mode 100644 index 0000000..7fecc52 --- /dev/null +++ b/docs/UPDATE.md @@ -0,0 +1,302 @@ +# Update + +Aktualisieren des OCR-Tools mit `update.sh` — Code, venv, System-Pakete und +systemd-Unit. + +Verwandte Dokumente: [README](../README.md) · [Installation](INSTALLATION.md) · [Debian-Major-Upgrade](OS-UPGRADE.md) + +> Für ein **Debian-Major-Upgrade** (12 → 13) gilt ein eigener Ablauf — die venv +> muss danach neu gebaut werden. Siehe [OS-UPGRADE.md](OS-UPGRADE.md). + +--- + +## Der Ablauf + +```bash +cd /pfad/zum/repo +git pull +sudo ./update.sh +``` + +``` +sudo ./update.sh --help # Optionen anzeigen +sudo ./update.sh --rebuild-venv # venv zwingend neu bauen (nach dist-upgrade) +``` + +`update.sh` muss aus dem Repo laufen. Findet es sich nicht selbst im Repo, liest +es den gespeicherten Pfad aus `/opt/pdf-ocr-hotfolder/.repo_path` — **das Repo +muss also liegen bleiben**, das Tool kopiert daraus. + +## 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 | +| 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` | +| 7 | **Dependencies** | `pip install --upgrade -r requirements.txt` — oder venv-Neubau, falls nötig | +| 8 | **systemd-Units** | Template-Unit aus dem Repo, LXC-Drop-in nachziehen, `daemon-reload` | +| 9 | **Berechtigungen** | Code gehört dem primären User (i.d.R. `pdfocr`) | +| 10 | **Configs prüfen** | `--check-config` je Instanz, siehe [unten](#config-prüfung-per---check-config) | +| 11 | **Instanzen starten + verifizieren** | mit Wartezeit und Crash-Loop-Erkennung | +| 12 | **Zusammenfassung** | Soll gegen Ist | + +Ab Schritt 2 gilt: **System-Pakete werden auch beim Update nachgezogen**, nicht +nur bei der Installation. Bereits installierte Tesseract-Sprachpakete bleiben +unangetastet — es gibt kein `purge` und kein `autoremove`. + +Die Schritte 1–3 verändern nichts auf der Platte. Erst ab Schritt 4 wird +angefasst. + +## Was das Skript NICHT anfasst + +| Bleibt unverändert | Warum | +|--------------------|-------| +| `/etc/pdf-ocr-hotfolder/*.toml` | Instanz-Configs werden **nie** überschrieben — weder neu geschrieben noch gemerged. Neue Config-Keys greifen über ihre Defaults, siehe [Config-Drift](#config-drift-nach-einem-update) | +| `/var/lib/pdf-ocr-hotfolder/…` | Datenverzeichnisse (`incoming`, `working`, `outgoing`, `error`, Archiv) — nichts wird verschoben oder gelöscht | +| Instanz-Drop-ins (`…@.service.d/user.conf`) | Service-User pro Instanz bleibt | +| Nachinstallierte Tesseract-Sprachpakete | werden nicht entfernt | +| Bewusst gestoppte Instanzen | bleiben gestoppt | + +Neue Config-Optionen muss man also selbst nachtragen, wenn man sie nutzen will. +`config.example.toml` liegt nach dem Update aktuell unter +`/opt/pdf-ocr-hotfolder/config.example.toml` und ist die Vorlage dafür. + +--- + +## Instanz-Erfassung + +Eine Instanz gilt als bekannt, wenn sie **irgendwo** auftaucht: als geladene +Unit (`systemctl list-units --all`, also auch `activating` und `failed`), in +`list-unit-files` (enabled), oder als Config unter `/etc/pdf-ocr-hotfolder/`. +Damit fällt auch eine Instanz auf, die gerade in einem Crash-Loop hängt. + +Aus dem Zustand vor dem Update ergeben sich drei Gruppen: + +| Gruppe | Zustand vorher | Behandlung | +|--------|----------------|------------| +| **lief sauber** | `active`, nicht `failed` | wird gestoppt und muss nachher wieder laufen — sonst ist das eine **Regression** und das Update meldet Exit 1 | +| **war kaputt** | `failed`, `activating`, `reloading`, `deactivating` | wird mitgestartet; läuft sie danach, meldet das Skript "vorher kaputt, läuft jetzt". Läuft sie weiterhin nicht, Exit 1, aber ohne Regressions-Alarm | +| **bewusst gestoppt** | `inactive` und nicht `failed` | bleibt gestoppt | + +### Verifikation nach dem Start + +Ein `systemctl start` sagt bei `Type=simple` noch nichts. Deshalb prüft +`verify_unit()` nach einer Wartezeit (`VERIFY_WAIT`, Default 6 s) drei Dinge: + +1. `is-active` muss `active` sein +2. `is-failed` darf nicht `failed` melden +3. `NRestarts` darf nicht gestiegen sein — das entlarvt den Crash-Loop, der sich + hinter einem sofortigen "active" versteckt + +Vorher wird `systemctl reset-failed` gefahren, damit der alte Zustand die +Prüfung nicht verfälscht. Scheitert eine Instanz, nennt das Skript direkt den +passenden `journalctl`-Aufruf. + +Die Zusammenfassung stellt am Ende **Soll gegen Ist** und liefert Exit 1, wenn +eine Instanz fehlt, eine Config einen Fehler hat oder eine vorher kaputte +Instanz immer noch kaputt ist. + +--- + +## Backup + +Vor dem ersten Eingriff auf der Platte schreibt `update.sh` ein Archiv: + +``` +/var/backups/pdf-ocr-hotfolder/backup-YYYYmmdd-HHMMSS.tar.gz +``` + +| Enthalten | Nicht enthalten | +|-----------|-----------------| +| `/opt/pdf-ocr-hotfolder/` (Code) | 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` | | +| `pip-freeze.txt` — `pip freeze` der **alten** venv plus Zeitstempel und Versionssprung | | + +`pip-freeze.txt` ist die Versicherung für den Fall, dass ein neuer Pin Ärger +macht: man sieht schwarz auf weiß, welche Paketversionen vorher liefen. + +**Rechte:** Das Archiv enthält die Instanz-Configs und damit **Klartext-Passwörter** +(SMTP, Nextcloud, SFTP). Es wird deshalb mit `umask 077` erzeugt und danach auf +`0600 root:root` gesetzt; das Verzeichnis selbst bekommt `700`. Backups nicht in +Tickets anhängen und nicht in allgemein lesbare Pfade kopieren. + +**Rotation:** Es werden die **letzten 5** Archive behalten (`BACKUP_KEEP`), +ältere löscht das Skript nach dem Schreiben des neuen. Was entfernt wurde, steht +im Log. + +Scheitert das Backup (typisch: volle Platte), bricht das Update ab, **bevor** +etwas getauscht wurde. + +--- + +## Rollback + +Das Backup-Archiv ist wurzelrelativ gepackt und lässt sich direkt zurückspielen: + +```bash +sudo systemctl stop 'pdf-ocr-hotfolder@*' +sudo tar -xzf /var/backups/pdf-ocr-hotfolder/backup-YYYYmmdd-HHMMSS.tar.gz -C / +sudo systemctl daemon-reload +sudo systemctl start 'pdf-ocr-hotfolder@' +``` + +Das Skript nennt diesen Befehl mit dem konkreten Archivnamen selbst — sowohl +beim Abbruch als auch bei einer erkannten Regression. + +### Grenzen des Rollbacks + +Ein Rollback ist ein **Overlay**, kein exaktes Zurücksetzen: + +- **Die venv ist nicht im Backup.** Wurde sie beim Update neu gebaut oder + hat `pip install --upgrade` Pakete angehoben, holt das Rollback den alten + Stand der Pakete **nicht** zurück. Dafür ist `pip-freeze.txt` aus dem Archiv + da: die dort genannten Versionen lassen sich von Hand wiederherstellen + (`venv/bin/pip install -r …`). +- **Dateien, die es vorher nicht gab, bleiben liegen.** `tar -x` legt nur an und + überschreibt; es löscht nichts. Eine mit dem neuen Stand hinzugekommene Datei + im Code-Verzeichnis überlebt das Rollback. Sauberer ist deshalb + `rm -rf /opt/pdf-ocr-hotfolder/pdf_ocr_hotfolder` **vor** dem Entpacken. +- **Die Datenverzeichnisse sind nicht im Backup** — gewollt. Ein Rollback + verändert keine PDFs, weder in `incoming/` noch in `error/`. +- **System-Pakete werden nicht zurückgenommen.** Ein per apt angehobenes + Ghostscript oder ein neues Sprachpaket bleibt. + +Der einfachere Weg zurück ist deshalb in den meisten Fällen: alten Stand im Repo +auschecken (`git checkout v`) und `sudo ./update.sh` erneut fahren. + +### Der ERR-Trap + +`update.sh` läuft mit `set -Eeuo pipefail` und hat ab dem Moment, in dem +Instanzen gestoppt werden, einen Trap auf `ERR`, `INT` und `TERM`. Bricht +irgendetwas ab — Fehler, Strg-C, `kill` —, dann: + +1. sagt das Skript laut, bei welchem Exit-Code und in welcher Zeile es aufhörte, +2. sagt es, **ob auf der Platte schon getauscht wurde** oder ob der alte Stand + unverändert ist, +3. **startet es die vorher laufenden Instanzen wieder** und meldet jede einzeln, +4. nennt es das Backup-Archiv und den Rollback-Befehl — oder sagt ausdrücklich, + dass noch kein Backup geschrieben wurde. + +Ein abgebrochenes Update lässt also keinen Hotfolder stumm gestoppt zurück. + +--- + +## Config-Prüfung per `--check-config` + +Nach dem Code-Update und vor dem Start prüft `update.sh` jede Instanz-Config mit +dem neuen Code: + +```bash +/opt/pdf-ocr-hotfolder/venv/bin/python -m pdf_ocr_hotfolder \ + --check-config --config /etc/pdf-ocr-hotfolder/.toml +``` + +`--check-config` verarbeitet nichts, es hat sogar Vorrang vor `--once`. Es lädt +die Config, zeigt die vier Pfade (inkl. Hinweis, falls ein Verzeichnis noch +fehlt), Sprachen, Seiten-Timeout und PDF/A-Level, fährt den Preflight +(`tesseract`, `gs`, Ghostscript-Version bei gesetztem `pdfa_level`) und +validiert die `[output]`-Sektion. + +| Exit | Bedeutung | Was der Admin tun soll | +|------|-----------|------------------------| +| **0** | Config sauber | nichts | +| **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 | + +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. + +Dieselben Warnungen schreibt der Dienst beim Start ins Journal — man sieht sie +also auch ohne Update: + +```bash +journalctl -u pdf-ocr-hotfolder@ | grep 'Config-Warnung' +``` + +--- + +## Config-Drift nach einem Update + +Instanz-Configs werden nie überschrieben. Das ist bequem, hat aber zwei +Konsequenzen. + +**Neue Keys sind unkritisch.** Fehlt ein Key, greift der Default aus der +Dataclass — genau der Wert, der auch in `config.example.toml` steht. Eine Config +von 0.2.x läuft unter 0.6.0 weiter, ohne dass etwas nachgetragen werden muss. +Wer die neue Option nutzen will, trägt sie nach; die aktuelle Vorlage liegt +nach jedem Update unter `/opt/pdf-ocr-hotfolder/config.example.toml`. + +Zwei Fälle brauchen aber Handarbeit — beide meldet `--check-config` von selbst: + +### `[ocr].timeout` — Bedeutung geändert seit 0.4.0 + +Vor 0.4.0 war `timeout` ein **Gesamt**-Timeout pro PDF mit Default `1800` — und +wurde nirgends ausgewertet, war also wirkungslos. Seit 0.4.0 geht der Wert als +`tesseract_timeout` an ocrmypdf und ist damit das Limit **pro Seite**; ein +Dokument-Timeout kennt ocrmypdf nicht. + +Wer den Altwert `1800` stehen hat, gibt Tesseract jetzt **30 Minuten je Seite**. +Ab `900` meldet `--check-config` deshalb eine Warnung. **Richtwert: 300.** + +```toml +[ocr] +timeout = 300 # Sekunden pro SEITE +``` + +`0` heißt "kein eigenes Limit": der Wert wird dann gar nicht durchgereicht, weil +ocrmypdf `tesseract_timeout=0` als "OCR komplett überspringen" interpretiert. + +### `[ocr].pdfa_level` — sollte leer sein + +`pdfa_level` gehört auf `""` (reines PDF, kein PDF/A). Ist es gesetzt, warnt +`--check-config`, weil Ghostscript 10.0.0–10.02.0 — der Debian-12-Default — in +Kombination mit `skip_text` das OCR blockiert. Der Preflight bricht in dem Fall +mit Exit 2 ab; ab Ghostscript 10.02.1 ist PDF/A unproblematisch. Hintergrund: +[INSTALLATION.md](INSTALLATION.md#ghostscript-bug-auf-debian-12). + +### Unbekannte Keys + +Einträge, die zu keiner Sektion und keinem Key gehören, werden beim Laden +ignoriert — aber **gemeldet**, mit Pfad (`[ocr].langauges`). Das deckt +Tippfehler und Optionen aus älteren Versionen ab. Die Meldung ist eine Warnung, +kein Fehler: der Dienst startet, der Eintrag tut nur nichts. + +--- + +## Nach dem Update prüfen + +```bash +systemctl status 'pdf-ocr-hotfolder@*' +journalctl -u 'pdf-ocr-hotfolder@*' --since '5 min ago' +``` + +Und einmal eine Test-PDF durchschieben: + +```bash +cp test.pdf /var/lib/pdf-ocr-hotfolder//incoming/ +journalctl -u pdf-ocr-hotfolder@ -f +``` + +Im `outgoing/` muss das OCR-PDF auftauchen. + +--- + +## Wiederaufnahme aus `working/` + +Beim Start greift der Dienst nicht nur `incoming/` auf, sondern zuerst +`working/`: Dateien, die ein harter Stopp dort liegen ließ, werden +wiederaufgenommen und das OCR läuft für sie neu. Unvollständige Zwischendateien +des abgebrochenen Laufs (Präfix `__ocr_`) werden dabei gelöscht. + +Für das Update heißt das: ein `systemctl stop` mitten im OCR kostet den +angefangenen Durchlauf, aber keine Datei. Die Unit gibt einem laufenden OCR +`TimeoutStopSec=300` Zeit, sauber fertig zu werden — **ein Stop kann damit pro +Instanz bis zu 5 Minuten dauern.** Bei mehreren Instanzen entsprechend länger; +`update.sh` stoppt sie nacheinander. diff --git a/install.sh b/install.sh index 5f2271e..6fd9382 100755 --- a/install.sh +++ b/install.sh @@ -37,18 +37,58 @@ if [ ! -f "$REPO_DIR/pdf_ocr_hotfolder/__init__.py" ]; then 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) # ============================================================ install_base() { log_step "System-Pakete installieren" + local -a PKGS + mapfile -t PKGS < <(pdf_ocr_apt_packages) apt-get update -qq - apt-get install -y --no-install-recommends \ - python3 python3-venv python3-pip \ - tesseract-ocr tesseract-ocr-deu tesseract-ocr-eng \ - ghostscript qpdf unpaper pngquant \ - icc-profiles-free ca-certificates curl + apt-get install -y --no-install-recommends "${PKGS[@]}" log_info "System-Pakete ok ✓" # Ghostscript-Versions-Check (Issue #3 + Issue #6) @@ -127,11 +167,25 @@ install_base() { echo "$REPO_DIR" > "$INSTALL_DIR/.repo_path" log_step "Python venv" + # Eine vorhandene, aber kaputte venv (z.B. nach Debian 12 -> 13) wird + # weggesichert und neu gebaut — ein reines "-d"-Vorhandensein reicht nicht. + if [ -d "$INSTALL_DIR/venv" ] && ! venv_is_healthy "$INSTALL_DIR/venv"; then + 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?)." + log_warn "Sie wird gesichert nach: $VENV_SAVED" + mv "$INSTALL_DIR/venv" "$VENV_SAVED" + fi if [ ! -d "$INSTALL_DIR/venv" ]; then python3 -m venv "$INSTALL_DIR/venv" fi "$INSTALL_DIR/venv/bin/pip" install --upgrade pip -q - "$INSTALL_DIR/venv/bin/pip" install -r "$INSTALL_DIR/requirements.txt" -q + if ! "$INSTALL_DIR/venv/bin/pip" install -r "$INSTALL_DIR/requirements.txt" -q; then + log_error "Requirements liessen sich nicht installieren." + log_error "Wahrscheinlich passt eine in requirements.txt gepinnte Version nicht" + log_error "zu Python $(python3 -c 'import sys; print("%d.%d" % sys.version_info[:2])' 2>/dev/null || echo '?')." + exit 1 + fi log_info "venv ok ✓" log_step "systemd Template-Unit installieren" @@ -415,9 +469,15 @@ echo "==========================================" echo " PDF OCR Hotfolder — Installer" echo "==========================================" +# Auch eine vorhandene, aber kaputte venv loest die Basis-Installation aus +# (sonst wuerde install.sh nach einem Distributions-Upgrade nichts reparieren). if [ ! -d "$INSTALL_DIR/venv" ] || [ ! -f "/etc/systemd/system/$SERVICE_TEMPLATE" ]; then log_step "Basis-Installation" install_base +elif ! venv_is_healthy "$INSTALL_DIR/venv"; then + log_warn "Basis-Installation vorhanden, aber die venv passt nicht mehr zum System-Python." + log_step "Basis-Installation wird zur Reparatur erneut ausgefuehrt" + install_base else log_info "Basis-Installation bereits vorhanden ($INSTALL_DIR)" log_info "Überspringe Basis-Setup (nutze update.sh für Code-Updates)" diff --git a/pdf_ocr_hotfolder/__init__.py b/pdf_ocr_hotfolder/__init__.py index ce5b42f..c543914 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.5.0" +__version__ = "0.6.0" diff --git a/pdf_ocr_hotfolder/__main__.py b/pdf_ocr_hotfolder/__main__.py index 9f8dec9..4fa680e 100644 --- a/pdf_ocr_hotfolder/__main__.py +++ b/pdf_ocr_hotfolder/__main__.py @@ -4,11 +4,24 @@ from __future__ import annotations import argparse import logging import sys +import tomllib from pathlib import Path from . import __version__ -from .config import ConfigError, load_config -from .service import HotfolderService, PreflightError +from .config import Config, ConfigError, config_warnings, load_config +from .service import ( + HotfolderService, + PreflightError, + check_output_config, + check_preflight, +) + +log = logging.getLogger(__name__) + +# Exit-Codes von --check-config (werden vom Updater ausgewertet) +CHECK_OK = 0 +CHECK_WARN = 1 +CHECK_ERROR = 2 def _setup_logging(level: str) -> None: @@ -19,6 +32,81 @@ def _setup_logging(level: str) -> None: ) +def _log_config_warnings(cfg: Config) -> None: + """Schreibt Legacy- und Unbekannt-Warnungen beim Dienststart ins Log.""" + for warning in config_warnings(cfg): + log.warning("Config-Warnung: %s", warning) + + +def check_config(cfg_path: Path) -> int: + """Lädt und prüft die Config, ohne irgendetwas zu verarbeiten. + + Returns: + 0 = alles sauber, 1 = nur Warnungen, 2 = Fehler (Config unbrauchbar + oder Preflight scheitert). + """ + print(f"Prüfe Konfiguration: {cfg_path}") + + try: + cfg = load_config(cfg_path) + except ConfigError as e: + 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) + return CHECK_ERROR + except OSError as e: + print(f"FEHLER: {cfg_path} nicht lesbar: {e}", file=sys.stderr) + return CHECK_ERROR + + print(" Config gelesen.") + for label, path in (("incoming", cfg.paths.incoming), + ("outgoing", cfg.paths.outgoing), + ("working ", cfg.paths.working), + ("error ", cfg.paths.error)): + hint = "" if path.is_dir() else " (existiert noch nicht, "\ + "wird beim Start angelegt)" + print(f" {label} = {path}{hint}") + print(f" OCR-Sprachen = {cfg.ocr.languages}") + print(f" Seiten-Timeout= {cfg.ocr.timeout} s") + print(f" PDF/A-Level = {cfg.ocr.pdfa_level or '(aus)'}") + + errors: list[str] = [] + try: + check_preflight(cfg.ocr.pdfa_level) + print(" Preflight ok (tesseract, gs vorhanden).") + except PreflightError as e: + errors.append(str(e)) + try: + check_output_config(cfg.output.original_on_success, + cfg.output.archive_dir, + cfg.output.name_mode) + print(" [output]-Sektion ok.") + except PreflightError as e: + errors.append(str(e)) + + warnings = config_warnings(cfg) + if warnings: + print(f"\n{len(warnings)} Warnung(en):") + for w in warnings: + print(f" WARNUNG: {w}") + + if errors: + print(f"\n{len(errors)} Fehler:", file=sys.stderr) + for e in errors: + print(f" FEHLER: {e}", file=sys.stderr) + print("\nErgebnis: Config unbrauchbar — der Dienst würde nicht starten.", + file=sys.stderr) + return CHECK_ERROR + + if warnings: + print("\nErgebnis: Config nutzbar, aber mit Warnungen.") + return CHECK_WARN + + print("\nErgebnis: Config sauber.") + return CHECK_OK + + def main() -> int: parser = argparse.ArgumentParser( prog="pdf-ocr-hotfolder", @@ -29,6 +117,9 @@ def main() -> int: parser.add_argument("--version", action="version", version=f"%(prog)s {__version__}") parser.add_argument("--once", action="store_true", help="Nur bestehende Dateien verarbeiten und beenden") + parser.add_argument("--check-config", action="store_true", dest="check_config", + help="Config nur prüfen, nichts verarbeiten " + "(Exit 0 = sauber, 1 = Warnungen, 2 = Fehler)") args = parser.parse_args() cfg_path = Path(args.config) @@ -36,12 +127,17 @@ def main() -> int: print(f"Config nicht gefunden: {cfg_path}", file=sys.stderr) return 2 + if args.check_config: + # Hat Vorrang vor --once: es wird nichts verarbeitet. + return check_config(cfg_path) + try: cfg = load_config(cfg_path) except ConfigError as e: print(f"FEHLER: {e}", file=sys.stderr) return 2 _setup_logging(cfg.log_level) + _log_config_warnings(cfg) service = HotfolderService(cfg) diff --git a/pdf_ocr_hotfolder/config.py b/pdf_ocr_hotfolder/config.py index 860db16..964d30a 100644 --- a/pdf_ocr_hotfolder/config.py +++ b/pdf_ocr_hotfolder/config.py @@ -105,6 +105,18 @@ class Config: sftp: SftpUpload email: EmailNotify log_level: str = "INFO" + # Einträge der TOML, die zu keiner Dataclass gehören (Tippfehler oder + # Optionen aus älteren Versionen). load_config() sammelt sie hier ein, + # statt sie stumm zu verwerfen — geloggt wird erst weiter oben, damit + # load_config() ohne konfiguriertes Logging benutzbar bleibt. + unknown_keys: list[str] = field(default_factory=list) + + +# Bekannte Sektionen — alles andere landet in Config.unknown_keys +_KNOWN_SECTIONS = ("paths", "ocr", "output", "verapdf", "upload", "notify", "logging") +_KNOWN_UPLOAD_TARGETS = ("folder", "nextcloud", "sftp") +_KNOWN_NOTIFY_TARGETS = ("email",) +_KNOWN_LOGGING_KEYS = ("level",) def _section(data: dict[str, Any], *keys: str) -> dict[str, Any]: @@ -130,6 +142,42 @@ def _require_path(p: dict[str, Any], key: str, cfg_path: Path) -> Path: return Path(str(value)) +def _unknown_in(data: dict[str, Any], keys: tuple[str, ...], + known: tuple[str, ...]) -> list[str]: + """Listet alle Keys einer Sektion auf, die nicht in `known` stehen.""" + label = ".".join(keys) + return [f"[{label}].{k}" for k in _section(data, *keys) if k not in known] + + +def _collect_unknown_keys(data: dict[str, Any]) -> list[str]: + """Sammelt alle TOML-Einträge, die nirgends ausgewertet werden. + + Dazu zählen Tippfehler (`[ocr].langauges`), Optionen aus älteren + Versionen und komplett unbekannte Sektionen. Die Reihenfolge entspricht + der Datei, damit die Meldung reproduzierbar bleibt. + """ + unknown: list[str] = [] + for name, value in data.items(): + if name not in _KNOWN_SECTIONS: + unknown.append(f"[{name}]" if isinstance(value, dict) else name) + unknown += _unknown_in(data, ("paths",), tuple(Paths.__annotations__)) + unknown += _unknown_in(data, ("ocr",), tuple(OcrConfig.__annotations__)) + unknown += _unknown_in(data, ("output",), tuple(OutputConfig.__annotations__)) + unknown += _unknown_in(data, ("verapdf",), tuple(VeraPdfConfig.__annotations__)) + for sub in _section(data, "upload"): + if sub not in _KNOWN_UPLOAD_TARGETS: + unknown.append(f"[upload.{sub}]") + unknown += _unknown_in(data, ("upload", "folder"), tuple(FolderUpload.__annotations__)) + unknown += _unknown_in(data, ("upload", "nextcloud"), tuple(NextcloudUpload.__annotations__)) + unknown += _unknown_in(data, ("upload", "sftp"), tuple(SftpUpload.__annotations__)) + for sub in _section(data, "notify"): + if sub not in _KNOWN_NOTIFY_TARGETS: + unknown.append(f"[notify.{sub}]") + unknown += _unknown_in(data, ("notify", "email"), tuple(EmailNotify.__annotations__)) + unknown += _unknown_in(data, ("logging",), _KNOWN_LOGGING_KEYS) + return unknown + + def load_config(path: str | Path) -> Config: path = Path(path) with path.open("rb") as f: @@ -171,4 +219,54 @@ def load_config(path: str | Path) -> Config: paths=paths, ocr=ocr, output=output, verapdf=verapdf, folder=folder, nextcloud=nextcloud, sftp=sftp, email=email, log_level=log_level, + unknown_keys=_collect_unknown_keys(data), ) + + +# ---- Legacy- und Plausibilitätswarnungen ---- + +# [ocr].timeout war vor 0.4.0 ein (wirkungsloses) Gesamt-Timeout mit Default +# 1800. Ab diesem Wert gehen wir von einem Altwert aus. +LEGACY_TIMEOUT_THRESHOLD = 900 +# Richtwert für das Seiten-Timeout seit 0.4.0 +RECOMMENDED_PAGE_TIMEOUT = 300 + + +def legacy_warnings(cfg: Config) -> list[str]: + """Warnt vor Einträgen, deren Bedeutung sich geändert hat. + + Die Meldungen werden sowohl beim Dienststart ins Log geschrieben als auch + von `--check-config` ausgegeben — deshalb steht der Text nur hier. + """ + out: list[str] = [] + if cfg.ocr.timeout >= LEGACY_TIMEOUT_THRESHOLD: + out.append( + f"[ocr].timeout = {cfg.ocr.timeout}: Seit Version 0.4.0 sind das " + "Sekunden PRO SEITE (vorher ein wirkungsloses Gesamt-Timeout mit " + f"Default 1800). Ein Wert >= {LEGACY_TIMEOUT_THRESHOLD} stammt fast " + "sicher aus einer alten Config und lässt eine einzelne Seite " + f"unnötig lange laufen. Richtwert: {RECOMMENDED_PAGE_TIMEOUT}." + ) + if cfg.ocr.pdfa_level: + out.append( + f"[ocr].pdfa_level = {cfg.ocr.pdfa_level!r}: PDF/A-Ausgabe ist " + "aktiv. Ghostscript 10.0.0-10.02.0 (Debian-12-Default) hat einen " + "Bug, der zusammen mit skip_text das OCR blockiert (Issue #3). Der " + "Preflight bricht ab, falls die installierte Ghostscript-Version " + "betroffen ist; ab 10.02.1 ist alles in Ordnung." + ) + return out + + +def unknown_key_warnings(cfg: Config) -> list[str]: + """Macht die beim Laden verworfenen Einträge sichtbar.""" + return [ + f"Unbekannter Config-Eintrag {key} — wird ignoriert (Tippfehler oder " + "Option aus einer älteren Version?)" + for key in cfg.unknown_keys + ] + + +def config_warnings(cfg: Config) -> list[str]: + """Alle Warnungen zu einer geladenen Config (Legacy + unbekannte Keys).""" + return legacy_warnings(cfg) + unknown_key_warnings(cfg) diff --git a/pdf_ocr_hotfolder/processor.py b/pdf_ocr_hotfolder/processor.py index bc41430..59309df 100644 --- a/pdf_ocr_hotfolder/processor.py +++ b/pdf_ocr_hotfolder/processor.py @@ -14,6 +14,10 @@ log = logging.getLogger(__name__) # Erlaubte Werte für [output].name_mode — wird auch vom Preflight geprüft VALID_NAME_MODES = ("prefix", "suffix", "none") +# Präfix der Zwischendatei, in die ocrmypdf schreibt. Bleibt sie nach einem +# harten Stopp in working/ liegen, ist sie ein unvollständiges Fragment. +OCR_TEMP_PREFIX = "__ocr_" + def build_output_name(src_name: str, mode: str, tag: str) -> str: """Erzeugt den Ziel-Dateinamen für ein OCR-PDF. @@ -114,13 +118,29 @@ def process_pdf( """Verarbeitet eine einzelne PDF: move→OCR→validate→outgoing/error.""" out_name = build_output_name(src.name, output_cfg.name_mode, output_cfg.name_tag) work_src = working_dir / src.name - work_out = working_dir / f"__ocr_{out_name}" # Temp-Name, damit er != src.name ist + work_out = working_dir / f"{OCR_TEMP_PREFIX}{out_name}" # Temp-Name, damit er != src.name ist final_out = outgoing_dir / out_name - try: - shutil.move(str(src), str(work_src)) - except OSError as e: - return ProcessResult(src, final_out, False, f"move to working failed: {e}") + if _is_same_file(src, work_src): + # Wiederaufnahme: die Datei liegt bereits in working/, weil ein + # früherer Lauf hart abgebrochen wurde. Kein zweiter Move — der würde + # die Datei bestenfalls auf sich selbst schieben. + log.warning("Wiederaufnahme aus %s: %s wird erneut per OCR verarbeitet", + working_dir, src.name) + elif work_src.exists(): + # Gleicher Dateiname, andere Datei: ein Move würde den laufenden bzw. + # wiederaufgenommenen Vorgang in working/ stillschweigend überschreiben. + return ProcessResult( + src, final_out, False, + f"in {working_dir} liegt bereits eine andere Datei namens " + f"{src.name} — Original bleibt in {src.parent} liegen und wird " + "beim nächsten Lauf erneut versucht", + ) + else: + try: + shutil.move(str(src), str(work_src)) + except OSError as e: + return ProcessResult(src, final_out, False, f"move to working failed: {e}") try: run_ocr(work_src, work_out, ocr_cfg) @@ -153,6 +173,14 @@ def process_pdf( return ProcessResult(src, final_out, True, verapdf_passed=vera_ok) +def _is_same_file(a: Path, b: Path) -> bool: + """Zeigen beide Pfade auf dieselbe Datei? (verträgt fehlende Dateien)""" + try: + return a.resolve() == b.resolve() + except OSError: + return False + + def _dispose_original(work_src: Path, original_name: str, cfg: OutputConfig) -> None: """Entsorgt das Original laut [output].original_on_success — löschen oder archivieren. diff --git a/pdf_ocr_hotfolder/service.py b/pdf_ocr_hotfolder/service.py index 7bca349..46e1cf2 100644 --- a/pdf_ocr_hotfolder/service.py +++ b/pdf_ocr_hotfolder/service.py @@ -9,13 +9,20 @@ import subprocess import threading import time from concurrent.futures import Future, ThreadPoolExecutor +from datetime import datetime from pathlib import Path from watchdog.events import FileSystemEvent, FileSystemEventHandler from watchdog.observers import Observer from .config import Config -from .processor import VALID_NAME_MODES, ProcessResult, _move_to_error, process_pdf +from .processor import ( + OCR_TEMP_PREFIX, + VALID_NAME_MODES, + ProcessResult, + _move_to_error, + process_pdf, +) from .uploaders import notify_email, upload_folder, upload_nextcloud, upload_sftp log = logging.getLogger(__name__) @@ -216,7 +223,7 @@ class HotfolderService: self.shutdown() def run_once(self) -> int: - """Verarbeitet alle bereits im incoming-Ordner liegenden PDFs und beendet sich. + """Verarbeitet alle bereits liegenden PDFs (incoming/ + working/) und beendet sich. Returns: Anzahl fehlgeschlagener PDFs (0 = alles ok). @@ -243,11 +250,84 @@ class HotfolderService: # ---- Queue ---- def _scan_existing(self) -> None: - """Beim Start: bereits liegende PDFs aufgreifen.""" - for p in self.cfg.paths.incoming.iterdir(): + """Beim Start: bereits liegende PDFs aufgreifen. + + Zuerst working/ (abgebrochene Läufe, siehe `_scan_working`), danach + incoming/. Die Reihenfolge ist wichtig, damit eine Namenskollision + zwischen beiden Verzeichnissen aufgelöst ist, bevor die + incoming-Datei nach working/ will. + """ + self._scan_working() + for p in sorted(self.cfg.paths.incoming.iterdir()): if _is_pdf(p): self.enqueue(p) + def _scan_working(self) -> None: + """Greift Dateien auf, die ein harter Stopp in working/ liegen ließ. + + `process_pdf()` verschiebt das Original vor dem OCR nach working/. + Wird der Dienst dort abgeschossen (SIGKILL nach TimeoutStopSec), + bleibt es liegen und wurde bisher nie wieder angefasst — stiller + Datenverlust. Die Datei wird deshalb an Ort und Stelle + wiederaufgenommen; `process_pdf()` erkennt das und verschiebt sie + nicht erneut. + + Die Zwischendateien des abgebrochenen OCR-Laufs (Präfix `__ocr_`) + sind unvollständige Fragmente: als Eingabe unbrauchbar und als + Ergebnis wertlos. Sie werden gelöscht, damit sie niemand für ein + fertiges PDF hält und damit der neue Lauf sauber startet. + """ + working = self.cfg.paths.working + if not working.is_dir(): + return + for p in sorted(working.iterdir()): + if not p.is_file(): + continue + if p.name.startswith(OCR_TEMP_PREFIX): + log.warning( + "Unvollständiges OCR-Fragment aus abgebrochenem Lauf " + "gefunden und gelöscht: %s", p, + ) + try: + p.unlink() + except OSError: + log.exception("Konnte OCR-Fragment %s nicht löschen", p) + continue + if not _is_pdf(p): + continue + target = self._free_resume_name(p) + log.warning( + "Abgebrochener Lauf wird fortgesetzt: %s lag noch in %s " + "(Dienst wurde vermutlich hart gestoppt) — OCR startet neu", + target.name, working, + ) + self.enqueue(target) + + def _free_resume_name(self, p: Path) -> Path: + """Entschärft eine Namenskollision zwischen working/ und incoming/. + + Liegt in incoming/ eine gleichnamige (aber andere) Datei, würden beide + dieselbe working- und dieselbe outgoing-Datei beanspruchen. Die + wiederaufgenommene Datei bekommt deshalb einen Zeitstempel angehängt — + dann laufen beide durch, statt dass eine überschrieben wird. + """ + if not (self.cfg.paths.incoming / p.name).exists(): + return p + ts = datetime.now().strftime("%Y%m%d-%H%M%S") + renamed = p.with_name(f"{p.stem}_{ts}{p.suffix}") + try: + p.rename(renamed) + except OSError: + log.exception("Konnte %s nicht umbenennen — Wiederaufnahme unter " + "Originalnamen", p) + return p + log.warning( + "In %s liegt eine gleichnamige Datei %s — die wiederaufgenommene " + "Datei wurde nach %s umbenannt, damit sich beide nicht " + "überschreiben", self.cfg.paths.incoming, p.name, renamed.name, + ) + return renamed + def enqueue(self, path: Path) -> None: if not _is_pdf(path): return diff --git a/requirements.txt b/requirements.txt index d281fc8..e4bfff5 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,4 +1,9 @@ -ocrmypdf>=16.0 -watchdog>=4.0 -requests>=2.31 -paramiko>=3.4 +# Feste Pins: ein Update darf nicht ungefragt einen Major-Sprung einziehen +# (ein ocrmypdf 16 -> 17 reisst sonst alle Instanzen auf einmal). +# Geprueft gegen Python 3.11 (Debian 12) und 3.13 (Debian 13) — fuer beide +# gibt es fertige Wheels, es wird nichts kompiliert. +# Beim Anheben: update.sh --rebuild-venv auf einer Testmaschine fahren. +ocrmypdf==16.13.0 +watchdog==6.0.0 +requests==2.33.1 +paramiko==4.0.0 diff --git a/systemd/pdf-ocr-hotfolder@.service b/systemd/pdf-ocr-hotfolder@.service index 5d7652b..81cb9ec 100644 --- a/systemd/pdf-ocr-hotfolder@.service +++ b/systemd/pdf-ocr-hotfolder@.service @@ -12,7 +12,10 @@ ExecStart=/opt/pdf-ocr-hotfolder/venv/bin/python -m pdf_ocr_hotfolder --config / Restart=on-failure RestartSec=5 KillMode=mixed -TimeoutStopSec=30 +# Ein laufendes OCR soll beim Stoppen zu Ende laufen duerfen. Bei SIGKILL +# bliebe das Original in working/ liegen (wird beim naechsten Start zwar +# wiederaufgenommen, kostet aber den kompletten Durchlauf). +TimeoutStopSec=300 # Hardening (lockerer wegen AD-User & Datei-ACLs) NoNewPrivileges=true diff --git a/tests/test_check_config.py b/tests/test_check_config.py new file mode 100644 index 0000000..552df08 --- /dev/null +++ b/tests/test_check_config.py @@ -0,0 +1,165 @@ +"""Tests für `--check-config`. + +Die Exit-Codes werden vom Updater ausgewertet und müssen verlässlich sein: +0 = sauber, 1 = nur Warnungen, 2 = Fehler. +""" +from __future__ import annotations + +import sys +from pathlib import Path +from unittest.mock import patch + +import pytest + +from pdf_ocr_hotfolder.__main__ import CHECK_ERROR, CHECK_OK, CHECK_WARN, main + + +def _cfg_file(tmp_path: Path, tmp_config, extra: str = "") -> Path: + 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}" +""" + extra) + return cfg_file + + +def _check(monkeypatch, cfg_file: Path, binaries_present: bool = True) -> int: + monkeypatch.setattr(sys, "argv", + ["pdf-ocr-hotfolder", "--config", str(cfg_file), + "--check-config"]) + which = "/usr/bin/fake" if binaries_present else None + with patch("pdf_ocr_hotfolder.service.shutil.which", return_value=which): + return main() + + +# ---------------- Exit 0 ---------------- + +def test_clean_config_returns_0(tmp_path, tmp_config, monkeypatch, capsys) -> None: + rc = _check(monkeypatch, _cfg_file(tmp_path, tmp_config)) + assert rc == CHECK_OK + out = capsys.readouterr().out + assert "Config sauber" in out + + +def test_check_does_not_process_files(tmp_path, tmp_config, monkeypatch) -> None: + """Der Check darf nichts verarbeiten und nichts verschieben.""" + pdf = tmp_config.paths.incoming / "scan.pdf" + pdf.write_bytes(b"%PDF-1.4\n") + + assert _check(monkeypatch, _cfg_file(tmp_path, tmp_config)) == CHECK_OK + assert pdf.exists() + assert list(tmp_config.paths.outgoing.iterdir()) == [] + + +# ---------------- Exit 1 (nur Warnungen) ---------------- + +def test_legacy_timeout_returns_1(tmp_path, tmp_config, monkeypatch, capsys) -> None: + rc = _check(monkeypatch, _cfg_file(tmp_path, tmp_config, + "\n[ocr]\ntimeout = 1800\n")) + assert rc == CHECK_WARN + out = capsys.readouterr().out + assert "WARNUNG" in out + assert "PRO SEITE" in out + + +def test_unknown_key_returns_1(tmp_path, tmp_config, monkeypatch, capsys) -> None: + rc = _check(monkeypatch, _cfg_file(tmp_path, tmp_config, + '\n[ocr]\nlangauges = "deu"\n')) + assert rc == CHECK_WARN + assert "[ocr].langauges" in capsys.readouterr().out + + +def test_pdfa_level_with_healthy_ghostscript_returns_1( + tmp_path, tmp_config, monkeypatch, capsys) -> None: + """Gesundes Ghostscript: nur Hinweis (Exit 1), kein Abbruch.""" + monkeypatch.setattr(sys, "argv", + ["pdf-ocr-hotfolder", "--config", + str(_cfg_file(tmp_path, tmp_config, + '\n[ocr]\npdfa_level = "2"\n')), + "--check-config"]) + with patch("pdf_ocr_hotfolder.service.shutil.which", return_value="/usr/bin/fake"), \ + patch("pdf_ocr_hotfolder.service.detect_ghostscript_version", + return_value="10.02.1"): + rc = main() + assert rc == CHECK_WARN + assert "Ghostscript" in capsys.readouterr().out + + +# ---------------- Exit 2 (Fehler) ---------------- + +def test_missing_config_file_returns_2(tmp_path, monkeypatch, capsys) -> None: + monkeypatch.setattr(sys, "argv", + ["pdf-ocr-hotfolder", "--config", + str(tmp_path / "gibtsnicht.toml"), "--check-config"]) + assert main() == CHECK_ERROR + assert "nicht gefunden" in capsys.readouterr().err + + +def test_broken_paths_section_returns_2(tmp_path, monkeypatch, capsys) -> None: + cfg_file = tmp_path / "cfg.toml" + cfg_file.write_text('[ocr]\nlanguages = "deu"\n') + assert _check(monkeypatch, cfg_file) == CHECK_ERROR + assert "[paths]" in capsys.readouterr().err + + +def test_invalid_toml_returns_2(tmp_path, monkeypatch, capsys) -> None: + """Kaputtes TOML: saubere Meldung statt Traceback.""" + cfg_file = tmp_path / "cfg.toml" + cfg_file.write_text("[paths\nincoming = ") + assert _check(monkeypatch, cfg_file) == CHECK_ERROR + assert "TOML" in capsys.readouterr().err + + +def test_missing_binaries_return_2(tmp_path, tmp_config, monkeypatch, capsys) -> None: + rc = _check(monkeypatch, _cfg_file(tmp_path, tmp_config), + binaries_present=False) + assert rc == CHECK_ERROR + err = capsys.readouterr().err + assert "tesseract" in err + + +def test_invalid_name_mode_returns_2(tmp_path, tmp_config, monkeypatch, capsys) -> None: + rc = _check(monkeypatch, _cfg_file(tmp_path, tmp_config, + '\n[output]\nname_mode = "praefix"\n')) + assert rc == CHECK_ERROR + assert "name_mode" in capsys.readouterr().err + + +def test_archive_without_dir_returns_2(tmp_path, tmp_config, monkeypatch, capsys) -> None: + rc = _check(monkeypatch, _cfg_file(tmp_path, tmp_config, + '\n[output]\noriginal_on_success = "archive"\n')) + assert rc == CHECK_ERROR + assert "archive_dir" in capsys.readouterr().err + + +def test_error_beats_warning(tmp_path, tmp_config, monkeypatch) -> None: + """Fehler + Warnung → Exit 2, nicht 1.""" + rc = _check(monkeypatch, + _cfg_file(tmp_path, tmp_config, + '\n[ocr]\ntimeout = 1800\n\n[output]\nname_mode = "x"\n')) + assert rc == CHECK_ERROR + + +# ---------------- Zusammenspiel mit anderen Optionen ---------------- + +def test_check_config_wins_over_once(tmp_path, tmp_config, monkeypatch) -> None: + """--check-config hat Vorrang: es wird nichts verarbeitet.""" + pdf = tmp_config.paths.incoming / "scan.pdf" + pdf.write_bytes(b"%PDF-1.4\n") + monkeypatch.setattr(sys, "argv", + ["pdf-ocr-hotfolder", "--config", + str(_cfg_file(tmp_path, tmp_config)), + "--once", "--check-config"]) + with patch("pdf_ocr_hotfolder.service.shutil.which", return_value="/usr/bin/fake"): + assert main() == CHECK_OK + assert pdf.exists() + + +@pytest.mark.parametrize("code,expected", [(CHECK_OK, 0), (CHECK_WARN, 1), + (CHECK_ERROR, 2)]) +def test_exit_code_constants(code: int, expected: int) -> None: + """Die Konstanten sind Teil der Schnittstelle zum Updater.""" + assert code == expected diff --git a/tests/test_config_warnings.py b/tests/test_config_warnings.py new file mode 100644 index 0000000..987c5b4 --- /dev/null +++ b/tests/test_config_warnings.py @@ -0,0 +1,198 @@ +"""Tests für Legacy-Warnungen und unbekannte Config-Keys. + +Zwei stille Fallen: +- [ocr].timeout bedeutet seit 0.4.0 Sekunden pro SEITE (vorher Gesamtlauf) +- unbekannte Keys (Tippfehler!) wurden beim Laden stumm verworfen +""" +from __future__ import annotations + +import logging +import sys +from pathlib import Path +from unittest.mock import patch + +from pdf_ocr_hotfolder.config import ( + config_warnings, + legacy_warnings, + load_config, + unknown_key_warnings, +) + +_PATHS = """ +[paths] +incoming = "/tmp/in" +outgoing = "/tmp/out" +working = "/tmp/work" +error = "/tmp/err" +""" + + +def _write(tmp_path: Path, extra: str = "") -> Path: + cfg = tmp_path / "config.toml" + cfg.write_text(_PATHS + extra) + return cfg + + +# ---------------- Legacy: [ocr].timeout ---------------- + +def test_legacy_timeout_warns(tmp_path: Path) -> None: + """Ein Altwert (Gesamt-Timeout 1800) muss deutlich benannt werden.""" + cfg = load_config(_write(tmp_path, "\n[ocr]\ntimeout = 1800\n")) + warnings = legacy_warnings(cfg) + assert len(warnings) == 1 + assert "timeout" in warnings[0] + assert "PRO SEITE" in warnings[0] + assert "300" in warnings[0] + + +def test_timeout_at_threshold_warns(tmp_path: Path) -> None: + cfg = load_config(_write(tmp_path, "\n[ocr]\ntimeout = 900\n")) + assert legacy_warnings(cfg) + + +def test_sane_timeout_does_not_warn(tmp_path: Path) -> None: + cfg = load_config(_write(tmp_path, "\n[ocr]\ntimeout = 300\n")) + assert legacy_warnings(cfg) == [] + + +def test_default_config_has_no_warnings(tmp_path: Path) -> None: + cfg = load_config(_write(tmp_path)) + assert config_warnings(cfg) == [] + + +# ---------------- Legacy: [ocr].pdfa_level ---------------- + +def test_pdfa_level_warns_about_ghostscript(tmp_path: Path) -> None: + cfg = load_config(_write(tmp_path, '\n[ocr]\npdfa_level = "2"\n')) + warnings = legacy_warnings(cfg) + assert len(warnings) == 1 + assert "pdfa_level" in warnings[0] + assert "Ghostscript" in warnings[0] + assert "10.02.0" in warnings[0] + + +def test_empty_pdfa_level_does_not_warn(tmp_path: Path) -> None: + cfg = load_config(_write(tmp_path, '\n[ocr]\npdfa_level = ""\n')) + assert legacy_warnings(cfg) == [] + + +def test_both_legacy_warnings_together(tmp_path: Path) -> None: + cfg = load_config(_write(tmp_path, '\n[ocr]\ntimeout = 1800\npdfa_level = "1"\n')) + assert len(legacy_warnings(cfg)) == 2 + + +# ---------------- Unbekannte Keys ---------------- + +def test_typo_key_is_collected(tmp_path: Path) -> None: + """`langauges` statt `languages` darf nicht mehr stumm verschwinden.""" + cfg = load_config(_write(tmp_path, '\n[ocr]\nlangauges = "deu"\n')) + assert cfg.unknown_keys == ["[ocr].langauges"] + assert "[ocr].langauges" in unknown_key_warnings(cfg)[0] + # Der Rest wird weiterhin normal geladen + assert cfg.ocr.languages == "deu+eng" + + +def test_known_keys_are_not_reported(tmp_path: Path) -> None: + cfg = load_config(_write(tmp_path, '\n[ocr]\nlanguages = "deu"\njobs = 2\n')) + assert cfg.unknown_keys == [] + assert cfg.ocr.languages == "deu" + + +def test_unknown_keys_in_all_sections(tmp_path: Path) -> None: + cfg = load_config(_write(tmp_path, """ +[ocr] +foo = 1 + +[output] +bar = "x" + +[verapdf] +baz = true + +[upload.folder] +qux = "" + +[upload.nextcloud] +quux = "" + +[upload.sftp] +corge = 0 + +[notify.email] +grault = "" + +[logging] +level = "INFO" +garply = 1 +""")) + assert cfg.unknown_keys == [ + "[ocr].foo", "[output].bar", "[verapdf].baz", + "[upload.folder].qux", "[upload.nextcloud].quux", "[upload.sftp].corge", + "[notify.email].grault", "[logging].garply", + ] + + +def test_unknown_top_level_key_is_reported(tmp_path: Path) -> None: + """Ein Key ausserhalb jeder Sektion (z.B. vergessene Sektionszeile).""" + cfg_file = tmp_path / "config.toml" + cfg_file.write_text('log_level = "DEBUG"\n' + _PATHS) + cfg = load_config(cfg_file) + assert cfg.unknown_keys == ["log_level"] + + +def test_unknown_section_is_reported(tmp_path: Path) -> None: + cfg = load_config(_write(tmp_path, '\n[ocrr]\nlanguages = "deu"\n\n[upload.ftp]\nhost = "x"\n')) + assert "[ocrr]" in cfg.unknown_keys + assert "[upload.ftp]" in cfg.unknown_keys + + +def test_unknown_key_inside_paths(tmp_path: Path) -> None: + """Ein zusätzlicher Key direkt in [paths] wird ebenfalls gemeldet.""" + cfg_file = tmp_path / "config.toml" + cfg_file.write_text(_PATHS + 'archive = "/tmp/a"\n') + cfg = load_config(cfg_file) + assert cfg.unknown_keys == ["[paths].archive"] + + +def test_load_config_works_without_logging(tmp_path: Path) -> None: + """load_config() darf nichts loggen müssen — Tests rufen sie direkt auf.""" + cfg_file = _write(tmp_path, '\n[ocr]\nlangauges = "deu"\n') + with patch("logging.Logger.warning") as warn: + cfg = load_config(cfg_file) + warn.assert_not_called() + assert cfg.unknown_keys + + +# ---------------- Warnungen beim Dienststart ---------------- + +def _argv(monkeypatch, cfg_file: Path, *extra: str) -> None: + monkeypatch.setattr(sys, "argv", + ["pdf-ocr-hotfolder", "--config", str(cfg_file), *extra]) + + +def test_warnings_are_logged_on_service_start(tmp_path: Path, tmp_config, + monkeypatch, caplog) -> None: + """Beim normalen Start landen die Warnungen im Log (nicht nur im Check).""" + cfg_file = tmp_path / "cfg.toml" + cfg_file.write_text(f""" +[paths] +incoming = "{tmp_config.paths.incoming}" +outgoing = "{tmp_config.paths.outgoing}" +working = "{tmp_config.paths.working}" +error = "{tmp_config.paths.error}" + +[ocr] +timeout = 1800 +langauges = "deu" +""") + _argv(monkeypatch, cfg_file, "--once") + + from pdf_ocr_hotfolder.__main__ import main + + with caplog.at_level(logging.WARNING, logger="pdf_ocr_hotfolder.__main__"), \ + patch("pdf_ocr_hotfolder.service.shutil.which", return_value="/usr/bin/fake"): + assert main() == 0 + + text = caplog.text + assert "PRO SEITE" in text + assert "[ocr].langauges" in text diff --git a/tests/test_resume_working.py b/tests/test_resume_working.py new file mode 100644 index 0000000..4158988 --- /dev/null +++ b/tests/test_resume_working.py @@ -0,0 +1,204 @@ +"""Tests für die Wiederaufnahme abgebrochener Läufe aus working/. + +Hintergrund: `process_pdf()` verschiebt das Original vor dem OCR nach +working/. Wird der Dienst dort hart gestoppt (SIGKILL nach TimeoutStopSec), +blieb die Datei bisher für immer liegen — weder outgoing/, noch error/, noch +eine Mail. ocrmypdf läuft in diesen Tests nie wirklich. +""" +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 OCR_TEMP_PREFIX, ProcessResult, process_pdf +from pdf_ocr_hotfolder.service import HotfolderService + + +def _fake_success(src: Path, working_dir, outgoing_dir, error_dir, **kwargs): + """Simuliert einen erfolgreichen Durchlauf inkl. Entsorgung des Originals.""" + out = outgoing_dir / f"OCR_{src.name}" + out.parent.mkdir(parents=True, exist_ok=True) + out.write_bytes(b"%PDF-1.4 ocr\n") + src.unlink(missing_ok=True) + return ProcessResult(src, out, True) + + +def _run_once(tmp_config, fake_process=_fake_success): + """run_once() mit gemocktem Preflight und gemocktem process_pdf.""" + seen: list[Path] = [] + + def spy(src, *args, **kwargs): + seen.append(src) + return fake_process(src, *args, **kwargs) + + with patch("pdf_ocr_hotfolder.service.check_preflight", return_value=None), \ + patch("pdf_ocr_hotfolder.service.process_pdf", side_effect=spy), \ + patch("pdf_ocr_hotfolder.service._wait_until_stable", return_value=True): + service = HotfolderService(tmp_config) + try: + service.run_once() + finally: + service._executor.shutdown(wait=False) + return service, seen + + +# ---------------- Aufgreifen aus working/ ---------------- + +def test_leftover_in_working_is_picked_up(tmp_config) -> None: + """Eine in working/ liegen gebliebene PDF wird wieder verarbeitet.""" + leftover = tmp_config.paths.working / "abgebrochen.pdf" + leftover.write_bytes(b"%PDF-1.4\n") + + service, seen = _run_once(tmp_config) + + assert [p.name for p in seen] == ["abgebrochen.pdf"] + assert seen[0].parent == tmp_config.paths.working + assert service.success_count == 1 + assert not leftover.exists() + assert (tmp_config.paths.outgoing / "OCR_abgebrochen.pdf").exists() + + +def test_resume_logs_warning(tmp_config, caplog) -> None: + """Die Wiederaufnahme muss deutlich im Log stehen.""" + (tmp_config.paths.working / "abgebrochen.pdf").write_bytes(b"%PDF-1.4\n") + + with caplog.at_level(logging.WARNING, logger="pdf_ocr_hotfolder.service"): + _run_once(tmp_config) + + text = caplog.text + assert "Abgebrochener Lauf wird fortgesetzt" in text + assert "abgebrochen.pdf" in text + + +def test_ocr_fragment_is_removed_and_not_processed(tmp_config, caplog) -> None: + """__ocr_-Fragmente sind unbrauchbar: löschen, nicht als Eingabe nehmen.""" + leftover = tmp_config.paths.working / "scan.pdf" + leftover.write_bytes(b"%PDF-1.4\n") + fragment = tmp_config.paths.working / f"{OCR_TEMP_PREFIX}OCR_scan.pdf" + fragment.write_bytes(b"%PDF-1.4 halbfertig\n") + + with caplog.at_level(logging.WARNING, logger="pdf_ocr_hotfolder.service"): + service, seen = _run_once(tmp_config) + + assert [p.name for p in seen] == ["scan.pdf"] + assert not fragment.exists() + assert service.error_count == 0 + assert "Fragment" in caplog.text + + +def test_non_pdf_in_working_is_ignored(tmp_config) -> None: + """Fremddateien in working/ werden nicht angefasst.""" + junk = tmp_config.paths.working / "notizen.txt" + junk.write_text("kein PDF") + + _service, seen = _run_once(tmp_config) + + assert seen == [] + assert junk.exists() + + +def test_incoming_and_working_both_scanned(tmp_config) -> None: + """incoming/ wird weiterhin gescannt — zusätzlich zu working/.""" + (tmp_config.paths.incoming / "neu.pdf").write_bytes(b"%PDF-1.4\n") + (tmp_config.paths.working / "alt.pdf").write_bytes(b"%PDF-1.4\n") + + service, seen = _run_once(tmp_config) + + assert sorted(p.name for p in seen) == ["alt.pdf", "neu.pdf"] + assert service.success_count == 2 + + +def test_name_collision_between_working_and_incoming(tmp_config, caplog) -> None: + """Gleicher Name in beiden Ordnern: die working-Datei wird umbenannt. + + Sonst würden sich beide dieselbe working- und dieselbe outgoing-Datei + teilen und eine der beiden ginge verloren. + """ + (tmp_config.paths.incoming / "scan.pdf").write_bytes(b"%PDF-1.4 neu\n") + (tmp_config.paths.working / "scan.pdf").write_bytes(b"%PDF-1.4 alt\n") + + with caplog.at_level(logging.WARNING, logger="pdf_ocr_hotfolder.service"): + service, seen = _run_once(tmp_config) + + names = sorted(p.name for p in seen) + assert len(names) == 2 + assert "scan.pdf" in names + # Die wiederaufgenommene Datei hat einen Zeitstempel bekommen + renamed = [n for n in names if n != "scan.pdf"][0] + assert renamed.startswith("scan_") and renamed.endswith(".pdf") + assert service.success_count == 2 + assert "umbenannt" in caplog.text + + +# ---------------- process_pdf: kein zweiter Move ---------------- + +def _ocr_ok(src: Path, dst: Path, cfg) -> None: + dst.write_bytes(b"%PDF-1.4 ocr\n") + + +def test_process_pdf_resumes_without_second_move(tmp_config) -> None: + """Eine Datei aus working/ darf nicht erneut nach working/ verschoben werden.""" + src = tmp_config.paths.working / "scan.pdf" + src.write_bytes(b"%PDF-1.4\n") + + with patch("pdf_ocr_hotfolder.processor.run_ocr", side_effect=_ocr_ok): + result = process_pdf( + src=src, + working_dir=tmp_config.paths.working, + outgoing_dir=tmp_config.paths.outgoing, + error_dir=tmp_config.paths.error, + ocr_cfg=OcrConfig(), + vera_cfg=VeraPdfConfig(enabled=False), + output_cfg=OutputConfig(), + ) + + assert result.success + assert (tmp_config.paths.outgoing / "OCR_scan.pdf").exists() + # Original entsorgt, keine Reste in working/ + assert list(tmp_config.paths.working.iterdir()) == [] + + +def test_process_pdf_resume_logs_warning(tmp_config, caplog) -> None: + src = tmp_config.paths.working / "scan.pdf" + src.write_bytes(b"%PDF-1.4\n") + + with caplog.at_level(logging.WARNING, logger="pdf_ocr_hotfolder.processor"), \ + patch("pdf_ocr_hotfolder.processor.run_ocr", side_effect=_ocr_ok): + process_pdf( + src=src, + working_dir=tmp_config.paths.working, + outgoing_dir=tmp_config.paths.outgoing, + error_dir=tmp_config.paths.error, + ocr_cfg=OcrConfig(), + vera_cfg=VeraPdfConfig(enabled=False), + output_cfg=OutputConfig(), + ) + + assert "Wiederaufnahme" in caplog.text + + +def test_process_pdf_refuses_to_overwrite_working_file(tmp_config) -> None: + """Belegter Name in working/: lieber Fehler als stilles Überschreiben.""" + busy = tmp_config.paths.working / "scan.pdf" + busy.write_bytes(b"%PDF-1.4 laeuft gerade\n") + src = tmp_config.paths.incoming / "scan.pdf" + src.write_bytes(b"%PDF-1.4 neu\n") + + with patch("pdf_ocr_hotfolder.processor.run_ocr", side_effect=_ocr_ok): + result = process_pdf( + src=src, + working_dir=tmp_config.paths.working, + outgoing_dir=tmp_config.paths.outgoing, + error_dir=tmp_config.paths.error, + ocr_cfg=OcrConfig(), + vera_cfg=VeraPdfConfig(enabled=False), + output_cfg=OutputConfig(), + ) + + assert not result.success + assert "scan.pdf" in result.error + # Beide Dateien unangetastet + assert busy.read_bytes() == b"%PDF-1.4 laeuft gerade\n" + assert src.read_bytes() == b"%PDF-1.4 neu\n" diff --git a/update.sh b/update.sh index cf83be6..37b433a 100755 --- a/update.sh +++ b/update.sh @@ -3,24 +3,685 @@ # PDF OCR Hotfolder — Update-Script # # Aktualisiert Code und venv unter /opt/pdf-ocr-hotfolder/ sowie die -# systemd Template-Unit. Danach werden alle laufenden Instanzen neu gestartet. -# Config-Dateien unter /etc/pdf-ocr-hotfolder/ bleiben unverändert. +# systemd Template-Unit. Danach werden alle Instanzen neu gestartet, die +# vorher laufen sollten. Config-Dateien unter /etc/pdf-ocr-hotfolder/ +# bleiben unveraendert. # -set -euo pipefail +# Ueberlebt Debian-Major-Upgrades (12 -> 13 -> 14): stimmt die Python-Version +# der venv nicht mehr mit dem System-Python ueberein oder ist der Interpreter +# der venv gar nicht mehr da, wird die venv neu gebaut (ganz oder gar nicht). +# +# Aufruf: +# sudo ./update.sh # normales Update +# sudo ./update.sh --rebuild-venv # venv-Neubau erzwingen (nach dist-upgrade) +# +set -Eeuo pipefail -RED='\033[0;31m'; GREEN='\033[0;32m'; YELLOW='\033[1;33m'; NC='\033[0m' +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 ./update.sh" - exit 1 +# 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}" +: "${BACKUP_DIR:=/var/backups/pdf-ocr-hotfolder}" +: "${TAR_ROOT:=/}" +: "${VERIFY_WAIT:=6}" +: "${BACKUP_KEEP:=5}" +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" +TOUCHED=0 # 1, sobald auf der Platte etwas getauscht wurde + +UNITS_ALL=() # alle bekannten Instanz-Units +PREV_OK=() # liefen vorher sauber -> muessen nachher laufen +PREV_BROKEN=() # waren vorher kaputt -> sollen laufen, gelten aber nicht als Erfolg +PREV_STOPPED=() # waren bewusst gestoppt -> bleiben gestoppt +CFG_WARN=() # Instanzen mit Config-Warnungen (Exit 1) +CFG_ERR=() # Instanzen mit Config-Fehlern (Exit 2) +STARTED_OK=() +STARTED_FAIL=() + +usage() { + cat < 13), wenn das + System-Python eine neue Version bekommen hat. + -h, --help Diese Hilfe. + +Ohne Option prueft das Skript selbst, ob die venv noch zum System-Python +passt, und baut sie bei Bedarf neu. +EOF +} + +# ============================================================ +# Abbruch-Behandlung +# ============================================================ + +CLEANUP_DONE=0 + +# Startet bei einem Abbruch die vorher laufenden Instanzen wieder und sagt +# laut, was passiert ist. +abort_handler() { + local rc="${1:-1}" line="${2:-?}" + if [ "$CLEANUP_DONE" -ne 0 ]; then + exit "$rc" + fi + CLEANUP_DONE=1 + trap - ERR INT TERM + echo + log_error "═══════════════════════════════════════════════════════════════" + log_error "UPDATE ABGEBROCHEN (Exit-Code $rc, Zeile $line)." + if [ "$TOUCHED" -eq 1 ]; then + log_error "Der Stand auf der Platte kann halb aktualisiert sein." + else + log_error "Es wurde noch nichts getauscht — der alte Stand ist unveraendert." + fi + log_error "═══════════════════════════════════════════════════════════════" + + local -a to_restart=() + [ "${#PREV_OK[@]}" -gt 0 ] && to_restart+=("${PREV_OK[@]}") + [ "${#PREV_BROKEN[@]}" -gt 0 ] && to_restart+=("${PREV_BROKEN[@]}") + if [ "${#to_restart[@]}" -gt 0 ]; then + log_warn "Starte die vorher laufenden Instanzen wieder..." + local unit + for unit in "${to_restart[@]}"; do + if systemctl start "$unit" >/dev/null 2>&1; then + log_info " wieder gestartet: $unit" + else + log_error " Start fehlgeschlagen: $unit (journalctl -u $unit -n 50)" + fi + done + else + log_warn "Es liefen vorher keine Instanzen — nichts wieder zu starten." + fi + + if [ -n "$BACKUP_FILE" ] && [ -f "$BACKUP_FILE" ]; then + log_warn "Backup vor dem Update: $BACKUP_FILE" + log_warn "Rollback: tar -xzf '$BACKUP_FILE' -C / && systemctl daemon-reload" + else + log_warn "Es wurde noch KEIN Backup geschrieben." + fi + log_warn "Aeltere Backups: $BACKUP_DIR" + exit "$rc" +} + +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 +} + +# Installiert die Requirements und uebersetzt pip-Fehler in eine Ansage, mit +# der man etwas anfangen kann (typisch: Pin passt nicht mehr zum Python). +pip_install_requirements() { + local venv="$1" out rc py_mm_now + py_mm_now="$(py_mm "$venv/bin/python")" + + out="$("$venv/bin/pip" install --upgrade pip 2>&1)" && rc=0 || rc=$? + if [ "$rc" -ne 0 ]; then + log_warn "pip liess sich nicht aktualisieren (weiter mit der vorhandenen Version):" + printf '%s\n' "$out" | tail -n 5 + fi + + out="$("$venv/bin/pip" install --upgrade -r "$INSTALL_DIR/requirements.txt" 2>&1)" && rc=0 || rc=$? + if [ "$rc" -eq 0 ]; then + return 0 + fi + + echo + log_error "Installation der Requirements fehlgeschlagen (pip Exit $rc)." + printf '%s\n' "$out" | tail -n 25 + echo + local bad + bad="$(printf '%s\n' "$out" | sed -n \ + -e 's/.*[Nn]o matching distribution found for \([^ ]*\).*/\1/p' \ + -e 's/.*Could not find a version that satisfies the requirement \([^ ]*\).*/\1/p' \ + -e 's/.*Failed building wheel for \([^ ]*\).*/\1/p' \ + -e 's/.*error: subprocess-exited-with-error.*building \([^ ]*\).*/\1/p' \ + | head -n1)" + if [ -n "$bad" ]; then + log_error "Gescheitertes Paket: $bad" + fi + log_error "Sehr wahrscheinliche Ursache: eine in requirements.txt fest gepinnte" + log_error "Version gibt es fuer Python ${py_mm_now:-?} nicht (mehr)." + log_error "Naechster Schritt: requirements.txt anheben (passende Version fuer" + log_error "Python ${py_mm_now:-?} eintragen) und update.sh --rebuild-venv erneut laufen lassen." + return 1 +} + +# Baut die venv neu: alte wegsichern, neue bauen, Requirements installieren, +# und erst bei Erfolg die alte entfernen. Scheitert etwas, wird die alte +# zurueckgerollt und hart abgebrochen. +rebuild_venv() { + local venv="$INSTALL_DIR/venv" + local saved sys_mm + saved="$INSTALL_DIR/venv.old-$(date +%Y%m%d-%H%M%S)" + sys_mm="$(py_mm "$(command -v python3 || echo /usr/bin/python3)")" + + log_step "venv wird neu gebaut (System-Python ${sys_mm:-?})" + if [ -d "$venv" ]; then + mv "$venv" "$saved" || die "Alte venv liess sich nicht nach $saved verschieben." + log_info "Alte venv gesichert: $saved" + else + saved="" + log_info "Keine alte venv vorhanden — es wird frisch gebaut." + fi + + if ! python3 -m venv "$venv"; then + log_error "python3 -m venv ist fehlgeschlagen." + log_error "Fehlt das Paket python3-venv? -> apt-get install -y python3-venv" + rm -rf "$venv" + if [ -n "$saved" ]; then + mv "$saved" "$venv" && log_warn "Alte venv wurde zurueckgerollt: $venv" + fi + die "venv-Neubau abgebrochen." + fi + + if ! pip_install_requirements "$venv"; then + rm -rf "$venv" + if [ -n "$saved" ]; then + mv "$saved" "$venv" && log_warn "Alte venv wurde zurueckgerollt: $venv" + log_warn "Sie haengt weiterhin an einem Python, das es so nicht mehr gibt —" + log_warn "die Instanzen laufen damit nicht. Erst requirements.txt korrigieren." + fi + die "venv-Neubau abgebrochen — keine halb gefuellte venv zurueckgelassen." + fi + + [ -n "$saved" ] && rm -rf "$saved" + VENV_REBUILT=1 + log_info "venv neu gebaut ✓ (Python ${sys_mm:-?})" +} + +# ============================================================ +# System-Pakete +# ============================================================ + +# Holt die Paketliste aus install.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). +sync_system_packages() { + local block pkgs + 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." + 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 + return 0 + fi + + mapfile -t pkgs < <(pdf_ocr_apt_packages) + if [ "${#pkgs[@]}" -eq 0 ]; then + log_warn "Paketliste ist leer — uebersprungen." + APT_WARN=1 + return 0 + fi + log_info "Pakete: ${pkgs[*]}" + + if ! apt-get update -qq; then + log_warn "'apt-get update' fehlgeschlagen (kein Netz / kein Mirror?)." + APT_WARN=1 + fi + if apt-get install -y --no-install-recommends "${pkgs[@]}"; then + log_info "System-Pakete ok ✓ (vorhandene Sprachpakete bleiben unangetastet)" + else + log_warn "Mindestens ein System-Paket liess sich nicht installieren." + log_warn "Falls eine neue Version ein neues Paket braucht, laufen die Instanzen evtl. nicht." + APT_WARN=1 + fi +} + +# ============================================================ +# Instanzen erfassen +# ============================================================ + +unit_state() { systemctl is-active "$1" 2>/dev/null || true; } +unit_failed() { systemctl is-failed "$1" 2>/dev/null || true; } +unit_restarts() { + local n + n="$(systemctl show -p NRestarts --value "$1" 2>/dev/null || true)" + case "$n" in (''|*[!0-9]*) echo 0 ;; (*) echo "$n" ;; esac +} + +# Sammelt alle Instanz-Units: geladene (aktiv, activating, failed), per +# list-unit-files enabled, und alle, fuer die eine Config existiert. +collect_instances() { + local -A seen=() + local unit name line state failed cfg + + while read -r line; do + unit="$(printf '%s' "$line" | awk '{print $1}')" + unit="${unit#●}" + [ -n "$unit" ] || continue + case "$unit" in + pdf-ocr-hotfolder@.service) continue ;; + pdf-ocr-hotfolder@?*.service) seen["$unit"]=1 ;; + esac + done < <(systemctl list-units --all --no-legend --plain "$UNIT_GLOB" 2>/dev/null || true) + + while read -r line; do + unit="$(printf '%s' "$line" | awk '{print $1}')" + [ -n "$unit" ] || continue + case "$unit" in + pdf-ocr-hotfolder@.service) continue ;; + pdf-ocr-hotfolder@?*.service) seen["$unit"]=1 ;; + esac + done < <(systemctl list-unit-files --no-legend --plain "$UNIT_GLOB" 2>/dev/null || true) + + shopt -s nullglob + for cfg in "$CONFIG_DIR"/*.toml; do + name="$(basename "$cfg" .toml)" + [ -n "$name" ] || continue + seen["pdf-ocr-hotfolder@${name}.service"]=1 + done + shopt -u nullglob + + UNITS_ALL=(); PREV_OK=(); PREV_BROKEN=(); PREV_STOPPED=() + [ "${#seen[@]}" -gt 0 ] || return 0 + mapfile -t UNITS_ALL < <(printf '%s\n' "${!seen[@]}" | sort) + + for unit in "${UNITS_ALL[@]}"; do + state="$(unit_state "$unit")" + failed="$(unit_failed "$unit")" + case "$state" in + active) + if [ "$failed" = "failed" ]; then + PREV_BROKEN+=("$unit") + else + PREV_OK+=("$unit") + fi + ;; + activating|reloading|deactivating|failed) + PREV_BROKEN+=("$unit") + ;; + *) + if [ "$failed" = "failed" ]; then + PREV_BROKEN+=("$unit") + else + PREV_STOPPED+=("$unit") + fi + ;; + esac + done +} + +report_instances() { + if [ "${#PREV_OK[@]}" -gt 0 ]; then + log_info "Lief vorher sauber (${#PREV_OK[@]}): ${PREV_OK[*]}" + else + log_info "Keine sauber laufende Instanz gefunden." + fi + if [ "${#PREV_BROKEN[@]}" -gt 0 ]; then + log_warn "War vorher KAPUTT (${#PREV_BROKEN[@]}): ${PREV_BROKEN[*]}" + log_warn " (failed oder Crash-Loop/activating — wird mit gestartet, gilt aber" + log_warn " erst als Erfolg, wenn sie nach dem Update wirklich laeuft.)" + fi + if [ "${#PREV_STOPPED[@]}" -gt 0 ]; then + log_info "Bewusst gestoppt, bleibt gestoppt (${#PREV_STOPPED[@]}): ${PREV_STOPPED[*]}" + fi +} + +stop_instances() { + local -a units=() + [ "${#PREV_OK[@]}" -gt 0 ] && units+=("${PREV_OK[@]}") + [ "${#PREV_BROKEN[@]}" -gt 0 ] && units+=("${PREV_BROKEN[@]}") + [ "${#units[@]}" -gt 0 ] || { log_info "Nichts zu stoppen."; return 0; } + log_info "Stoppe Instanzen: ${units[*]}" + local unit + for unit in "${units[@]}"; do + systemctl stop "$unit" >/dev/null 2>&1 || log_warn " stop fehlgeschlagen: $unit" + done +} + +# ============================================================ +# Verifikation nach dem Start +# ============================================================ + +# Wartet mindestens VERIFY_WAIT Sekunden und prueft danach is-active, +# is-failed und den Restart-Zaehler. Ein Crash-Loop (Type=simple meldet +# sofort "active") faellt damit auf. +verify_unit() { + local unit="$1" + local r0 r1 state failed waited=0 + r0="$(unit_restarts "$unit")" + while [ "$waited" -lt "$VERIFY_WAIT" ]; do + sleep 1 + waited=$((waited + 1)) + [ "$(unit_state "$unit")" = "failed" ] && break + done + state="$(unit_state "$unit")" + failed="$(unit_failed "$unit")" + r1="$(unit_restarts "$unit")" + + if [ "$state" != "active" ]; then + log_error " ❌ $unit — Status '$state' nach ${waited}s" + return 1 + fi + if [ "$failed" = "failed" ]; then + log_error " ❌ $unit — systemd meldet 'failed'" + return 1 + fi + if [ "$r1" -gt "$r0" ]; then + log_error " ❌ $unit — Crash-Loop (NRestarts $r0 -> $r1 in ${waited}s)" + return 1 + fi + return 0 +} + +start_instances() { + local -a units=() + [ "${#PREV_OK[@]}" -gt 0 ] && units+=("${PREV_OK[@]}") + [ "${#PREV_BROKEN[@]}" -gt 0 ] && units+=("${PREV_BROKEN[@]}") + STARTED_OK=(); STARTED_FAIL=() + [ "${#units[@]}" -gt 0 ] || { log_info "Keine Instanz zu starten."; return 0; } + + local unit + for unit in "${units[@]}"; do + # Alten failed-Zustand raeumen, damit is-failed/NRestarts danach + # wirklich etwas ueber diesen Start aussagen. + systemctl reset-failed "$unit" >/dev/null 2>&1 || true + systemctl start "$unit" >/dev/null 2>&1 || log_warn " start meldete einen Fehler: $unit" + if verify_unit "$unit"; then + log_info " ✅ $unit laeuft (nach ${VERIFY_WAIT}s stabil)" + STARTED_OK+=("$unit") + else + log_error " Logs: journalctl -u $unit -n 50 --no-pager" + STARTED_FAIL+=("$unit") + fi + done +} + +# ============================================================ +# Config-Pruefung (CLI des Python-Teils) +# ============================================================ + +# Nutzt: venv/bin/python -m pdf_ocr_hotfolder --check-config --config +# Exit 0 = sauber, 1 = Warnungen, 2 = Fehler. +# Kennt der installierte Code das Subkommando nicht, wird die Pruefung +# uebersprungen (Warnung), das Update laeuft aber weiter. +check_all_configs() { + local py="$INSTALL_DIR/venv/bin/python" + local cfg name out rc + local -a cfgs=() + + log_step "Configs pruefen (--check-config)" + CFG_WARN=(); CFG_ERR=() + + if [ ! -x "$py" ]; then + log_warn "venv-Python nicht ausfuehrbar — Config-Pruefung uebersprungen." + return 0 + fi + shopt -s nullglob + cfgs=("$CONFIG_DIR"/*.toml) + shopt -u nullglob + if [ "${#cfgs[@]}" -eq 0 ]; then + log_info "Keine Instanz-Configs unter $CONFIG_DIR." + return 0 + fi + + for cfg in "${cfgs[@]}"; do + name="$(basename "$cfg" .toml)" + out="$(cd "$INSTALL_DIR" && "$py" -m pdf_ocr_hotfolder --check-config --config "$cfg" 2>&1)" && rc=0 || rc=$? + case "$rc" in + 0) + log_info " ✅ $name — Config ok" + [ -n "$out" ] && printf '%s\n' "$out" + ;; + 1) + log_warn " ⚠ $name — Warnungen:" + printf '%s\n' "$out" + CFG_WARN+=("$name") + ;; + 2) + # argparse beendet sich bei unbekannten Optionen ebenfalls mit 2 — + # das ist kein Config-Fehler, sondern aelterer Code. + if printf '%s' "$out" | grep -qiE 'unrecognized arguments|invalid choice|no such option|unknown option|unrecognized option'; then + log_warn "Der installierte Code kennt --check-config nicht (aeltere Version)." + log_warn "Config-Pruefung wird uebersprungen — Update laeuft weiter." + CFG_WARN=(); CFG_ERR=() + return 0 + fi + log_error " ❌ $name — Config-FEHLER:" + printf '%s\n' "$out" + CFG_ERR+=("$name") + ;; + *) + log_warn " ⚠ $name — --check-config lieferte unerwarteten Exit-Code $rc; ignoriert." + [ -n "$out" ] && printf '%s\n' "$out" | tail -n 10 + ;; + esac + done +} + +# ============================================================ +# Backup +# ============================================================ + +# Pfad relativ zu TAR_ROOT (fuer tar -C "$TAR_ROOT"). +strip_root() { + local p="$1" root="${TAR_ROOT%/}" + [ -n "$root" ] && p="${p#"$root"}" + printf '%s' "${p#/}" +} + +# Sichert Code, Instanz-Configs, Template-Unit, Drop-ins und ein pip freeze +# der alten venv. Datenverzeichnisse (/var/lib/...) bleiben bewusst draussen. +# Das Archiv enthaelt Klartext-Passwoerter aus den Instanz-Configs und wird +# deshalb root-only (0600) abgelegt. +create_backup() { + local stage tmp d + local -a items=() + + log_step "Backup erstellen" + mkdir -p "$BACKUP_DIR" || die "Backup-Verzeichnis $BACKUP_DIR nicht anlegbar." + chmod 700 "$BACKUP_DIR" 2>/dev/null || true + + stage="$(mktemp -d)" || die "mktemp -d fehlgeschlagen." + { + echo "# pip freeze der alten venv vor dem Update" + echo "# Zeitpunkt: $(date -Is)" + echo "# Version: ${OLD_VERSION:-unknown} -> ${NEW_VERSION:-unknown}" + if [ -x "$INSTALL_DIR/venv/bin/pip" ]; then + "$INSTALL_DIR/venv/bin/pip" freeze 2>/dev/null || echo "# pip freeze fehlgeschlagen (venv defekt?)" + else + echo "# keine funktionierende venv vorhanden" + fi + } > "$stage/pip-freeze.txt" + + [ -d "$INSTALL_DIR" ] && items+=("$(strip_root "$INSTALL_DIR")") + [ -d "$CONFIG_DIR" ] && items+=("$(strip_root "$CONFIG_DIR")") + [ -f "$SYSTEMD_DIR/$SERVICE_TEMPLATE" ] && items+=("$(strip_root "$SYSTEMD_DIR/$SERVICE_TEMPLATE")") + shopt -s nullglob + for d in "$SYSTEMD_DIR"/pdf-ocr-hotfolder@*.service.d; do + items+=("$(strip_root "$d")") + done + shopt -u nullglob + + if [ "${#items[@]}" -eq 0 ]; then + rm -rf "$stage" + die "Nichts zu sichern gefunden — das sieht nach einer kaputten Installation aus." + fi + + tmp="$BACKUP_DIR/backup-$(date +%Y%m%d-%H%M%S).tar.gz" + local old_umask; old_umask="$(umask)" + umask 077 + if ! tar -czf "$tmp" \ + --exclude="$(strip_root "$INSTALL_DIR")/venv" \ + --exclude="$(strip_root "$INSTALL_DIR")/venv.old-*" \ + --exclude='*/__pycache__' \ + --exclude='*.pyc' \ + -C "$TAR_ROOT" "${items[@]}" \ + -C "$stage" pip-freeze.txt; then + umask "$old_umask" + rm -f "$tmp" + rm -rf "$stage" + die "Backup fehlgeschlagen (volle Platte?). Es wurde NICHTS veraendert." + fi + umask "$old_umask" + rm -rf "$stage" + chmod 600 "$tmp" || true + chown root:root "$tmp" 2>/dev/null || true + + 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_warn " Achtung: die Instanz-Configs enthalten Klartext-Passwoerter" + log_warn " (SMTP/Nextcloud/SFTP) — Archiv ist deshalb root-only (0600)." + rotate_backups +} + +# Behaelt die letzten $BACKUP_KEEP Archive, loescht aeltere. +rotate_backups() { + local -a all=() old=() + shopt -s nullglob + mapfile -t all < <(printf '%s\n' "$BACKUP_DIR"/backup-*.tar.gz | sort) + shopt -u nullglob + [ "${#all[@]}" -gt "$BACKUP_KEEP" ] || return 0 + old=("${all[@]:0:$(( ${#all[@]} - BACKUP_KEEP ))}") + local f + for f in "${old[@]}"; do + rm -f "$f" && log_info " Altes Backup entfernt: $(basename "$f")" + done + log_info " Rotation: ${BACKUP_KEEP} Backups behalten, ${#old[@]} entfernt." +} + +# ============================================================ +# systemd +# ============================================================ + +install_units() { + log_step "systemd-Units aktualisieren" + cp "$REPO_DIR/systemd/$SERVICE_TEMPLATE" "$SYSTEMD_DIR/$SERVICE_TEMPLATE" + log_info "Template-Unit aktualisiert ✓" + + # Issue #4 redux: existiert das LXC-Drop-in, muss es mit der Template-Unit + # mitwachsen — sonst reisst ein neu ergaenzter Hardening-Schalter alle + # Container-Instanzen (Error 226/NAMESPACE). + if [ -f "$LXC_DROPIN" ]; then + if [ -f "$REPO_DIR/systemd/lxc-compat.conf" ]; then + cp "$REPO_DIR/systemd/lxc-compat.conf" "$LXC_DROPIN" + LXC_SYNCED=1 + log_info "LXC-Drop-in nachgezogen: $LXC_DROPIN ✓" + else + log_warn "systemd/lxc-compat.conf fehlt im Repo — Drop-in bleibt alt." + fi + elif systemd-detect-virt --container -q 2>/dev/null; then + log_warn "Container-Umgebung erkannt, aber kein LXC-Drop-in installiert." + log_warn "Bei Error 226/NAMESPACE: install.sh erneut laufen lassen oder" + log_warn " cp '$REPO_DIR/systemd/lxc-compat.conf' '$LXC_DROPIN'" + fi + systemctl daemon-reload +} + +# ============================================================ +# LIB_ONLY: bis hierher nur Definitionen. Mit +# PDF_OCR_UPDATE_LIB_ONLY=1 source ./update.sh +# laesst sich alles oben isoliert testen, ohne dass etwas passiert. +# ============================================================ +if [ "${PDF_OCR_UPDATE_LIB_ONLY:-0}" = "1" ]; then + # shellcheck disable=SC2317 # 'exit' greift nur, wenn das Skript nicht gesourct wurde + return 0 2>/dev/null || exit 0 fi -INSTALL_DIR="/opt/pdf-ocr-hotfolder" -CONFIG_DIR="/etc/pdf-ocr-hotfolder" -SERVICE_TEMPLATE="pdf-ocr-hotfolder@.service" +# ============================================================ +# Main +# ============================================================ + +while [ "$#" -gt 0 ]; do + case "$1" in + --rebuild-venv) REBUILD_VENV=1 ;; + -h|--help) usage; exit 0 ;; + *) log_error "Unbekannte Option: $1"; echo; usage; exit 1 ;; + esac + shift +done + +if [ "${EUID}" -ne 0 ]; then + log_error "Bitte als root ausfuehren: sudo ./update.sh" + exit 1 +fi SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" if [ -f "$SCRIPT_DIR/pdf_ocr_hotfolder/__init__.py" ]; then @@ -29,11 +690,11 @@ elif [ -f "$INSTALL_DIR/.repo_path" ]; then REPO_DIR="$(cat "$INSTALL_DIR/.repo_path")" [ -d "$REPO_DIR" ] || { log_error "Gespeicherter Repo-Pfad existiert nicht: $REPO_DIR"; exit 1; } else - log_error "Repo nicht gefunden. update.sh aus dem Repo ausführen." + log_error "Repo nicht gefunden. update.sh aus dem Repo ausfuehren." exit 1 fi -[ -d "$INSTALL_DIR" ] || { log_error "Installation nicht gefunden. Erst install.sh ausführen."; exit 1; } +[ -d "$INSTALL_DIR" ] || { log_error "Installation nicht gefunden. Erst install.sh ausfuehren."; exit 1; } OLD_VERSION="$(cat "$INSTALL_DIR/VERSION" 2>/dev/null || echo unknown)" NEW_VERSION="$(cat "$REPO_DIR/VERSION" 2>/dev/null || echo unknown)" @@ -47,64 +708,153 @@ log_info "Install: $INSTALL_DIR" log_info "Version: $OLD_VERSION → $NEW_VERSION" echo -# Laufende Instanzen ermitteln -mapfile -t RUNNING < <(systemctl list-units --no-legend --state=active 'pdf-ocr-hotfolder@*.service' 2>/dev/null | awk '{print $1}') -if [ "${#RUNNING[@]}" -gt 0 ]; then - log_info "Laufende Instanzen: ${RUNNING[*]}" +# Ab hier kann ein Abbruch Instanzen gestoppt zuruecklassen. +trap 'abort_handler $? $LINENO' ERR +trap 'abort_handler 130 $LINENO' INT +trap 'abort_handler 143 $LINENO' TERM + +# --- Instanzen erfassen --- +log_step "Instanzen erfassen" +collect_instances +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)" else - log_info "Keine laufenden Instanzen." + PRIMARY_USER="$(stat -c '%U' "$INSTALL_DIR" 2>/dev/null || echo pdfocr)" +fi +[ "$PRIMARY_USER" = "root" ] && PRIMARY_USER="pdfocr" + +# --- System-Pakete (auch beim Update, nicht nur bei install.sh) --- +sync_system_packages + +# --- venv-Gesundheit pruefen (vor dem Stoppen, damit man es frueh sieht) --- +log_step "venv pruefen" +NEED_REBUILD=0 +if [ "$REBUILD_VENV" -eq 1 ]; then + log_info "--rebuild-venv gesetzt: venv wird in jedem Fall neu gebaut." + NEED_REBUILD=1 +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 + 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." + NEED_REBUILD=1 fi -log_info "Stoppe laufende Instanzen..." -for unit in "${RUNNING[@]}"; do - systemctl stop "$unit" || true -done +# --- Ab hier wird angefasst --- +log_step "Instanzen stoppen" +stop_instances -log_info "Backup erstellen..." -BACKUP_DIR="/var/backups/pdf-ocr-hotfolder" -mkdir -p "$BACKUP_DIR" -tar -czf "$BACKUP_DIR/backup-$(date +%Y%m%d-%H%M%S).tar.gz" \ - -C "$INSTALL_DIR" --exclude=venv --exclude=__pycache__ . 2>/dev/null || true +create_backup -log_info "Code aktualisieren..." +log_step "Code aktualisieren" +TOUCHED=1 rm -rf "$INSTALL_DIR/pdf_ocr_hotfolder" cp -r "$REPO_DIR/pdf_ocr_hotfolder" "$INSTALL_DIR/" cp "$REPO_DIR/requirements.txt" "$INSTALL_DIR/" cp "$REPO_DIR/VERSION" "$INSTALL_DIR/" cp "$REPO_DIR/config.example.toml" "$INSTALL_DIR/" echo "$REPO_DIR" > "$INSTALL_DIR/.repo_path" +log_info "Code auf $NEW_VERSION ✓" -log_info "Dependencies aktualisieren..." -"$INSTALL_DIR/venv/bin/pip" install --upgrade pip -q -"$INSTALL_DIR/venv/bin/pip" install --upgrade -r "$INSTALL_DIR/requirements.txt" -q +log_step "Dependencies aktualisieren" +if [ "$NEED_REBUILD" -eq 1 ]; then + rebuild_venv +else + pip_install_requirements "$INSTALL_DIR/venv" || die "Dependencies liessen sich nicht aktualisieren." + log_info "Dependencies ok ✓" +fi -log_info "systemd Template-Unit aktualisieren..." -cp "$REPO_DIR/systemd/$SERVICE_TEMPLATE" "/etc/systemd/system/$SERVICE_TEMPLATE" -systemctl daemon-reload +install_units -log_info "Berechtigungen setzen..." -# Eigentümer des Codes bleibt der primäre User (pdfocr); Instanzen laufen +log_step "Berechtigungen setzen" +# Eigentuemer des Codes bleibt der primaere User (pdfocr); Instanzen laufen # ggf. als anderer User, lesen aber nur den Code. -PRIMARY_USER="$(stat -c '%U' "$INSTALL_DIR/venv" 2>/dev/null || echo pdfocr)" chown -R "$PRIMARY_USER":"$PRIMARY_USER" "$INSTALL_DIR" +log_info "Eigentuemer: $PRIMARY_USER" -log_info "Starte Instanzen wieder..." -FAIL=0 -for unit in "${RUNNING[@]}"; do - systemctl start "$unit" || true - sleep 1 - if systemctl is-active --quiet "$unit"; then - log_info " ✅ $unit" +check_all_configs + +log_step "Instanzen starten" +start_instances + +# ============================================================ +# Zusammenfassung: Ist gegen Soll +# ============================================================ +trap - ERR INT TERM + +RC=0 +echo +echo "==========================================" +echo " Zusammenfassung" +echo "==========================================" +log_info "Version: $OLD_VERSION → $NEW_VERSION" +log_info "Backup: ${BACKUP_FILE:-keines}" +[ "$VENV_REBUILT" -eq 1 ] && log_info "venv: neu gebaut (Python $(py_mm "$INSTALL_DIR/venv/bin/python"))" +[ "$LXC_SYNCED" -eq 1 ] && log_info "LXC: Drop-in nachgezogen" + +SOLL=$(( ${#PREV_OK[@]} + ${#PREV_BROKEN[@]} )) +log_info "Soll: $SOLL Instanz(en) sollen laufen | Ist: ${#STARTED_OK[@]} laufen" + +if [ "${#PREV_STOPPED[@]}" -gt 0 ]; then + log_info "Gestoppt gelassen: ${PREV_STOPPED[*]}" +fi + +# Vorher kaputte Instanzen, die auch jetzt nicht laufen: klar benennen. +FIXED=(); STILL_BROKEN=() +for unit in "${PREV_BROKEN[@]:-}"; do + [ -n "$unit" ] || continue + if printf '%s\n' "${STARTED_OK[@]:-}" | grep -qxF "$unit"; then + FIXED+=("$unit") else - log_error " ❌ $unit — journalctl -u $unit -n 30" - FAIL=1 + STILL_BROKEN+=("$unit") fi done +[ "${#FIXED[@]}" -gt 0 ] && log_info "Vorher kaputt, laeuft jetzt: ${FIXED[*]}" + +if [ "${#STILL_BROKEN[@]}" -gt 0 ]; then + log_error "Vorher kaputt und immer noch kaputt: ${STILL_BROKEN[*]}" + RC=1 +fi + +VORHER_OK_JETZT_NICHT=() +for unit in "${PREV_OK[@]:-}"; do + [ -n "$unit" ] || continue + printf '%s\n' "${STARTED_OK[@]:-}" | grep -qxF "$unit" || VORHER_OK_JETZT_NICHT+=("$unit") +done +if [ "${#VORHER_OK_JETZT_NICHT[@]}" -gt 0 ]; then + log_error "REGRESSION — lief vorher, laeuft jetzt nicht: ${VORHER_OK_JETZT_NICHT[*]}" + log_error "Rollback: tar -xzf '${BACKUP_FILE:-}' -C / && systemctl daemon-reload" + RC=1 +fi + +if [ "${#CFG_ERR[@]}" -gt 0 ]; then + log_error "Config-FEHLER (Exit 2) bei: ${CFG_ERR[*]}" + log_error " Diese Instanzen gelten NICHT als erfolgreich aktualisiert." + RC=1 +fi +if [ "${#CFG_WARN[@]}" -gt 0 ]; then + log_warn "Config-Warnungen (Exit 1) bei: ${CFG_WARN[*]}" + log_warn " Kein Abbruchgrund, aber bitte nachsehen:" + for name in "${CFG_WARN[@]}"; do + log_warn " $INSTALL_DIR/venv/bin/python -m pdf_ocr_hotfolder --check-config --config $CONFIG_DIR/$name.toml" + done +fi +if [ "$APT_WARN" -eq 1 ]; then + log_warn "System-Pakete konnten nicht vollstaendig abgeglichen werden (siehe oben)." +fi echo -if [ "$FAIL" -eq 0 ]; then +if [ "$RC" -eq 0 ]; then log_info "Update auf $NEW_VERSION abgeschlossen ✓" else - log_warn "Update abgeschlossen, aber mindestens eine Instanz läuft nicht." - exit 1 + log_error "Update abgeschlossen, aber mit Problemen (siehe oben)." fi +exit "$RC"