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:
+155
-45
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user