feat: Wiederaufnahme aus working/, --check-config, feldtauglicher Updater (v0.6.0)

Datenverlust behoben:
- Nach hartem Stopp blieb das Original in working/ liegen und wurde nie
  wieder angefasst (_scan_existing sah nur incoming/). Es wird jetzt beim
  Start an Ort und Stelle wieder aufgegriffen, mit Kollisionsschutz gegen
  gleichnamige neue Scans; angefangene __ocr_-Fragmente werden geloescht.
- TimeoutStopSec 30 -> 300, damit laufendes OCR zu Ende laufen darf.

Config-Drift sichtbar gemacht:
- Neues --check-config (Exit 0 sauber / 1 Warnungen / 2 Fehler), das
  update.sh vor dem Neustart ueber alle Instanz-Configs laufen laesst.
- Warnungen fuer [ocr].timeout >= 900 (seit 0.4.0 pro SEITE) und gesetztes
  pdfa_level, beim Dienststart wie im Check.
- Unbekannte Config-Keys werden nicht mehr still verworfen, sondern genannt.

Updater feldtauglich:
- venv-Health-Check erkennt toten Symlink UND Versions-Drift gegen das
  System-Python; --rebuild-venv als ausdruecklicher Weg nach einem Debian-
  Major-Upgrade. Neubau ist ganz-oder-gar-nicht mit Rollback.
- apt-Pakete werden auch beim Update synchronisiert (Quelle: install.sh).
- Instanz-Erfassung inkl. activating/failed, Verifikation prueft is-failed
  und NRestarts statt sleep 1 + is-active.
- Backup enthaelt Configs, Unit, Drop-ins und pip-freeze.txt, liegt auf
  0600 und rotiert auf 5; schlaegt es fehl, bricht das Update vorher ab.
- ERR-Trap faehrt die vorher laufenden Instanzen wieder hoch.
- lxc-compat.conf wird beim Update nachgezogen.
- requirements.txt gepinnt (ocrmypdf 16.13.0, geprueft fuer Python 3.11+3.13).

Doku in Installation / Update / OS-Upgrade aufgeteilt (docs/).
135 Tests gruen.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-09-22 22:04:04 +02:00
parent 2062476252
commit 3e24aa2ecd
20 changed files with 3095 additions and 347 deletions
+168 -84
View File
@@ -1,8 +1,15 @@
# AI Agent Briefing — PDF OCR Hotfolder # AI Agent Briefing — PDF OCR Hotfolder
**Zuletzt aktualisiert:** 2026-09-22 **Zuletzt aktualisiert:** 2026-09-22
**Version:** 0.5.0 **Version:** 0.6.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. **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 ## 🎯 Projektziel
@@ -14,32 +21,40 @@ Eingehende gescannte PDFs werden automatisch durch OCR (ocrmypdf + Tesseract) in
pdf-ocr-hotfolder/ pdf-ocr-hotfolder/
├── pdf_ocr_hotfolder/ ├── pdf_ocr_hotfolder/
│ ├── __init__.py # Versionsstring (__version__) │ ├── __init__.py # Versionsstring (__version__)
│ ├── __main__.py # CLI (argparse: --config, --once, --version); Exit 0/1/2 │ ├── __main__.py # CLI (argparse: --config, --once, --check-config, --version)
│ ├── config.py # TOML-Loader, Dataclasses, ConfigError │ ├── config.py # TOML-Loader, Dataclasses, ConfigError, Warnungen
│ ├── service.py # HotfolderService (watchdog + ThreadPool), Preflight, Zähler │ ├── service.py # HotfolderService (watchdog + ThreadPool), Preflight, Resume, Zähler
│ ├── processor.py # ocrmypdf-Call, veraPDF, Ausgabename, Original-Entsorgung │ ├── processor.py # ocrmypdf-Call, veraPDF, Ausgabename, Original-Entsorgung
│ └── uploaders.py # folder, nextcloud (WebDAV), sftp, E-Mail-Notify │ └── 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 │ ├── conftest.py # Fixtures tmp_config / dummy_pdf
│ ├── test_check_config.py # --check-config, Exit 0/1/2
│ ├── test_config_errors.py │ ├── test_config_errors.py
│ ├── test_config_warnings.py # Legacy- und Unbekannt-Warnungen
│ ├── test_error_counting.py │ ├── test_error_counting.py
│ ├── test_ghostscript_version.py │ ├── test_ghostscript_version.py
│ ├── test_ocr_timeout.py │ ├── test_ocr_timeout.py
│ ├── test_once_exit_code.py │ ├── test_once_exit_code.py
│ ├── test_output_naming.py │ ├── test_output_naming.py
│ ├── test_preflight.py │ ├── test_preflight.py
│ ├── test_resume_working.py # Wiederaufnahme + __ocr_-Fragmente
│ └── test_upload_folder.py │ └── test_upload_folder.py
├── systemd/ ├── 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 │ └── 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 ├── pytest.ini # testpaths = tests
├── config.example.toml ├── config.example.toml
├── install.sh # Interaktiver Installer + Instanz-Manager ├── install.sh # Interaktiver Installer + Instanz-Manager
├── update.sh # Update aus Repo ├── update.sh # Updater (--help, --rebuild-venv), ~860 Zeilen
├── requirements.txt ├── requirements.txt # feste Pins (ocrmypdf 16.x!)
├── VERSION ├── VERSION
├── CHANGELOG.md ├── CHANGELOG.md
└── README.md ├── README.md
└── AI_AGENT_BRIEFING.md
``` ```
## 🔧 Stack ## 🔧 Stack
@@ -56,6 +71,12 @@ pdf-ocr-hotfolder/
| Tests | `pytest` | | Tests | `pytest` |
| Service | systemd (Template-Unit) | | 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) ## 🖥️ Installations-Layout (Multi-Instanz)
| Pfad | Inhalt | | 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/lxc-compat.conf` | Drop-in für Container (optional) |
| `/etc/systemd/system/pdf-ocr-hotfolder@<instanz>.service.d/user.conf` | Drop-in für abweichenden User (optional) | | `/etc/systemd/system/pdf-ocr-hotfolder@<instanz>.service.d/user.conf` | Drop-in für abweichenden User (optional) |
| `/var/lib/pdf-ocr-hotfolder/<instanz>/{incoming,working,outgoing,error}/` | Daten pro Instanz | | `/var/lib/pdf-ocr-hotfolder/<instanz>/{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 Ein eigenes Logverzeichnis gibt es **nicht** (seit 0.4.1 auch nicht mehr vom
Installer angelegt): `_setup_logging()` nutzt `logging.basicConfig()` ohne 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 - 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 - 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) - Basis-Install wird an `venv` + Template-Unit erkannt und übersprungen — **außer**
- Folgender Lauf: Basis-Install wird übersprungen (erkannt an `venv` + Template-Unit), bestehende Instanzen werden gelistet, weitere Instanzen können ergänzt werden die venv passt nicht mehr zum System-Python, dann läuft er zur Reparatur erneut
- Eingaben pro Instanz (seit 0.5.0 fünf statt drei): (`venv_is_healthy()` in `install.sh`, schlankere Variante der Prüfung in `update.sh`).
1. Name (`[a-z0-9][a-z0-9-]*`) - Abfragen pro Instanz: Name, Basis-Pfad, Service-User, **OCR-Sprachen**,
2. Basis-Pfad (default `/var/lib/pdf-ocr-hotfolder/<name>`) **Original archivieren?** — `LANGS`/`ORIG_MODE`/`ARCHIVE_DIR` sind `local` in
3. Service-User (default `pdfocr`) `create_instance()`, gelten also **instanz-lokal** und nicht global.
4. **OCR-Sprachen** (default `deu+eng`) — Format `^[a-z]{3}(_[A-Za-z]+)?(\+…)*$`, - Sprachprüfung gegen `tesseract --list-langs`, fehlende Pakete werden als
bei Unsinn wird erneut gefragt. Jeder Code wird gegen `tesseract --list-langs` `tesseract-ocr-<code>` angeboten (Unterstrich → Bindestrich). Ablehnung führt
geprüft; fehlt einer, bietet der Installer `tesseract-ocr-<code>` an nicht zum Abbruch, sondern zurück zur Sprach-Abfrage.
(Unterstrich → Bindestrich, `chi_sim` → `tesseract-ocr-chi-sim`). Ablehnung - Das Archiv-Verzeichnis darf **nicht** `incoming/`/`outgoing/`/`working/`/`error/`
oder fehlgeschlagene Installation → Warnung, dass OCR mit dieser Sprache sein — im Eingang würde das Original endlos neu aufgegriffen.
**pro Datei** scheitert, und die Sprach-Abfrage beginnt von vorn (kein - `<instanz>.toml` wird aus `config.example.toml` per `sed` erzeugt. Substituiert
harter Abbruch). Ist `tesseract` nicht aufrufbar, wird die Prüfung werden die vier `[paths]`-Zeilen **sowie** `[ocr].languages`,
ü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
- `<instanz>.toml` wird aus `config.example.toml` per `sed` generiert. Substituiert
werden die vier `[paths]`-Zeilen **sowie** (seit 0.5.0) `[ocr].languages`,
`[output].original_on_success` und `[output].archive_dir`. Die Ausdrücke sind `[output].original_on_success` und `[output].archive_dir`. Die Ausdrücke sind
am Zeilenanfang verankert (`^key[[:space:]]*=`), damit die deutschen am Zeilenanfang verankert (`^key[[:space:]]*=`), damit die deutschen
Kommentarzeilen über den Keys nicht getroffen werden (im Beispiel steht z.B. Kommentarzeilen über den Keys nicht getroffen werden; Pfad-Variablen laufen
`"archive" : Original wird in archive_dir verschoben` als Kommentar); vorher durch `sed_escape_repl()` (maskiert `\`, `&`, `|`). Nach dem sed-Lauf
Pfad-Variablen laufen vorher durch `sed_escape_repl()` (maskiert `\`, `&`, `|`). liest `config_value()` die drei Keys zurück und vergleicht sie mit der Eingabe.
Nach dem sed-Lauf liest `config_value()` die drei Keys zurück und vergleicht - Die apt-Paketliste steht als **einzige Quelle** in `install.sh` zwischen den
sie mit der Eingabe; erst wenn das passt, nennt die Zusammenfassung Sprachen Marken `# --- BEGIN apt-packages` / `# --- END apt-packages` in der Funktion
und Archiv-Verzeichnis. `pdf_ocr_apt_packages()`. **`update.sh` schneidet diesen Block per `sed` heraus
- Instanz wird sofort `enable --now` gestartet 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: ## 🔄 Update-Verhalten (Kurzfassung)
```bash
systemctl disable --now pdf-ocr-hotfolder@<name>
rm /etc/pdf-ocr-hotfolder/<name>.toml
rm -rf /etc/systemd/system/pdf-ocr-hotfolder@<name>.service.d
systemctl daemon-reload
# Datenverzeichnis /var/lib/pdf-ocr-hotfolder/<name> manuell aufräumen
```
## 🔄 Update-Verhalten Vollständig: [docs/UPDATE.md](docs/UPDATE.md). Für die Arbeit am Skript wichtig:
`update.sh`: - `update.sh` hat `--help` und `--rebuild-venv`, läuft mit `set -Eeuo pipefail`
1. Findet das Repo (eigenes Verzeichnis oder `/opt/pdf-ocr-hotfolder/.repo_path`) und hat ab dem Stoppen der Instanzen einen **ERR/INT/TERM-Trap**: er sagt, ob
2. Ermittelt alle **aktiven** `pdf-ocr-hotfolder@*.service` Units und stoppt sie auf der Platte schon getauscht wurde (`TOUCHED`), startet die vorher laufenden
3. Backup nach `/var/backups/pdf-ocr-hotfolder/` (tar.gz, ohne venv/`__pycache__`) Instanzen wieder und nennt Backup + Rollback-Befehl.
4. Kopiert Code + requirements + VERSION + config.example aus dem Repo - Reihenfolge: Instanzen erfassen → apt-Sync → venv-Health → stoppen → Backup →
5. `pip install --upgrade` im venv Code → Deps/venv → Units → chown → `--check-config` → starten + verifizieren →
6. Aktualisiert Template-Unit + `daemon-reload` Zusammenfassung (Soll gegen Ist, Exit 1 bei Regression/Config-Fehler).
7. Setzt den Code-Eigentümer auf den User, dem `venv` gehört (default `pdfocr`) - **Instanz-Erfassung** deckt `list-units --all` (inkl. `activating`/`failed`),
8. Startet alle zuvor aktiven Instanzen wieder, Exit 1 wenn eine nicht mehr hochkommt `list-unit-files` und die Configs unter `/etc/pdf-ocr-hotfolder/` ab. Drei
Gruppen: `PREV_OK`, `PREV_BROKEN`, `PREV_STOPPED` — bewusst gestoppte bleiben
Config-Dateien werden **nie** überschrieben. Das Repo muss erhalten bleiben — `update.sh` kopiert daraus. 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-<ts>`, 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) ## ⚙️ 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 | | 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` | | `[notify.email]` | `enabled`, SMTP-Daten, `from_addr`, `to_addrs`, `on` = `always`/`errors`/`never` |
| `[logging]` | `level` = DEBUG/INFO/WARNING/ERROR | | `[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 <datei>` 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 ## 🔄 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 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` 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) 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:** **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) 2. `_wait_until_stable()` wartet, bis die Datei nicht mehr wächst (max. ~60s)
3. Move nach `working/` 3. Move nach `working/` (entfällt bei Wiederaufnahme)
4. `ocrmypdf.ocr()` als **Library-Call** (kein Subprozess-Start pro PDF) 4. `ocrmypdf.ocr()` als **Library-Call** (kein Subprozess-Start pro PDF), Ziel ist `working/__ocr_<zielname>`
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) 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) 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) 8. Aktive Upload-Targets ausführen (folder/nextcloud/sftp)
9. E-Mail-Notify je nach `[notify.email].on` 9. E-Mail-Notify je nach `[notify.email].on`
**Fehlerbehandlung (Stand 0.4.1):** **Fehlerbehandlung:**
| Fehlerfall | Zählt als Fehler | Wo liegt die Datei danach | | 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 | | 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 | — | | Datei verschwindet vor der Verarbeitung | nein | — |
| In `working/` liegt schon eine andere Datei gleichen Namens | ja | bleibt in `incoming/` |
| OCR wirft (ocrmypdf) | ja | `error/` | | OCR wirft (ocrmypdf) | ja | `error/` |
| veraPDF FAIL | ja | OCR-Ergebnis nach `error/`, Original laut `original_on_success` (`delete` → weg, `archive` → `archive_dir`; seit 0.4.1) | | veraPDF 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/` | | 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 ## ⚠️ 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). - **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. 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. - **`[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.
- **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. - **`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.
- **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). - **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`).
- **Klartext-Passwörter in der Instanz-Config**: SMTP-, Nextcloud- und SFTP-Zugangsdaten stehen unverschlüsselt in `/etc/pdf-ocr-hotfolder/<instanz>.toml`. Deshalb `chmod 640` und `chown root:<service-gruppe>`, und `/etc/pdf-ocr-hotfolder` selbst `750 root:pdfocr`. Beim Debuggen nicht versehentlich in ein Ticket oder Log kopieren. - **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/<instanz>.toml`. Deshalb `chmod 640` und `chown root:<service-gruppe>`, 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 ## 🛠️ Entwicklung
@@ -233,17 +312,21 @@ python -m pdf_ocr_hotfolder --config /tmp/config.toml
Tests (aus dem Repo-Root, `pytest.ini` setzt `testpaths = tests`): Tests (aus dem Repo-Root, `pytest.ini` setzt `testpaths = tests`):
```bash ```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. `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 ## 📋 Roadmap / TODO
- [x] Tests (`pytest`) für `processor` und `uploaders` — 95 Tests - [x] Tests (`pytest`) für `processor` und `uploaders` — 135 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] 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) - [ ] Prometheus-Metriken (verarbeitete PDFs, Fehlerquote, Laufzeit)
- [ ] CLI-Subkommandos: `pdf-ocr-hotfolder reprocess <error-file>` - [ ] CLI-Subkommandos: `pdf-ocr-hotfolder reprocess <error-file>`
- [ ] Instanz-Löschung in `install.sh` statt als Handarbeit
- [ ] Optional: S3/MinIO Upload-Target - [ ] Optional: S3/MinIO Upload-Target
- [ ] Docker-Image für Setups ohne systemd - [ ] 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 - **Repo:** https://gitea.sonith.de/sonith_ug/pdf-ocr-hotfolder
- **Owner:** sonith_ug - **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) - **Versionierung:** Semver (PATCH bei jedem Build, MINOR bei Features, MAJOR manuell)
- **Tags:** `v{VERSION}`, automatischer Push nach Commit - **Tags:** `v{VERSION}`, automatischer Push nach Commit
+115
View File
@@ -1,5 +1,120 @@
# Changelog # 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-<timestamp>` 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 ## [0.5.0] - 2026-09-22
### Added ### Added
+67 -190
View File
@@ -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. 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 ## Features
- 🔍 **OCR via ocrmypdf + Tesseract** (Library-Call, kein Subprozess-Overhead) - 🔍 **OCR via ocrmypdf + Tesseract** (Library-Call, kein Subprozess-Overhead)
- 📂 **Hotfolder via watchdog** — reagiert auf `created`, `moved`, `closed` Events - 📂 **Hotfolder via watchdog** — reagiert auf `created`, `moved`, `closed` Events
- 🧠 **Stabilitäts-Erkennung**: wartet bis Scanner fertig geschrieben hat - 🧠 **Stabilitäts-Erkennung**: wartet bis Scanner fertig geschrieben hat
- 🔁 **Parallelverarbeitung** mehrerer PDFs (ThreadPool, konfigurierbar) - 🔁 **Parallelverarbeitung** mehrerer PDFs (ThreadPool, konfigurierbar)
- ♻️ **Wiederaufnahme aus `working/`** nach einem harten Stopp — keine Datei bleibt liegen
- ✅ **PDF/A-Output** (1, 2 oder 3) optional - ✅ **PDF/A-Output** (1, 2 oder 3) optional
- 🛡️ **veraPDF-Validierung** optional - 🛡️ **veraPDF-Validierung** optional
- ☁️ **Upload-Ziele**: lokaler Ordner, Nextcloud (WebDAV via Python), SFTP - ☁️ **Upload-Ziele**: lokaler Ordner, Nextcloud (WebDAV via Python), SFTP
- 📧 **E-Mail-Notify** (immer / nur Fehler / nie) - 📧 **E-Mail-Notify** (immer / nur Fehler / nie)
- 🔐 **Service-User-Support** für lokale **und AD-User mit lokaler UID** (SSSD/Winbind) - 🔐 **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 ## Schnellstart
```bash ```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 cd pdf-ocr-hotfolder
sudo ./install.sh sudo ./install.sh
``` ```
Der Installer: Der Installer legt einmalig Code, venv und die systemd-Template-Unit an und
1. Installiert einmalig Code + venv + systemd-Template-Unit fragt danach **pro Instanz** Name, Basis-Pfad, Service-User, OCR-Sprachen und
2. Fragt **pro Instanz** ab: die Original-Behandlung ab. Bei jedem erneuten Aufruf erkennt er bestehende
- Instanz-Name Instanzen und fragt nur nach neuen.
- Basis-Pfad für die Daten
- Service-User
- **OCR-Sprachen** (Tesseract, Default `deu+eng`) — fehlende Sprachpakete
(`tesseract-ocr-<code>`) werden erkannt und auf Wunsch nachinstalliert
- **Original nach erfolgreichem OCR archivieren?** (Default nein = löschen;
bei ja zusätzlich der Archiv-Pfad, vorgeschlagen `<basis>/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.
Test: Test:
@@ -46,126 +49,56 @@ journalctl -u pdf-ocr-hotfolder@<instanz> -f
Nach wenigen Sekunden liegt das OCR-PDF im `outgoing/`-Ordner der Instanz. 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@<name>.service`. Jede Instanz hat: 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)**.
- eigene Config-Datei: `/etc/pdf-ocr-hotfolder/<name>.toml`
- eigene Datenverzeichnisse: `/var/lib/pdf-ocr-hotfolder/<name>/{incoming,working,outgoing,error}/`
- eigene systemd-Unit: `pdf-ocr-hotfolder@<name>.service`
- optional eigenen Service-User (via Drop-in `/etc/systemd/system/pdf-ocr-hotfolder@<name>.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.
## Verzeichnisse ## Verzeichnisse
| Pfad | Zweck | | Pfad | Zweck |
|------|-------| |------|-------|
| `/opt/pdf-ocr-hotfolder/` | Code + venv (für alle Instanzen gemeinsam) | | `/opt/pdf-ocr-hotfolder/` | Code + venv (für alle Instanzen gemeinsam) |
| `/etc/pdf-ocr-hotfolder/<instanz>.toml` | Config pro Instanz | | `/etc/pdf-ocr-hotfolder/<instanz>.toml` | Config pro Instanz (640, root:\<service-gruppe\>) |
| `/etc/systemd/system/pdf-ocr-hotfolder@.service` | systemd Template-Unit | | `/etc/systemd/system/pdf-ocr-hotfolder@.service` | systemd Template-Unit |
| `/var/lib/pdf-ocr-hotfolder/<instanz>/incoming` | Eingang (Scanner schreibt hier rein) | | `/var/lib/pdf-ocr-hotfolder/<instanz>/incoming` | Eingang (Scanner schreibt hier rein) |
| `/var/lib/pdf-ocr-hotfolder/<instanz>/working` | Arbeitsverzeichnis während OCR | | `/var/lib/pdf-ocr-hotfolder/<instanz>/working` | Arbeitsverzeichnis während OCR |
| `/var/lib/pdf-ocr-hotfolder/<instanz>/outgoing` | Ausgang (fertige PDFs) | | `/var/lib/pdf-ocr-hotfolder/<instanz>/outgoing` | Ausgang (fertige PDFs) |
| `/var/lib/pdf-ocr-hotfolder/<instanz>/error` | Fehlgeschlagene PDFs | | `/var/lib/pdf-ocr-hotfolder/<instanz>/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 Jede Instanz hat ihre eigene TOML unter `/etc/pdf-ocr-hotfolder/<instanz>.toml`.
`[output].archive_dir` pro Instanz ab und schreibt sie direkt in die Vollständiges, kommentiertes Beispiel: [`config.example.toml`](config.example.toml).
Instanz-Config — die Werte unten sind nur die Beispiel-Defaults. Key-für-Key-Referenz: [docs/INSTALLATION.md](docs/INSTALLATION.md#konfigurationsreferenz).
### `[ocr]` | Sektion | Zweck |
```toml |---------|-------|
languages = "deu+eng" # Tesseract-Sprachen (Installer fragt pro Instanz) | `[paths]` | `incoming`, `outgoing`, `working`, `error` — **Pflicht** |
jobs = 4 # Threads pro PDF | `[ocr]` | Sprachen, `jobs`, `skip_text`, `pdfa_level`, `deskew`, `max_workers`, `timeout` (Sekunden **pro Seite**) |
skip_text = true # bereits OCR-haltige Seiten überspringen | `[output]` | Dateibenennung (`name_mode`/`name_tag`) und Original-Behandlung (`delete`/`archive`) |
pdfa_level = "" # "1", "2", "3" oder "" für reines PDF (Default "" wegen Ghostscript-Bug, s.u.) | `[verapdf]` | optionale PDF/A-Validierung per CLI |
deskew = true | `[upload.folder]` / `[upload.nextcloud]` / `[upload.sftp]` | Upload-Ziele, beliebig viele gleichzeitig |
max_workers = 2 # parallele PDFs | `[notify.email]` | SMTP-Benachrichtigung: `always` \| `errors` \| `never` |
timeout = 300 # max. Sekunden pro SEITE (Tesseract), 0 = ocrmypdf-Default | `[logging]` | `level` = DEBUG/INFO/WARNING/ERROR |
Die Instanz-Configs enthalten **Klartext-Passwörter** (SMTP, Nextcloud, SFTP) —
deshalb `640 root:<service-gruppe>` 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/<instanz>.toml
``` ```
### `[output]` Exit 0 = sauber, 1 = Warnungen, 2 = Fehler. Details:
```toml [docs/UPDATE.md](docs/UPDATE.md#config-prüfung-per---check-config).
# 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 <alerts@example.com>"
to_addrs = ["admin@example.com"]
on = "errors" # always | errors | never
```
## Service-Verwaltung ## Service-Verwaltung
@@ -178,80 +111,11 @@ journalctl -u pdf-ocr-hotfolder@kunde-a -f
# Alle Instanzen # Alle Instanzen
sudo systemctl status 'pdf-ocr-hotfolder@*' sudo systemctl status 'pdf-ocr-hotfolder@*'
sudo systemctl restart 'pdf-ocr-hotfolder@*' sudo systemctl restart 'pdf-ocr-hotfolder@*'
journalctl -u 'pdf-ocr-hotfolder@*' --since today
``` ```
### Logs Ein laufendes OCR darf beim Stoppen zu Ende laufen (`TimeoutStopSec=300`) — ein
`stop` kann deshalb pro Instanz bis zu 5 Minuten dauern.
Der Dienst schreibt **kein eigenes Logfile** — alles geht nach stdout und damit
ins journal:
```bash
journalctl -u pdf-ocr-hotfolder@<instanz> -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`.
## Architektur ## 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 ## Lizenz
MIT — © Sonith UG MIT — © Sonith UG
--- ---
**Version:** 0.5.0 **Version:** 0.6.0
**Repo:** https://gitea.sonith.de/sonith_ug/pdf-ocr-hotfolder **Repo:** https://gitea.sonith.de/sonith_ug/pdf-ocr-hotfolder
+1 -1
View File
@@ -1 +1 @@
0.5.0 0.6.0
+1 -1
View File
@@ -1,5 +1,5 @@
# PDF OCR Hotfolder — Konfiguration # PDF OCR Hotfolder — Konfiguration
# Speichern als /etc/pdf-ocr-hotfolder/config.toml # Vorlage — install.sh erzeugt daraus pro Instanz /etc/pdf-ocr-hotfolder/<instanz>.toml
[paths] [paths]
# Eingangsverzeichnis: hier landen gescannte PDFs # Eingangsverzeichnis: hier landen gescannte PDFs
+464
View File
@@ -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, `<instanz>.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-<timestamp>`
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@<name>.service`) und zum Config-Dateinamen
(`/etc/pdf-ocr-hotfolder/<name>.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/<name>]:
```
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@<instanz>.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-<code>`,
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 [<basis>/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:<service-gruppe>`, 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@<instanz>.service`.
### Test
```bash
cp irgendein-scan.pdf /var/lib/pdf-ocr-hotfolder/<instanz>/incoming/
journalctl -u pdf-ocr-hotfolder@<instanz> -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@<name>.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@<name>
sudo rm /etc/pdf-ocr-hotfolder/<name>.toml
sudo rm -rf /etc/systemd/system/pdf-ocr-hotfolder@<name>.service.d
sudo systemctl daemon-reload
# Datenverzeichnis /var/lib/pdf-ocr-hotfolder/<name> 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/<instanz>.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/<instanz>
```
### 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/<instanz>.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.
+219
View File
@@ -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-<timestamp>` 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 \<version\> 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: <paket> auf <version> 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/<instanz>/incoming/
journalctl -u pdf-ocr-hotfolder@<instanz> -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.
+302
View File
@@ -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 (`…@<instanz>.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@<instanz>'
```
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<version>`) 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/<instanz>.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@<instanz> | 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/<instanz>/incoming/
journalctl -u pdf-ocr-hotfolder@<instanz> -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.
+66 -6
View File
@@ -37,18 +37,58 @@ if [ ! -f "$REPO_DIR/pdf_ocr_hotfolder/__init__.py" ]; then
exit 1 exit 1
fi 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) # Basis-Installation (idempotent)
# ============================================================ # ============================================================
install_base() { install_base() {
log_step "System-Pakete installieren" log_step "System-Pakete installieren"
local -a PKGS
mapfile -t PKGS < <(pdf_ocr_apt_packages)
apt-get update -qq apt-get update -qq
apt-get install -y --no-install-recommends \ apt-get install -y --no-install-recommends "${PKGS[@]}"
python3 python3-venv python3-pip \
tesseract-ocr tesseract-ocr-deu tesseract-ocr-eng \
ghostscript qpdf unpaper pngquant \
icc-profiles-free ca-certificates curl
log_info "System-Pakete ok ✓" log_info "System-Pakete ok ✓"
# Ghostscript-Versions-Check (Issue #3 + Issue #6) # Ghostscript-Versions-Check (Issue #3 + Issue #6)
@@ -127,11 +167,25 @@ install_base() {
echo "$REPO_DIR" > "$INSTALL_DIR/.repo_path" echo "$REPO_DIR" > "$INSTALL_DIR/.repo_path"
log_step "Python venv" 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 if [ ! -d "$INSTALL_DIR/venv" ]; then
python3 -m venv "$INSTALL_DIR/venv" python3 -m venv "$INSTALL_DIR/venv"
fi fi
"$INSTALL_DIR/venv/bin/pip" install --upgrade pip -q "$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_info "venv ok ✓"
log_step "systemd Template-Unit installieren" log_step "systemd Template-Unit installieren"
@@ -415,9 +469,15 @@ echo "=========================================="
echo " PDF OCR Hotfolder — Installer" echo " PDF OCR Hotfolder — Installer"
echo "==========================================" 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 if [ ! -d "$INSTALL_DIR/venv" ] || [ ! -f "/etc/systemd/system/$SERVICE_TEMPLATE" ]; then
log_step "Basis-Installation" log_step "Basis-Installation"
install_base 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 else
log_info "Basis-Installation bereits vorhanden ($INSTALL_DIR)" log_info "Basis-Installation bereits vorhanden ($INSTALL_DIR)"
log_info "Überspringe Basis-Setup (nutze update.sh für Code-Updates)" log_info "Überspringe Basis-Setup (nutze update.sh für Code-Updates)"
+1 -1
View File
@@ -1,3 +1,3 @@
"""PDF OCR Hotfolder — Scanner-PDFs automatisch durchsuchbar machen.""" """PDF OCR Hotfolder — Scanner-PDFs automatisch durchsuchbar machen."""
__version__ = "0.5.0" __version__ = "0.6.0"
+98 -2
View File
@@ -4,11 +4,24 @@ from __future__ import annotations
import argparse import argparse
import logging import logging
import sys import sys
import tomllib
from pathlib import Path from pathlib import Path
from . import __version__ from . import __version__
from .config import ConfigError, load_config from .config import Config, ConfigError, config_warnings, load_config
from .service import HotfolderService, PreflightError 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: 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: def main() -> int:
parser = argparse.ArgumentParser( parser = argparse.ArgumentParser(
prog="pdf-ocr-hotfolder", 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("--version", action="version", version=f"%(prog)s {__version__}")
parser.add_argument("--once", action="store_true", parser.add_argument("--once", action="store_true",
help="Nur bestehende Dateien verarbeiten und beenden") 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() args = parser.parse_args()
cfg_path = Path(args.config) cfg_path = Path(args.config)
@@ -36,12 +127,17 @@ def main() -> int:
print(f"Config nicht gefunden: {cfg_path}", file=sys.stderr) print(f"Config nicht gefunden: {cfg_path}", file=sys.stderr)
return 2 return 2
if args.check_config:
# Hat Vorrang vor --once: es wird nichts verarbeitet.
return check_config(cfg_path)
try: try:
cfg = load_config(cfg_path) cfg = load_config(cfg_path)
except ConfigError as e: except ConfigError as e:
print(f"FEHLER: {e}", file=sys.stderr) print(f"FEHLER: {e}", file=sys.stderr)
return 2 return 2
_setup_logging(cfg.log_level) _setup_logging(cfg.log_level)
_log_config_warnings(cfg)
service = HotfolderService(cfg) service = HotfolderService(cfg)
+98
View File
@@ -105,6 +105,18 @@ class Config:
sftp: SftpUpload sftp: SftpUpload
email: EmailNotify email: EmailNotify
log_level: str = "INFO" 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]: 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)) 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: def load_config(path: str | Path) -> Config:
path = Path(path) path = Path(path)
with path.open("rb") as f: 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, paths=paths, ocr=ocr, output=output, verapdf=verapdf,
folder=folder, nextcloud=nextcloud, sftp=sftp, email=email, folder=folder, nextcloud=nextcloud, sftp=sftp, email=email,
log_level=log_level, 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)
+33 -5
View File
@@ -14,6 +14,10 @@ log = logging.getLogger(__name__)
# Erlaubte Werte für [output].name_mode — wird auch vom Preflight geprüft # Erlaubte Werte für [output].name_mode — wird auch vom Preflight geprüft
VALID_NAME_MODES = ("prefix", "suffix", "none") 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: def build_output_name(src_name: str, mode: str, tag: str) -> str:
"""Erzeugt den Ziel-Dateinamen für ein OCR-PDF. """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.""" """Verarbeitet eine einzelne PDF: move→OCR→validate→outgoing/error."""
out_name = build_output_name(src.name, output_cfg.name_mode, output_cfg.name_tag) out_name = build_output_name(src.name, output_cfg.name_mode, output_cfg.name_tag)
work_src = working_dir / src.name 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 final_out = outgoing_dir / out_name
try: if _is_same_file(src, work_src):
shutil.move(str(src), str(work_src)) # Wiederaufnahme: die Datei liegt bereits in working/, weil ein
except OSError as e: # früherer Lauf hart abgebrochen wurde. Kein zweiter Move — der würde
return ProcessResult(src, final_out, False, f"move to working failed: {e}") # 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: try:
run_ocr(work_src, work_out, ocr_cfg) 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) 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: def _dispose_original(work_src: Path, original_name: str, cfg: OutputConfig) -> None:
"""Entsorgt das Original laut [output].original_on_success — löschen oder archivieren. """Entsorgt das Original laut [output].original_on_success — löschen oder archivieren.
+84 -4
View File
@@ -9,13 +9,20 @@ import subprocess
import threading import threading
import time import time
from concurrent.futures import Future, ThreadPoolExecutor from concurrent.futures import Future, ThreadPoolExecutor
from datetime import datetime
from pathlib import Path from pathlib import Path
from watchdog.events import FileSystemEvent, FileSystemEventHandler from watchdog.events import FileSystemEvent, FileSystemEventHandler
from watchdog.observers import Observer from watchdog.observers import Observer
from .config import Config 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 from .uploaders import notify_email, upload_folder, upload_nextcloud, upload_sftp
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
@@ -216,7 +223,7 @@ class HotfolderService:
self.shutdown() self.shutdown()
def run_once(self) -> int: 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: Returns:
Anzahl fehlgeschlagener PDFs (0 = alles ok). Anzahl fehlgeschlagener PDFs (0 = alles ok).
@@ -243,11 +250,84 @@ class HotfolderService:
# ---- Queue ---- # ---- Queue ----
def _scan_existing(self) -> None: def _scan_existing(self) -> None:
"""Beim Start: bereits liegende PDFs aufgreifen.""" """Beim Start: bereits liegende PDFs aufgreifen.
for p in self.cfg.paths.incoming.iterdir():
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): if _is_pdf(p):
self.enqueue(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: def enqueue(self, path: Path) -> None:
if not _is_pdf(path): if not _is_pdf(path):
return return
+9 -4
View File
@@ -1,4 +1,9 @@
ocrmypdf>=16.0 # Feste Pins: ein Update darf nicht ungefragt einen Major-Sprung einziehen
watchdog>=4.0 # (ein ocrmypdf 16 -> 17 reisst sonst alle Instanzen auf einmal).
requests>=2.31 # Geprueft gegen Python 3.11 (Debian 12) und 3.13 (Debian 13) — fuer beide
paramiko>=3.4 # 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
+4 -1
View File
@@ -12,7 +12,10 @@ ExecStart=/opt/pdf-ocr-hotfolder/venv/bin/python -m pdf_ocr_hotfolder --config /
Restart=on-failure Restart=on-failure
RestartSec=5 RestartSec=5
KillMode=mixed 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) # Hardening (lockerer wegen AD-User & Datei-ACLs)
NoNewPrivileges=true NoNewPrivileges=true
+165
View File
@@ -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
+198
View File
@@ -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
+204
View File
@@ -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"
+798 -48
View File
@@ -3,24 +3,685 @@
# PDF OCR Hotfolder — Update-Script # PDF OCR Hotfolder — Update-Script
# #
# Aktualisiert Code und venv unter /opt/pdf-ocr-hotfolder/ sowie die # Aktualisiert Code und venv unter /opt/pdf-ocr-hotfolder/ sowie die
# systemd Template-Unit. Danach werden alle laufenden Instanzen neu gestartet. # systemd Template-Unit. Danach werden alle Instanzen neu gestartet, die
# Config-Dateien unter /etc/pdf-ocr-hotfolder/ bleiben unverändert. # 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_info() { echo -e "${GREEN}[INFO]${NC} $*"; }
log_warn() { echo -e "${YELLOW}[WARN]${NC} $*"; } log_warn() { echo -e "${YELLOW}[WARN]${NC} $*"; }
log_error() { echo -e "${RED}[ERROR]${NC} $*"; } log_error() { echo -e "${RED}[ERROR]${NC} $*"; }
log_step() { echo -e "\n${BLUE}==>${NC} $*"; }
if [ "${EUID}" -ne 0 ]; then # Pfade sind ueberschreibbar, damit die Funktionen dieses Skripts isoliert
log_error "Bitte als root ausführen: sudo ./update.sh" # gegen eine Fake-Umgebung getestet werden koennen (siehe LIB_ONLY unten).
exit 1 : "${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 <<EOF
PDF OCR Hotfolder — Update
sudo ./update.sh [OPTIONEN]
Optionen:
--rebuild-venv Python-venv zwingend neu bauen und Requirements frisch
installieren. Das ist der Aufruf nach einem Debian-
Major-Upgrade (apt full-upgrade, z.B. 12 -> 13), wenn das
System-Python eine neue Version bekommen hat.
-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 <datei>
# 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 fi
INSTALL_DIR="/opt/pdf-ocr-hotfolder" # ============================================================
CONFIG_DIR="/etc/pdf-ocr-hotfolder" # Main
SERVICE_TEMPLATE="pdf-ocr-hotfolder@.service" # ============================================================
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)" SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
if [ -f "$SCRIPT_DIR/pdf_ocr_hotfolder/__init__.py" ]; then 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")" REPO_DIR="$(cat "$INSTALL_DIR/.repo_path")"
[ -d "$REPO_DIR" ] || { log_error "Gespeicherter Repo-Pfad existiert nicht: $REPO_DIR"; exit 1; } [ -d "$REPO_DIR" ] || { log_error "Gespeicherter Repo-Pfad existiert nicht: $REPO_DIR"; exit 1; }
else 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 exit 1
fi 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)" OLD_VERSION="$(cat "$INSTALL_DIR/VERSION" 2>/dev/null || echo unknown)"
NEW_VERSION="$(cat "$REPO_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" log_info "Version: $OLD_VERSION → $NEW_VERSION"
echo echo
# Laufende Instanzen ermitteln # Ab hier kann ein Abbruch Instanzen gestoppt zuruecklassen.
mapfile -t RUNNING < <(systemctl list-units --no-legend --state=active 'pdf-ocr-hotfolder@*.service' 2>/dev/null | awk '{print $1}') trap 'abort_handler $? $LINENO' ERR
if [ "${#RUNNING[@]}" -gt 0 ]; then trap 'abort_handler 130 $LINENO' INT
log_info "Laufende Instanzen: ${RUNNING[*]}" 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 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 fi
log_info "Stoppe laufende Instanzen..." # --- Ab hier wird angefasst ---
for unit in "${RUNNING[@]}"; do log_step "Instanzen stoppen"
systemctl stop "$unit" || true stop_instances
done
log_info "Backup erstellen..." create_backup
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
log_info "Code aktualisieren..." log_step "Code aktualisieren"
TOUCHED=1
rm -rf "$INSTALL_DIR/pdf_ocr_hotfolder" rm -rf "$INSTALL_DIR/pdf_ocr_hotfolder"
cp -r "$REPO_DIR/pdf_ocr_hotfolder" "$INSTALL_DIR/" cp -r "$REPO_DIR/pdf_ocr_hotfolder" "$INSTALL_DIR/"
cp "$REPO_DIR/requirements.txt" "$INSTALL_DIR/" cp "$REPO_DIR/requirements.txt" "$INSTALL_DIR/"
cp "$REPO_DIR/VERSION" "$INSTALL_DIR/" cp "$REPO_DIR/VERSION" "$INSTALL_DIR/"
cp "$REPO_DIR/config.example.toml" "$INSTALL_DIR/" cp "$REPO_DIR/config.example.toml" "$INSTALL_DIR/"
echo "$REPO_DIR" > "$INSTALL_DIR/.repo_path" echo "$REPO_DIR" > "$INSTALL_DIR/.repo_path"
log_info "Code auf $NEW_VERSION ✓"
log_info "Dependencies aktualisieren..." log_step "Dependencies aktualisieren"
"$INSTALL_DIR/venv/bin/pip" install --upgrade pip -q if [ "$NEED_REBUILD" -eq 1 ]; then
"$INSTALL_DIR/venv/bin/pip" install --upgrade -r "$INSTALL_DIR/requirements.txt" -q 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..." install_units
cp "$REPO_DIR/systemd/$SERVICE_TEMPLATE" "/etc/systemd/system/$SERVICE_TEMPLATE"
systemctl daemon-reload
log_info "Berechtigungen setzen..." log_step "Berechtigungen setzen"
# Eigentümer des Codes bleibt der primäre User (pdfocr); Instanzen laufen # Eigentuemer des Codes bleibt der primaere User (pdfocr); Instanzen laufen
# ggf. als anderer User, lesen aber nur den Code. # 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" chown -R "$PRIMARY_USER":"$PRIMARY_USER" "$INSTALL_DIR"
log_info "Eigentuemer: $PRIMARY_USER"
log_info "Starte Instanzen wieder..." check_all_configs
FAIL=0
for unit in "${RUNNING[@]}"; do log_step "Instanzen starten"
systemctl start "$unit" || true start_instances
sleep 1
if systemctl is-active --quiet "$unit"; then # ============================================================
log_info " ✅ $unit" # 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 else
log_error " ❌ $unit — journalctl -u $unit -n 30" STILL_BROKEN+=("$unit")
FAIL=1
fi fi
done 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:-<backup>}' -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 echo
if [ "$FAIL" -eq 0 ]; then if [ "$RC" -eq 0 ]; then
log_info "Update auf $NEW_VERSION abgeschlossen ✓" log_info "Update auf $NEW_VERSION abgeschlossen ✓"
else else
log_warn "Update abgeschlossen, aber mindestens eine Instanz läuft nicht." log_error "Update abgeschlossen, aber mit Problemen (siehe oben)."
exit 1
fi fi
exit "$RC"