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
+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.