feat: stille Datenverlust-Pfade geschlossen, gemeinsame Shell-Lib (v0.7.0)

Vor dem Rollout durchgesehen und die verbliebenen Stellen geschlossen, an
denen etwas schiefgehen konnte, ohne dass es irgendwo sichtbar wurde.

Datenverlust:
- veraPDF: das in [verapdf].binary konfigurierte Programm wird im Preflight
  geprueft. Bisher galt bei falschem Pfad JEDE Datei als "nicht konform" —
  Ergebnis nach error/, Original geloescht (Default delete). run_verapdf()
  trennt jetzt ausserdem ein echtes FAIL-Urteil von einer Stoerung
  (VeraPdfUnavailable: nicht startbar, abgestuerzt, kein PASS/FAIL in der
  Ausgabe). Bei Stoerung wandern Original UND Ergebnis nach error/, das
  Original wird nicht entsorgt.
- Gleichnamige Dateien wurden in outgoing/, error/ und beim Ordner-Upload
  mit abweichendem target kommentarlos ueberschrieben. Jetzt Zeitstempel
  daneben, mit Warnung; ProcessResult.output traegt den echten Pfad.

Robustheit:
- Kaputtes oder nicht lesbares TOML beim Start: Exit 2 statt Traceback.
- RestartPreventExitStatus=2 in der Unit — Exit 2 (Config/Preflight) laeuft
  nicht mehr endlos neu, die Instanz bleibt sichtbar failed stehen.
- Toter watchdog-Observer wird erkannt: Exit 3, systemd setzt den Watch neu
  auf. Vorher blieb die Unit "active" und verarbeitete nichts mehr.
- Relative Pfade in [paths]/archive_dir/target sind ein Config-Fehler statt
  still unter /opt zu landen.
- Fehler beim Archivieren entwertet den Durchlauf nicht mehr: Upload und
  Mail laufen, Sichtbarkeit ueber log.error + "OK mit Warnung"-Mail.
- Nicht-PDFs in incoming/ werden beim Start-Scan gesammelt gemeldet.
- Logging explizit nach stdout (die Doku versprach das schon).

Struktur:
- Neue lib/common.sh, von install.sh und update.sh gesourct. Die doppelte
  venv_is_healthy() gibt es nur noch einmal, in der gruendlichen Fassung —
  die schlanke in install.sh haette eine nach einem Distro-Sprung kaputte
  venv als gesund durchgewunken (nachgewiesen).
- install.sh warnt in Containern, wenn systemd-journald nicht laeuft.

Doku: Dateisystem-Festlegung (ext4/xfs/zfs, kein CIFS/NFS wegen inotify),
Debian 13 in LXC auf Proxmox scheitert an journald (243/CREDENTIALS,
AppArmor blockiert sd-mkdcreds) inkl. Abhilfe, echte Speicher-Messwerte,
Exit-Code-Tabelle.

254 Tests gruen (vorher 152).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-09-23 00:59:17 +02:00
parent 305454eeb5
commit cd803a3dfe
28 changed files with 2902 additions and 286 deletions
+155 -45
View File
@@ -1,8 +1,8 @@
# AI Agent Briefing — PDF OCR Hotfolder
**Zuletzt aktualisiert:** 2026-09-23
**Version:** 0.6.3
**Status:** Multi-Instanz-Betrieb, Preflight-Checks, Fehlerzählung, Wiederaufnahme aus `working/` und ein abgesicherter Updater (venv-Rebuild, Backup, Verifikation, Versionssprung-Meldung, Rauchtest). Test-Suite grün (152 pytest-Tests). Ein Produktiv-Einsatz ist im Repo (README/CHANGELOG) nicht dokumentiert — die bisherigen Fixes stammen aus Issues #1–#6 und aus Vorbereitungen auf Debian 13, nicht aus einem belegten Dauerbetrieb.
**Version:** 0.7.0
**Status:** Multi-Instanz-Betrieb, Preflight-Checks (inkl. veraPDF-Binary), Fehlerzählung, Wiederaufnahme aus `working/`, Kollisionsschutz auf allen Schreibpfaden, Bewachung des Verzeichnis-Watches und ein abgesicherter Updater (venv-Rebuild, Backup, Verifikation, Versionssprung-Meldung, Rauchtest). `install.sh` und `update.sh` teilen sich `lib/common.sh`. Test-Suite grün (**254 pytest-Tests**). Ein Produktiv-Einsatz ist im Repo (README/CHANGELOG) nicht dokumentiert — die bisherigen Fixes stammen aus Issues #1–#6, aus Vorbereitungen auf Debian 13 und aus einer Durchsicht auf stille Datenverlust-Pfade (0.7.0), nicht aus einem belegten Dauerbetrieb.
> **Betriebsabläufe stehen nicht hier**, sondern in:
> [README.md](README.md) (Einstieg, Layout, Config-Überblick) ·
@@ -23,33 +23,46 @@ pdf-ocr-hotfolder/
│ ├── __init__.py # Versionsstring (__version__)
│ ├── __main__.py # CLI (argparse: --config, --once, --check-config, --version)
│ ├── config.py # TOML-Loader, Dataclasses, ConfigError, Warnungen
│ ├── service.py # HotfolderService (watchdog + ThreadPool), Preflight, Resume, Zähler
│ ├── processor.py # ocrmypdf-Call, veraPDF, Ausgabename, Original-Entsorgung
│ ├── service.py # HotfolderService (watchdog + ThreadPool), Preflight, Resume, Zähler, Observer-Bewachung
│ ├── processor.py # ocrmypdf-Call, veraPDF, Ausgabename, Original-Entsorgung, Kollisionsschutz
│ └── uploaders.py # folder, nextcloud (WebDAV), sftp, E-Mail-Notify
├── tests/ # pytest-Suite (152 Tests, ocrmypdf wird gemockt)
├── lib/
│ └── common.sh # gemeinsam fuer install.sh + update.sh: Logging, require_root,
│ # Layout-Konstanten, apt-Paketliste, venv_is_healthy()
├── tests/ # pytest-Suite (254 Tests, ocrmypdf wird gemockt)
│ ├── conftest.py # Fixtures tmp_config / dummy_pdf
│ ├── test_check_config.py # --check-config, Exit 0/1/2
│ ├── test_config_errors.py
│ ├── test_config_warnings.py # Legacy- und Unbekannt-Warnungen
│ ├── test_config_warnings.py # Legacy- und Unbekannt-Warnungen
│ ├── test_dispose_failure.py # Original nicht entsorgbar -> Erfolg + warning
│ ├── test_error_collision.py # error/ ueberschreibt nichts
│ ├── test_error_counting.py
│ ├── test_ghostscript_version.py
│ ├── test_incoming_non_pdf.py # Sammelmeldung fuer Fremddateien
│ ├── test_log_stream.py # Logging geht nach stdout
│ ├── test_observer_watchdog.py # toter Observer -> EXIT_OBSERVER_DEAD
│ ├── test_ocr_timeout.py
│ ├── test_once_exit_code.py
│ ├── test_outgoing_collision.py # outgoing/ + Archiv ueberschreiben nichts
│ ├── test_output_naming.py
│ ├── test_preflight.py
│ ├── test_resume_working.py # Wiederaufnahme + __ocr_-Fragmente
│ └── test_upload_folder.py
│ ├── test_relative_paths.py # relative Pfade -> ConfigError
│ ├── test_resume_working.py # Wiederaufnahme + __ocr_-Fragmente
│ ├── test_startup_toml_error.py # kaputtes TOML beim Dienststart -> Exit 2
│ ├── test_upload_folder.py
│ └── test_verapdf_preflight.py # Binary-Pruefung + VeraPdfUnavailable
├── systemd/
│ ├── pdf-ocr-hotfolder@.service # Template-Unit (Instanz = %i), TimeoutStopSec=300
│ ├── pdf-ocr-hotfolder@.service # Template-Unit (Instanz = %i), TimeoutStopSec=300,
│ │ # RestartPreventExitStatus=2
│ └── lxc-compat.conf # Drop-in-Vorlage: Hardening für LXC abschalten
├── docs/
│ ├── INSTALLATION.md # Erstinstallation + Konfigurationsreferenz
│ ├── INSTALLATION.md # Erstinstallation, Konfigurationsreferenz, Exit-Codes, Troubleshooting
│ ├── UPDATE.md # update.sh, Backup/Rollback, --check-config, Config-Drift
│ └── OS-UPGRADE.md # Debian 12 -> 13 -> 14, venv-Rebuild, Pins
├── pytest.ini # testpaths = tests
├── config.example.toml
├── install.sh # Interaktiver Installer + Instanz-Manager
├── update.sh # Updater (--help, --rebuild-venv), ~860 Zeilen
├── install.sh # Interaktiver Installer + Instanz-Manager, ~500 Zeilen
├── update.sh # Updater (--help, --rebuild-venv, --no-smoke-test), ~1270 Zeilen
├── requirements.txt # feste Pins (ocrmypdf 17.x — 16.x ist unbrauchbar, s. 0.6.1)
├── VERSION
├── CHANGELOG.md
@@ -82,6 +95,7 @@ Instanzen auf einmal reißen). Geprüft gegen Python 3.11 (Debian 12) und 3.13
| Pfad | Inhalt |
|------|--------|
| `/opt/pdf-ocr-hotfolder/` | Code + venv (für alle Instanzen gemeinsam) |
| `/opt/pdf-ocr-hotfolder/lib/common.sh` | Kopie der gemeinsamen Shell-Bibliothek; `update.sh` sourct sie, wenn es nicht aus dem Repo läuft |
| `/opt/pdf-ocr-hotfolder/.repo_path` | Pfad zum Repo, aus dem installiert wurde (nutzt `update.sh`) |
| `/etc/pdf-ocr-hotfolder/<instanz>.toml` | Config pro Instanz (mode 640, root:<service-group>) |
| `/etc/systemd/system/pdf-ocr-hotfolder@.service` | Template-Unit |
@@ -92,7 +106,9 @@ Instanzen auf einmal reißen). Geprüft gegen Python 3.11 (Debian 12) und 3.13
Ein eigenes Logverzeichnis gibt es **nicht** (seit 0.4.1 auch nicht mehr vom
Installer angelegt): `_setup_logging()` nutzt `logging.basicConfig()` ohne
FileHandler, alles geht nach stdout → journald.
FileHandler, alles geht nach stdout → journald. Der Stream wird seit 0.7.0
**explizit** auf `sys.stdout` gesetzt — der `basicConfig()`-Default ist
**stderr**, und README wie `docs/INSTALLATION.md` versprachen stdout.
```bash
journalctl -u pdf-ocr-hotfolder@<instanz> -f # eine Instanz mitlesen
@@ -116,7 +132,12 @@ Arbeit am Code zählt:
- Basis-Install wird an `venv` + Template-Unit erkannt und übersprungen — **außer**
die venv passt nicht mehr zum System-Python, dann läuft er zur Reparatur erneut
(`venv_is_healthy()` in `install.sh`, schlankere Variante der Prüfung in `update.sh`).
(`venv_is_healthy()` aus `lib/common.sh`, Befunde über `report_venv_issues`).
- In Containern (`systemd-detect-virt --container`) bietet der Installer das
LXC-Drop-in an **und** prüft, ob `systemd-journald` läuft. Tut es das nicht,
warnt er (der Dienst loggt ausschließlich nach journald), nennt den
`ImportCredential=`-Drop-in für den Debian-13-Fall und fragt, ob fortgefahren
werden soll.
- Abfragen pro Instanz: Name, Basis-Pfad, Service-User, **OCR-Sprachen**,
**Original archivieren?** — `LANGS`/`ORIG_MODE`/`ARCHIVE_DIR` sind `local` in
`create_instance()`, gelten also **instanz-lokal** und nicht global.
@@ -132,15 +153,50 @@ Arbeit am Code zählt:
Kommentarzeilen über den Keys nicht getroffen werden; Pfad-Variablen laufen
vorher durch `sed_escape_repl()` (maskiert `\`, `&`, `|`). Nach dem sed-Lauf
liest `config_value()` die drei Keys zurück und vergleicht sie mit der Eingabe.
- Die apt-Paketliste steht als **einzige Quelle** in `install.sh` zwischen den
- Die apt-Paketliste steht als **einzige Quelle** in `lib/common.sh` zwischen den
Marken `# --- BEGIN apt-packages` / `# --- END apt-packages` in der Funktion
`pdf_ocr_apt_packages()`. **`update.sh` schneidet diesen Block per `sed` heraus
und evaluiert ihn** — Marken und Funktionsname dürfen sich nicht ändern, ohne
`update.sh` anzupassen.
`pdf_ocr_apt_packages()`. `install.sh` bekommt sie durchs Sourcen;
**`update.sh` schneidet den Block zusätzlich per `sed` aus der Repo-Fassung
heraus und evaluiert ihn**, weil gesourct evtl. die ältere installierte Kopie
wurde. Marken und Funktionsname dürfen sich nicht ändern, ohne `update.sh`
anzupassen.
- Instanz wird sofort `enable --now` gestartet. Löschen macht der Installer
nicht, das steht als Handgriff in
[docs/INSTALLATION.md](docs/INSTALLATION.md#instanz-manuell-löschen).
## 🧰 `lib/common.sh` — gemeinsame Shell-Bibliothek
Seit 0.7.0 sourcen `install.sh` und `update.sh` dieselbe Datei. Sie führt beim
Sourcen **nichts** aus, was das System anfasst, und enthält nur Definitionen:
| Inhalt | Details |
|--------|---------|
| Ausgabe | `log_info`/`log_warn`/`log_error`/`log_step`, Farbkonstanten |
| Rechte | `require_root "<gemeinter Aufruf>"` |
| Layout | `INSTALL_DIR`, `CONFIG_DIR`, `DATA_ROOT`, `SYSTEMD_DIR`, `DEFAULT_USER`, `SERVICE_TEMPLATE`, `LXC_DROPIN_DIR`, `LXC_DROPIN`, `COMMON_LIB_REL` — alle per `: "${X:=…}"`, also aus der Umgebung überschreibbar (Tests) |
| Pakete | `pdf_ocr_apt_packages()` zwischen den BEGIN/END-Marken |
| venv | `py_mm()`, `pyvenv_cfg_mm()`, `venv_is_healthy()`, `report_venv_issues()` |
Drei Dinge, die man dabei wissen muss:
- **`venv_is_healthy()` gibt es nur noch einmal.** Vorher hatte jedes der beiden
Skripte eine eigene Fassung, und die in `install.sh` war die schlankere: sie
verglich nur `major.minor` des venv-Interpreters mit dem System-Python und
hätte den Distro-Upgrade-Fall über `pyvenv.cfg` nicht bemerkt. Erhalten
geblieben ist die gründliche Fassung (Verzeichnis, ausführbarer Interpreter,
Interpreter **läuft**, Version == System-Python, `pyvenv.cfg` == Interpreter).
Sie setzt `VENV_ISSUES` und gibt nichts selbst aus — dafür ist
`report_venv_issues()` da.
- **`lib/` wird mitinstalliert.** `install.sh` **und** `update.sh` kopieren es
nach `/opt/pdf-ocr-hotfolder/lib/`, jeweils mit vorherigem
`rm -rf "${INSTALL_DIR:?}/lib"`. Es liegt damit auch im Update-Backup (das
sichert `$INSTALL_DIR` ohne venv).
- **Fundreihenfolge in `update.sh`:** erst `$SCRIPT_DIR/lib/common.sh` (Repo),
dann `${INSTALL_DIR}/lib/common.sh`. Fehlt sie überall, bricht das Skript
**sofort** ab — ein `command not found` mitten im Lauf wäre die schlechtere
Nachricht. `install.sh` sucht nur neben sich und verlangt das vollständige
Repo.
## 🔄 Update-Verhalten (Kurzfassung)
Vollständig: [docs/UPDATE.md](docs/UPDATE.md). Für die Arbeit am Skript wichtig:
@@ -159,10 +215,10 @@ Vollständig: [docs/UPDATE.md](docs/UPDATE.md). Für die Arbeit am Skript wichti
- **Verifikation**: `verify_unit()` wartet `VERIFY_WAIT` (6 s) und prüft
`is-active`, `is-failed` **und** `NRestarts` — sonst würde ein Crash-Loop bei
`Type=simple` als Erfolg durchgehen. Vorher `reset-failed`.
- **venv-Health** (`venv_is_healthy()` in `update.sh`): Verzeichnis, ausführbarer
Interpreter, Interpreter **läuft** überhaupt, `major.minor` == System-Python,
`pyvenv.cfg` stimmt mit dem Interpreter überein. Bei Drift wird auch ohne
`--rebuild-venv` neu gebaut.
- **venv-Health** (`venv_is_healthy()` aus `lib/common.sh`): Verzeichnis,
ausführbarer Interpreter, Interpreter **läuft** überhaupt, `major.minor` ==
System-Python, `pyvenv.cfg` stimmt mit dem Interpreter überein. Bei Drift wird
auch ohne `--rebuild-venv` neu gebaut.
- **`rebuild_venv()` ist ganz oder gar nicht**: alte venv nach
`venv.old-<ts>`, neu bauen, Requirements installieren, **erst bei Erfolg** die
alte löschen; scheitert etwas, wird zurückgerollt und hart abgebrochen.
@@ -182,7 +238,9 @@ Vollständig: [docs/UPDATE.md](docs/UPDATE.md). Für die Arbeit am Skript wichti
muss erhalten bleiben — `update.sh` kopiert daraus (`.repo_path`).
- `PDF_OCR_UPDATE_LIB_ONLY=1 source ./update.sh` lädt nur die Funktionen, ohne
irgendetwas zu tun — dafür sind `INSTALL_DIR`, `CONFIG_DIR`, `SYSTEMD_DIR`,
`BACKUP_DIR`, `TAR_ROOT`, `VERIFY_WAIT`, `BACKUP_KEEP` überschreibbar.
`BACKUP_DIR`, `TAR_ROOT`, `VERIFY_WAIT`, `BACKUP_KEEP` überschreibbar. Die
Vorgaben stehen in `lib/common.sh` und sind dort ebenfalls überschreibbar
gehalten (`: "${X:=…}"`).
## ⚙️ Konfiguration (Überblick)
@@ -191,16 +249,24 @@ Vollständiges Beispiel mit Kommentaren: `config.example.toml`.
| Sektion | Zweck |
|---------|-------|
| `[paths]` | `incoming`, `outgoing`, `working`, `error` — **Pflicht**, fehlt einer → `ConfigError` + Exit 2 |
| `[paths]` | `incoming`, `outgoing`, `working`, `error` — **Pflicht** und **absolut**, fehlt einer oder ist relativ → `ConfigError` + Exit 2 |
| `[ocr]` | `languages`, `jobs`, `skip_text`, `oversample`, `pdfa_level`, `deskew`, `clean`, `max_workers`, `timeout` (Sekunden **pro Seite**) |
| `[output]` | `name_mode` (`prefix`/`suffix`/`none`), `name_tag`, `original_on_success` (`delete`/`archive`), `archive_dir` |
| `[output]` | `name_mode` (`prefix`/`suffix`/`none`), `name_tag`, `original_on_success` (`delete`/`archive`), `archive_dir` (absolut) |
| `[verapdf]` | `enabled`, `binary`, `flavour` — optionale PDF/A-Validierung per CLI |
| `[upload.folder]` | `enabled`, `target` (leer = `[paths].outgoing`, dann No-op) |
| `[upload.folder]` | `enabled`, `target` (leer = `[paths].outgoing`, dann No-op; sonst absolut) |
| `[upload.nextcloud]` | `enabled`, `url`, `username`, `password`, `remote_path`, `verify_ssl` |
| `[upload.sftp]` | `enabled`, `host`, `port`, `username`, `key_file`, `password`, `remote_path` |
| `[notify.email]` | `enabled`, SMTP-Daten, `from_addr`, `to_addrs`, `on` = `always`/`errors`/`never` |
| `[logging]` | `level` = DEBUG/INFO/WARNING/ERROR |
**Absolute Pfade sind Pflicht** (`_require_absolute()` in `config.py`, seit
0.7.0): `[paths]`-Einträge, `[output].archive_dir` und
`[upload.folder].target`. Ein relativer Pfad wurde gegen das
`WorkingDirectory` der Unit aufgelöst, landete also still unter
`/opt/pdf-ocr-hotfolder/` — der Scanner schrieb dann woanders hin als der
Dienst schaute, ohne dass irgendwo ein Fehler auftauchte. Leere Werte bleiben
erlaubt (beide Keys sind optional).
Unbekannte Keys werden beim Laden zwar ignoriert, aber **nicht mehr still**:
`_collect_unknown_keys()` sammelt sie in `Config.unknown_keys` (Format
`[ocr].langauges`, auch ganze unbekannte Sektionen und Upload-/Notify-Targets),
@@ -221,19 +287,37 @@ Unbekannte Keys werden beim Laden zwar ignoriert, aber **nicht mehr still**:
### `--check-config`
`python -m pdf_ocr_hotfolder --check-config --config <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.
zeigt Pfade/Sprachen/Timeout/PDF/A sowie veraPDF-Binary und -Flavour (bzw.
`(aus)`), fährt `check_preflight()` und `check_output_config()` und gibt die
Warnungen aus. Exit-Codes: `CHECK_OK=0`, `CHECK_WARN=1`, `CHECK_ERROR=2`. Hat
Vorrang vor `--once`. `update.sh` wertet genau diese Codes aus und erkennt an
der argparse-Meldung, wenn der installierte Code das Flag noch nicht kennt.
### Exit-Codes des Prozesses
| Code | Woher | Bedeutung |
|------|-------|-----------|
| `0` | `main()` | regulärer Stopp, `--once` ohne Fehler, Config sauber |
| `1` | `main()` | `--once` mit `error_count > 0`; `--check-config` mit Warnungen |
| `2` | `main()` | `ConfigError`, `TOMLDecodeError`, `OSError` beim Laden, `PreflightError` |
| `3` | `service.EXIT_OBSERVER_DEAD` | watchdog-Observer gestorben — Neustart **erwünscht** |
`run()` gibt seit 0.7.0 einen `int` zurück (vorher `None`), `main()` reicht ihn
durch. Die Unit setzt **`RestartPreventExitStatus=2`**: Config-/Preflight-Fehler
heilt kein Neustart, die Instanz bleibt sichtbar `failed` stehen statt im
5-Sekunden-Takt zu kreisen (das Start-Rate-Limit greift bei `RestartSec=5` nie).
Exit 3 ist **bewusst nicht** 2, damit `Restart=on-failure` dort greift.
Anwender-Sicht: [docs/INSTALLATION.md](docs/INSTALLATION.md#exit-codes).
## 🔄 Verarbeitungs-Flow
**Beim Start (`run()` wie `run_once()`), vor allem anderen:**
1. `check_preflight(pdfa_level, skip_text)` — `tesseract` und `gs` müssen im PATH sein; zusätzlich wird die Ghostscript-Version gegen den 10.0.0–10.02.0-Bug geprüft, und zwar unter genau der Bedingung, unter der ocrmypdf selbst abbricht (`_gs_block_reason()`: betroffene GS-Version **und** `skip_text` **und** (`pdfa_level` gesetzt **oder** ocrmypdf < 17))
2. `check_output_config()` — validiert `original_on_success`, `archive_dir` (Pflicht bei `archive`) und `name_mode`
3. Scheitert eines davon → `PreflightError`, CLI beendet sich mit **Exit-Code 2** (ebenso bei kaputter/fehlender Config)
4. `ensure_dirs()`, dann `_scan_existing()`: **zuerst `working/`**, danach `incoming/`
**Beim Start (`run()` wie `run_once()`), vor allem anderen** — beide rufen
dasselbe `_preflight()`:
1. `check_preflight(pdfa_level, skip_text, verapdf_enabled, verapdf_binary)` — `tesseract` und `gs` müssen im PATH sein; zusätzlich wird die Ghostscript-Version gegen den 10.0.0–10.02.0-Bug geprüft, und zwar unter genau der Bedingung, unter der ocrmypdf selbst abbricht (`_gs_block_reason()`: betroffene GS-Version **und** `skip_text` **und** (`pdfa_level` gesetzt **oder** ocrmypdf < 17))
2. `check_verapdf_binary()` — nur bei `[verapdf].enabled`: `binary` darf nicht leer sein und muss über `resolve_verapdf_binary()` auffindbar **und ausführbar** sein (Pfad mit `/` direkt geprüft, nackter Name über `shutil.which`)
3. `check_output_config()` — validiert `original_on_success`, `archive_dir` (Pflicht bei `archive`) und `name_mode`
4. Scheitert eines davon → `PreflightError`, CLI beendet sich mit **Exit-Code 2** (ebenso bei kaputter/unlesbarer/fehlender Config)
5. `ensure_dirs()`, dann `_scan_existing()`: **zuerst `working/`**, danach `incoming/`; Dateien ohne `.pdf`-Endung meldet `_report_non_pdf()` als **eine** Sammelzeile (Anzahl + bis zu 3 Beispiele), nur beim Start-Scan
**Wiederaufnahme aus `working/` (`_scan_working()`):**
`process_pdf()` verschiebt das Original vor dem OCR nach `working/`. Wird der
@@ -252,16 +336,34 @@ liegen und wurde nie wieder angefasst — stiller Datenverlust. Jetzt:
`process_pdf()` für die neue ab und lässt sie in `incoming/` liegen, statt den
laufenden Vorgang stillschweigend zu überschreiben.
**Im Betrieb (`_wait_loop()`):** die Schleife wartet nicht nur auf den Stopp,
sie prüft **sekündlich `self._observer.is_alive()`**. Stirbt der Observer
(erschöpftes `fs.inotify.max_user_watches`, ersetztes oder neu gemountetes
Verzeichnis), blieb die Unit früher `active (running)` und verarbeitete stumm
nichts mehr. Jetzt: `log.error` mit den möglichen Ursachen und `return
EXIT_OBSERVER_DEAD` (3), damit `Restart=on-failure` den Watch neu aufsetzt. Bei
regulärem Stopp wird die Prüfung übersprungen, sonst gäbe es dort einen
Fehlalarm.
**Pro Datei:**
1. `watchdog` triggert auf `created`/`moved`/`closed` in `incoming/`
2. `_wait_until_stable()` wartet, bis die Datei nicht mehr wächst (max. ~60s)
3. Move nach `working/` (entfällt bei Wiederaufnahme)
4. `ocrmypdf.ocr()` als **Library-Call** (kein Subprozess-Start pro PDF), Ziel ist `working/__ocr_<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)
6. Move nach `outgoing/` unter dem laut `[output]` gebauten Namen (`build_output_name()`)
7. Original in `working/` wird laut `original_on_success` **gelöscht** oder nach `archive_dir` **archiviert** (Kollision → Timestamp-Suffix)
5. Optional: veraPDF-Validierung (CLI-Subprozess). **FAIL** → OCR-Ergebnis nach `error/`, Original folgt `original_on_success` (bei `archive` also erhalten). **`VeraPdfUnavailable`** → Original **und** Ergebnis nach `error/`, das Original wird weder gelöscht noch archiviert
6. Move nach `outgoing/` unter dem laut `[output]` gebauten Namen (`build_output_name()`), vorher durch `_collision_free_path()` — `ProcessResult.output` trägt den **tatsächlich** geschriebenen Pfad
7. Original in `working/` wird laut `original_on_success` **gelöscht** oder nach `archive_dir` **archiviert** (Kollision → Timestamp-Suffix). `_dispose_original()` wirft nicht, sondern liefert bei Misserfolg einen Meldungstext → `ProcessResult.warning`
8. Aktive Upload-Targets ausführen (folder/nextcloud/sftp)
9. E-Mail-Notify je nach `[notify.email].on`
9. E-Mail-Notify je nach `[notify.email].on` — bei gesetztem `warning` als **„OK mit Warnung"** und mit `success=False` an `notify_email()`, damit sie auch bei `on = "errors"` zugestellt wird
**Kollisionsschutz (`_collision_free_path()` in `processor.py`):** existiert das
Ziel, wird `scan.pdf` zu `scan_<YYYYmmdd-HHMMSS>.pdf`; ist auch das belegt (zwei
Dateien in derselben Sekunde, mehrere Worker), wird zusätzlich hochgezählt.
Benutzt von `outgoing/`, `_dispose_original()` (Archiv), `_move_to_error()` —
und damit auch `_rescue_to_error()` — sowie `upload_folder()` in
`uploaders.py`. Jeder dieser Pfade überschrieb vorher still. **`uploaders.py`
importiert dafür `_collision_free_path` aus `processor.py`** — die einzige
Abhängigkeit in diese Richtung.
**Fehlerbehandlung:**
@@ -272,6 +374,8 @@ liegen und wurde nie wieder angefasst — stiller Datenverlust. Jetzt:
| In `working/` liegt schon eine andere Datei gleichen Namens | ja | bleibt in `incoming/` |
| OCR wirft (ocrmypdf) | ja | `error/` |
| veraPDF FAIL | ja | OCR-Ergebnis nach `error/`, Original laut `original_on_success` (`delete` → weg, `archive` → `archive_dir`; seit 0.4.1) |
| veraPDF nicht befragbar (`VeraPdfUnavailable`) | ja | **Original UND Ergebnis** nach `error/`; das Original wird weder gelöscht noch archiviert (seit 0.7.0) |
| Original lässt sich nicht entsorgen (`_dispose_original`) | **nein** — gilt als Erfolg | Ergebnis in `outgoing/`, Upload läuft; Original bleibt in `working/` und wird beim nächsten Start erneut verarbeitet. `log.error` + Mail „OK mit Warnung" |
| Beliebige Exception aus `process_pdf()` (z.B. `shutil.move` nach `outgoing/`) | ja | `_rescue_to_error()` sucht in `incoming/` und `working/` und verschiebt nach `error/` |
| Mindestens ein Upload-Ziel schlägt fehl | ja | PDF bleibt **bewusst in `outgoing/`** (das OCR war ja erfolgreich), Fehler-Mail nennt die Ziele |
@@ -296,9 +400,13 @@ Der Service läuft in allen Fällen weiter (kein `exit 1` wie im alten Bash-Tool
`pdfa_level = ""` ist deshalb **kein** Schutz für sich genommen — es wirkt nur mit ocrmypdf ≥ 17. `requirements.txt` pinnt daher 17.x; ein Downgrade auf 16.x macht jede Debian-12-Instanz unbrauchbar, bei grünem `systemctl status`. Der Preflight bildet die reale Bedingung ab (`_gs_block_reason()`) und bricht mit Exit 2 ab, `--check-config` meldet denselben Zustand als Fehler. `redo_ocr` ist bewusst **nicht** in der Bedingung: die Config kennt keinen solchen Key. Abhilfe: Ghostscript ≥ 10.02.1 aus bookworm-backports (der Installer bietet das an) oder `skip_text = false`.
- **`[ocr].timeout` ist ein Timeout PRO SEITE**, kein Gesamt-Timeout pro PDF. Der Wert geht als `tesseract_timeout` (ocrmypdf-Option `--tesseract-timeout`) durch; ocrmypdf kennt kein Dokument-Timeout. Wer noch den alten Default `1800` in einer Config stehen hat, gibt Tesseract 30 Minuten **je Seite** — Richtwert ist 300, ab 900 warnt `--check-config`. Ein durchgereichtes `0` würde ocrmypdf dazu bringen, OCR **still zu überspringen**, deshalb wird bei `0` (oder negativ) gar nichts übergeben und der ocrmypdf-Default greift.
- **`TimeoutStopSec=300` in der Unit ist Absicht.** Ein laufendes OCR soll beim Stoppen zu Ende laufen dürfen — ein `systemctl stop` kann deshalb pro Instanz bis zu 5 Minuten dauern, und `update.sh` (das nacheinander stoppt) entsprechend länger. Bei SIGKILL bliebe das Original in `working/` liegen; das wird zwar wiederaufgenommen, kostet aber den kompletten Durchlauf.
- **Die venv hängt an der Python-Version der Distribution.** Nach einem Debian-Major-Upgrade ist `venv/bin/python` tot (systemd: `203/EXEC`) oder eine andere Version als das System-Python. Der Weg dahin und zurück steht in [docs/OS-UPGRADE.md](docs/OS-UPGRADE.md); im Code prüfen `install.sh` und `update.sh` das je mit einem eigenen `venv_is_healthy()` (die Variante in `update.sh` ist die gründlichere und schaut zusätzlich in `pyvenv.cfg`).
- **Die venv hängt an der Python-Version der Distribution.** Nach einem Debian-Major-Upgrade ist `venv/bin/python` tot (systemd: `203/EXEC`) oder eine andere Version als das System-Python. Der Weg dahin und zurück steht in [docs/OS-UPGRADE.md](docs/OS-UPGRADE.md); im Code prüfen beide Skripte das mit **demselben** `venv_is_healthy()` aus `lib/common.sh` (seit 0.7.0 — vorher hatte jedes eine eigene Fassung, und die in `install.sh` war die schwächere).
- **`incoming/` darf nicht auf CIFS/NFS liegen.** Der Hotfolder hängt vollständig an inotify, und inotify sieht nur Änderungen des lokalen Kernels. Schreibt ein anderer Rechner über SMB/NFS in ein gemountetes Verzeichnis, entsteht **gar kein Event** — der Dienst meldet `active (running)`, arbeitet beim Start-Scan den Bestand ab und bemerkt danach nichts mehr. Betriebsvorgabe: ext4, xfs oder zfs, `incoming/` lokal ([docs/INSTALLATION.md](docs/INSTALLATION.md#dateisystem-ext4-xfs-oder-zfs)).
- **Debian 13 in LXC auf Proxmox: journald scheitert mit `243/CREDENTIALS`.** systemd ≥ 255 (Debian 13 hat 257) setzt `ImportCredential=journal.*`; der Hilfsprozess `(sd-mkdcreds)` mountet dafür, und das AppArmor-Profil des Proxmox-Hosts blockiert das. Da der Dienst ausschließlich nach journald loggt, gibt es dann **keine** Logs. Betrifft jede Debian-13-LXC auf Proxmox 8.4 (Debian 12 mit systemd 252 nicht) und legt auch logind, networkd, console-getty und tmpfiles-setup lahm. Abhilfe und Hintergrund: [docs/INSTALLATION.md](docs/INSTALLATION.md#debian-13-in-lxc-auf-proxmox-journald-scheitert-243credentials). In 0.6.3 stand hier noch, das sei ein Schaden auf genau einer Maschine — das war falsch.
- **Ein nicht aufrufbares veraPDF war bis 0.6.3 der gefährlichste Fehler des Dienstes.** `run_verapdf()` lieferte für ein fehlendes Binary, einen Timeout oder eine leere Ausgabe schlicht `False` — also ein inhaltliches FAIL-Urteil. Damit wanderte jedes OCR-Ergebnis nach `error/` und das Original wurde laut `original_on_success` entsorgt, beim Default `delete` also gelöscht. Scan für Scan, bei grünem `systemctl status`. Seit 0.7.0: Preflight-Prüfung (Exit 2) **und** `VeraPdfUnavailable` als eigene Ausnahme, die ausdrücklich kein Urteil über die Datei ist.
- **systemd-Hardening bricht in LXC-Containern** (`Error 226/NAMESPACE` durch `PrivateTmp`, `ProtectSystem` usw., Issue #4). Gegenmittel ist das Drop-in `systemd/lxc-compat.conf` nach `/etc/systemd/system/pdf-ocr-hotfolder@.service.d/`; der Installer erkennt Container via `systemd-detect-virt --container` und bietet es an, `update.sh` zieht ein vorhandenes Drop-in nach.
- **Das Paket wird nicht pip-installiert, sondern nach `/opt/pdf-ocr-hotfolder` kopiert.** Gestartet wird per `python -m pdf_ocr_hotfolder`, gefunden wird das Modul nur über das Arbeitsverzeichnis — `WorkingDirectory=/opt/pdf-ocr-hotfolder` in der Unit ist daher Pflicht, nicht Kosmetik (Issue #5). Auch `update.sh` ruft `--check-config` deshalb mit `cd "$INSTALL_DIR"` auf.
- **Relative Pfade in der Config waren still falsch.** Sie wurden gegen `WorkingDirectory=/opt/pdf-ocr-hotfolder` aufgelöst, nicht gegen das Verzeichnis der Config. Seit 0.7.0 `ConfigError` + Exit 2 für `[paths]`, `[output].archive_dir`, `[upload.folder].target`. **Das ist der einzige Fall, in dem ein Update von 0.6.x eine bisher „laufende" Instanz stoppt** — gewollt, siehe [docs/UPDATE.md](docs/UPDATE.md#relative-pfade--fehler-seit-070).
- **Klartext-Passwörter in der Instanz-Config**: SMTP-, Nextcloud- und SFTP-Zugangsdaten stehen unverschlüsselt in `/etc/pdf-ocr-hotfolder/<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).
@@ -316,18 +424,20 @@ python -m pdf_ocr_hotfolder --config /tmp/config.toml
Tests (aus dem Repo-Root, `pytest.ini` setzt `testpaths = tests`):
```bash
pytest # aktuell 152 Tests
pytest # aktuell 254 Tests
```
`ocrmypdf` muss dafür **nicht** installiert sein: der Import in `processor.py` ist lazy, und `tests/test_ocr_timeout.py` schiebt ein Dummy-Modul in `sys.modules`. Die übrigen Tests mocken `process_pdf` bzw. arbeiten nur auf Config-Ebene.
`ocrmypdf` muss dafür **nicht** installiert sein: der Import in `processor.py` ist lazy, und `tests/test_ocr_timeout.py` schiebt ein Dummy-Modul in `sys.modules`. Die übrigen Tests mocken `process_pdf` bzw. arbeiten nur auf Config-Ebene. veraPDF wird über `subprocess.run` gemockt, der watchdog-Observer über ein Fake-Objekt mit `is_alive()`.
## 📋 Roadmap / TODO
- [x] Tests (`pytest`) für `processor` und `uploaders` — 152 Tests
- [x] Tests (`pytest`) für `processor` und `uploaders` — 254 Tests
- [x] Wiederaufnahme abgebrochener Läufe aus `working/`
- [x] Config-Prüfung ohne Verarbeitung (`--check-config`) + Auswertung im Updater
- [x] Updater übersteht Debian-Major-Upgrades (venv-Rebuild, Pins, Rollback)
- [ ] Test-Lücken schließen: der watchdog-Eventpfad (`_Handler`/`Observer`) wird nirgends getestet, `run_verapdf()` ebenso wenig (der FAIL-*Pfad* in `process_pdf()` ist getestet, die veraPDF-CLI-Anbindung selbst nicht), und `run_ocr()` nur gegen ein gemocktes ocrmypdf — es gibt keinen Test mit einer echten PDF-Datei. Auch `upload_nextcloud()` und `upload_sftp()` sind ungetestet (nur `upload_folder()`). **`install.sh`/`update.sh` haben keine automatisierten Tests** — die `LIB_ONLY`-Schnittstelle in `update.sh` ist dafür vorbereitet, aber ungenutzt.
- [x] Stille Datenverlust-Pfade geschlossen: Kollisionsschutz in `outgoing/`, Archiv, `error/` und Ordner-Upload; veraPDF-Preflight + `VeraPdfUnavailable`; relative Pfade als Config-Fehler
- [x] Toter watchdog-Observer wird erkannt (Exit 3) und getestet
- [ ] Test-Lücken schließen: `run_ocr()` läuft nur gegen ein gemocktes ocrmypdf — es gibt keinen Test mit einer echten PDF-Datei. Der `_Handler`-Eventpfad ist weiterhin ungetestet (getestet ist nur die Observer-**Bewachung** in `_wait_loop()`). `upload_nextcloud()` und `upload_sftp()` sind ungetestet (nur `upload_folder()`). **`install.sh`/`update.sh`/`lib/common.sh` haben keine automatisierten Tests** — die `LIB_ONLY`-Schnittstelle in `update.sh` ist dafür vorbereitet, aber ungenutzt; `lib/common.sh` wäre jetzt die einfachste Stelle zum Anfangen, weil sie beim Sourcen nichts tut.
- [ ] Prometheus-Metriken (verarbeitete PDFs, Fehlerquote, Laufzeit)
- [ ] CLI-Subkommandos: `pdf-ocr-hotfolder reprocess <error-file>`
- [ ] Instanz-Löschung in `install.sh` statt als Handarbeit