Files
pdf-ocr-hotfolder/update.sh
T
techadmin aa9918adba fix: ocrmypdf-Pin auf 17.4.1, Preflight erkennt den GS-Fall, Rauchtest (v0.6.1)
v0.6.0 hat ocrmypdf auf 16.13.0 gepinnt, um einen ungewollten Major-Sprung
zu verhindern. Auf Bestandsinstallationen war das ein DOWNGRADE (dort lief
via ">=16.0" bereits 17.x) — und 16.13.0 bricht auf Debian 12 mit dem
Bord-Ghostscript 10.0.0 bei JEDER PDF ab, sobald skip_text gesetzt ist.
Im Test auf CT 200 lief das Update mit Exit 0 durch, der Dienst blieb
"active", --check-config meldete "Preflight ok" — und jede Datei landete
in error/. Stiller Totalausfall.

- requirements.txt: ocrmypdf==17.4.1 (real auf Debian 12 + gs 10.0.0
  verifiziert). Ab 17.0.0 steht die GS-Pruefung in ocrmypdf unter einem
  `if options.output_type.startswith('pdfa')`; bis 16.x lief sie ohne
  diesen Guard und schlug auch bei output_type="pdf" zu.
- check_preflight() prueft Ghostscript nicht mehr nur bei gesetztem
  pdfa_level, sondern bildet die reale Bedingung ab:
  betroffene GS-Version UND skip_text UND (PDF/A ODER ocrmypdf < 17).
  Der Dienst bricht damit beim Start ab statt bei der ersten Datei.
- update.sh zeigt Versionsspruenge der gepinnten Pakete; Downgrades als
  WARN, auch in der Abschluss-Zusammenfassung.
- update.sh faehrt nach dem Start einen Rauchtest (eingebettete Mini-PDF
  durch die echte Pipeline) und raeumt restlos auf. Uebersprungen, wenn
  Upload-Ziele oder E-Mail-Notify aktiv sind, damit kein Testmuell zum
  Kunden geht. Abschaltbar mit --no-smoke-test.
- Doku korrigiert: pdfa_level = "" allein ist keine Entwarnung, die haengt
  an der ocrmypdf-Version.

152 Tests gruen.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-22 22:55:47 +02:00

1296 lines
48 KiB
Bash
Executable File

#!/usr/bin/env bash
#
# PDF OCR Hotfolder — Update-Script
#
# Aktualisiert Code und venv unter /opt/pdf-ocr-hotfolder/ sowie die
# systemd Template-Unit. Danach werden alle Instanzen neu gestartet, die
# vorher laufen sollten. Config-Dateien unter /etc/pdf-ocr-hotfolder/
# bleiben unveraendert.
#
# Ueberlebt Debian-Major-Upgrades (12 -> 13 -> 14): stimmt die Python-Version
# der venv nicht mehr mit dem System-Python ueberein oder ist der Interpreter
# der venv gar nicht mehr da, wird die venv neu gebaut (ganz oder gar nicht).
#
# Aufruf:
# sudo ./update.sh # normales Update
# sudo ./update.sh --rebuild-venv # venv-Neubau erzwingen (nach dist-upgrade)
# sudo ./update.sh --no-smoke-test # ohne Rauchtest durchlaufen
#
set -Eeuo pipefail
RED='\033[0;31m'; GREEN='\033[0;32m'; YELLOW='\033[1;33m'; BLUE='\033[0;34m'; NC='\033[0m'
log_info() { echo -e "${GREEN}[INFO]${NC} $*"; }
log_warn() { echo -e "${YELLOW}[WARN]${NC} $*"; }
log_error() { echo -e "${RED}[ERROR]${NC} $*"; }
log_step() { echo -e "\n${BLUE}==>${NC} $*"; }
# Pfade sind ueberschreibbar, damit die Funktionen dieses Skripts isoliert
# gegen eine Fake-Umgebung getestet werden koennen (siehe LIB_ONLY unten).
: "${INSTALL_DIR:=/opt/pdf-ocr-hotfolder}"
: "${CONFIG_DIR:=/etc/pdf-ocr-hotfolder}"
: "${SYSTEMD_DIR:=/etc/systemd/system}"
: "${BACKUP_DIR:=/var/backups/pdf-ocr-hotfolder}"
: "${TAR_ROOT:=/}"
: "${VERIFY_WAIT:=6}"
: "${BACKUP_KEEP:=5}"
# Max. Sekunden, die der Rauchtest auf das fertige OCR-PDF wartet. Eine
# Mini-PDF braucht ein paar Sekunden (Datei-Stabilisierung + OCR), unter Last
# auch mal laenger.
: "${SMOKE_TIMEOUT:=90}"
SERVICE_TEMPLATE="pdf-ocr-hotfolder@.service"
UNIT_GLOB='pdf-ocr-hotfolder@*.service'
LXC_DROPIN_DIR="$SYSTEMD_DIR/${SERVICE_TEMPLATE}.d"
LXC_DROPIN="$LXC_DROPIN_DIR/lxc-compat.conf"
REBUILD_VENV=0 # per --rebuild-venv erzwungen
VENV_REBUILT=0 # wurde tatsaechlich neu gebaut?
APT_WARN=0 # apt-Sync hat gemeckert
LXC_SYNCED=0
BACKUP_FILE=""
PRIMARY_USER="pdfocr"
TOUCHED=0 # 1, sobald auf der Platte etwas getauscht wurde
SMOKE_TEST=1 # per --no-smoke-test abschaltbar
UNITS_ALL=() # alle bekannten Instanz-Units
PREV_OK=() # liefen vorher sauber -> muessen nachher laufen
PREV_BROKEN=() # waren vorher kaputt -> sollen laufen, gelten aber nicht als Erfolg
PREV_STOPPED=() # waren bewusst gestoppt -> bleiben gestoppt
CFG_WARN=() # Instanzen mit Config-Warnungen (Exit 1)
CFG_ERR=() # Instanzen mit Config-Fehlern (Exit 2)
STARTED_OK=()
STARTED_FAIL=()
DEP_UPGRADES=() # Paket: alt -> neu (hoeher)
DEP_DOWNGRADES=() # Paket: alt -> neu (niedriger) <- das Gefaehrliche
DEP_NEW=() # Paket war vorher nicht installiert
SMOKE_OK=() # Rauchtest bestanden
SMOKE_FAIL=() # Rauchtest fehlgeschlagen
SMOKE_SKIP=() # Rauchtest uebersprungen (mit Begruendung)
usage() {
cat <<EOF
PDF OCR Hotfolder — Update
sudo ./update.sh [OPTIONEN]
Optionen:
--rebuild-venv Python-venv zwingend neu bauen und Requirements frisch
installieren. Das ist der Aufruf nach einem Debian-
Major-Upgrade (apt full-upgrade, z.B. 12 -> 13), wenn das
System-Python eine neue Version bekommen hat.
--no-smoke-test Den Rauchtest nach dem Start auslassen.
-h, --help Diese Hilfe.
Ohne Option prueft das Skript selbst, ob die venv noch zum System-Python
passt, und baut sie bei Bedarf neu.
Rauchtest (Standard: an)
Nach dem Start schiebt das Skript pro Instanz eine winzige Test-PDF durch
die echte Pipeline und prueft, ob sie in outgoing/ ankommt. Erst das zeigt
einen Totalausfall, den systemd nicht sieht (Dienst active, aber jede PDF
landet in error/). Test-PDF und Ergebnis werden danach restlos wieder
entfernt, auch im Fehlerfall.
Uebersprungen wird der Test bei Instanzen mit aktivem Upload-Ziel
(Nextcloud, SFTP, Ordner-Ziel ausserhalb von outgoing) oder aktivem
E-Mail-Notify: dort wuerde die Testdatei nach aussen gehen.
Ein fehlgeschlagener Rauchtest setzt den Exit-Code, rollt aber NICHTS
zurueck. Wartezeit pro Instanz: ${SMOKE_TIMEOUT}s (SMOKE_TIMEOUT=...).
EOF
}
# ============================================================
# Abbruch-Behandlung
# ============================================================
CLEANUP_DONE=0
# Startet bei einem Abbruch die vorher laufenden Instanzen wieder und sagt
# laut, was passiert ist.
abort_handler() {
local rc="${1:-1}" line="${2:-?}"
if [ "$CLEANUP_DONE" -ne 0 ]; then
exit "$rc"
fi
CLEANUP_DONE=1
trap - ERR INT TERM
echo
log_error "═══════════════════════════════════════════════════════════════"
log_error "UPDATE ABGEBROCHEN (Exit-Code $rc, Zeile $line)."
if [ "$TOUCHED" -eq 1 ]; then
log_error "Der Stand auf der Platte kann halb aktualisiert sein."
else
log_error "Es wurde noch nichts getauscht — der alte Stand ist unveraendert."
fi
log_error "═══════════════════════════════════════════════════════════════"
local -a to_restart=()
[ "${#PREV_OK[@]}" -gt 0 ] && to_restart+=("${PREV_OK[@]}")
[ "${#PREV_BROKEN[@]}" -gt 0 ] && to_restart+=("${PREV_BROKEN[@]}")
if [ "${#to_restart[@]}" -gt 0 ]; then
log_warn "Starte die vorher laufenden Instanzen wieder..."
local unit
for unit in "${to_restart[@]}"; do
if systemctl start "$unit" >/dev/null 2>&1; then
log_info " wieder gestartet: $unit"
else
log_error " Start fehlgeschlagen: $unit (journalctl -u $unit -n 50)"
fi
done
else
log_warn "Es liefen vorher keine Instanzen — nichts wieder zu starten."
fi
if [ -n "$BACKUP_FILE" ] && [ -f "$BACKUP_FILE" ]; then
log_warn "Backup vor dem Update: $BACKUP_FILE"
log_warn "Rollback: tar -xzf '$BACKUP_FILE' -C / && systemctl daemon-reload"
else
log_warn "Es wurde noch KEIN Backup geschrieben."
fi
log_warn "Aeltere Backups: $BACKUP_DIR"
exit "$rc"
}
die() { log_error "$*"; abort_handler 1 "${BASH_LINENO[0]:-?}"; }
# ============================================================
# Python / venv
# ============================================================
# major.minor des uebergebenen Interpreters; leer, wenn er nicht laeuft.
py_mm() {
local py="$1"
"$py" -c 'import sys; print("%d.%d" % sys.version_info[:2])' 2>/dev/null || true
}
# major.minor aus pyvenv.cfg (version = / version_info =); leer, wenn unlesbar.
pyvenv_cfg_mm() {
local cfg="$1/pyvenv.cfg"
[ -f "$cfg" ] || return 0
sed -n 's/^[[:space:]]*version\(_info\)\?[[:space:]]*=[[:space:]]*\([0-9]\+\.[0-9]\+\).*/\2/p' "$cfg" | head -n1
}
# Prueft die venv gegen das aktuelle System-Python.
# Setzt VENV_ISSUES (Array) und gibt 0 zurueck, wenn alles passt.
VENV_ISSUES=()
venv_is_healthy() {
local venv="$1"
local sys_mm venv_mm cfg_mm
VENV_ISSUES=()
if [ ! -d "$venv" ]; then
VENV_ISSUES+=("venv-Verzeichnis fehlt: $venv")
return 1
fi
if [ ! -x "$venv/bin/python" ]; then
VENV_ISSUES+=("$venv/bin/python fehlt oder ist nicht ausfuehrbar")
return 1
fi
venv_mm="$(py_mm "$venv/bin/python")"
if [ -z "$venv_mm" ]; then
VENV_ISSUES+=("$venv/bin/python laeuft nicht (toter Symlink nach einem Distributions-Upgrade?)")
return 1
fi
sys_mm="$(py_mm "$(command -v python3 || echo /usr/bin/python3)")"
if [ -z "$sys_mm" ]; then
VENV_ISSUES+=("System-python3 laeuft nicht — venv-Pruefung nicht moeglich")
return 1
fi
if [ "$venv_mm" != "$sys_mm" ]; then
VENV_ISSUES+=("venv haengt an Python $venv_mm, das System liefert Python $sys_mm")
return 1
fi
cfg_mm="$(pyvenv_cfg_mm "$venv")"
if [ -n "$cfg_mm" ] && [ "$cfg_mm" != "$venv_mm" ]; then
VENV_ISSUES+=("pyvenv.cfg nennt Python $cfg_mm, der Interpreter meldet $venv_mm")
return 1
fi
return 0
}
# Installiert die Requirements und uebersetzt pip-Fehler in eine Ansage, mit
# der man etwas anfangen kann (typisch: Pin passt nicht mehr zum Python).
pip_install_requirements() {
local venv="$1" out rc py_mm_now
py_mm_now="$(py_mm "$venv/bin/python")"
out="$("$venv/bin/pip" install --upgrade pip 2>&1)" && rc=0 || rc=$?
if [ "$rc" -ne 0 ]; then
log_warn "pip liess sich nicht aktualisieren (weiter mit der vorhandenen Version):"
printf '%s\n' "$out" | tail -n 5
fi
out="$("$venv/bin/pip" install --upgrade -r "$INSTALL_DIR/requirements.txt" 2>&1)" && rc=0 || rc=$?
if [ "$rc" -eq 0 ]; then
return 0
fi
echo
log_error "Installation der Requirements fehlgeschlagen (pip Exit $rc)."
printf '%s\n' "$out" | tail -n 25
echo
local bad
bad="$(printf '%s\n' "$out" | sed -n \
-e 's/.*[Nn]o matching distribution found for \([^ ]*\).*/\1/p' \
-e 's/.*Could not find a version that satisfies the requirement \([^ ]*\).*/\1/p' \
-e 's/.*Failed building wheel for \([^ ]*\).*/\1/p' \
-e 's/.*error: subprocess-exited-with-error.*building \([^ ]*\).*/\1/p' \
| head -n1)"
if [ -n "$bad" ]; then
log_error "Gescheitertes Paket: $bad"
fi
log_error "Sehr wahrscheinliche Ursache: eine in requirements.txt fest gepinnte"
log_error "Version gibt es fuer Python ${py_mm_now:-?} nicht (mehr)."
log_error "Naechster Schritt: requirements.txt anheben (passende Version fuer"
log_error "Python ${py_mm_now:-?} eintragen) und update.sh --rebuild-venv erneut laufen lassen."
return 1
}
# ============================================================
# Versionen der Kernabhaengigkeiten
# ============================================================
#
# Ein Downgrade (z.B. ocrmypdf 17.4.1 -> 16.13.0, weil requirements.txt neu
# gepinnt wurde) verschwand bis v0.6.0 wortlos hinter "[INFO] Dependencies ok".
# Genau so ist der Totalausfall aus Issue #7 entstanden. Deshalb: vorher und
# nachher messen und jede Aenderung benennen.
# Paketnamen aus einer requirements.txt (normalisiert: klein, Bindestriche).
pinned_package_names() {
local req
for req in "$@"; do
[ -f "$req" ] || continue
sed -n 's/^[[:space:]]*\([A-Za-z0-9._-]\{1,\}\)[[:space:]]*[<>=!~].*/\1/p' "$req"
done | tr '[:upper:]_' '[:lower:]-' | sort -u
}
# "paket version" je Zeile — nur fuer die in $2.. gepinnten Pakete.
snapshot_pinned_versions() {
local venv="$1"; shift
local line name ver n
local -a names=()
mapfile -t names < <(pinned_package_names "$@")
[ "${#names[@]}" -gt 0 ] || return 0
[ -x "$venv/bin/pip" ] || return 0
while IFS= read -r line; do
case "$line" in (*==*) ;; (*) continue ;; esac
name="$(printf '%s' "${line%%==*}" | tr '[:upper:]_' '[:lower:]-')"
ver="${line#*==}"
for n in "${names[@]}"; do
[ "$n" = "$name" ] && printf '%s %s\n' "$name" "$ver"
done
done < <("$venv/bin/pip" list --format=freeze 2>/dev/null || true)
}
# 0, wenn $1 eine aeltere Version als $2 ist.
version_is_older() {
[ "$1" = "$2" ] && return 1
[ "$(printf '%s\n%s\n' "$1" "$2" | sort -V | head -n1)" = "$1" ]
}
# Vergleicht zwei Snapshots und fuellt DEP_UPGRADES/DEP_DOWNGRADES/DEP_NEW.
diff_pinned_versions() {
local before="$1" after="$2"
local name new old
DEP_UPGRADES=(); DEP_DOWNGRADES=(); DEP_NEW=()
while read -r name new; do
[ -n "$name" ] || continue
old="$(awk -v p="$name" '$1==p {print $2; exit}' "$before" 2>/dev/null || true)"
if [ -z "$old" ]; then
DEP_NEW+=("$name $new")
elif [ "$old" = "$new" ]; then
continue
elif version_is_older "$new" "$old"; then
DEP_DOWNGRADES+=("$name: $old -> $new")
else
DEP_UPGRADES+=("$name: $old -> $new")
fi
done < "$after"
}
# Gibt die Aenderungen aus. Downgrades als WARN — die sind das Gefaehrliche.
report_dependency_changes() {
local entry
if [ "${#DEP_DOWNGRADES[@]}" -eq 0 ] && [ "${#DEP_UPGRADES[@]}" -eq 0 ] \
&& [ "${#DEP_NEW[@]}" -eq 0 ]; then
log_info "Kernabhaengigkeiten unveraendert."
return 0
fi
for entry in "${DEP_DOWNGRADES[@]:-}"; do
[ -n "$entry" ] || continue
log_warn "DOWNGRADE: $entry"
done
if [ "${#DEP_DOWNGRADES[@]}" -gt 0 ]; then
log_warn " Ein Downgrade kommt aus einem gesenkten Pin in requirements.txt."
log_warn " Pruefen, ob das beabsichtigt ist — die aeltere Version kann auf"
log_warn " diesem System unbrauchbar sein, ohne dass systemd es merkt."
fi
for entry in "${DEP_UPGRADES[@]:-}"; do
[ -n "$entry" ] || continue
log_info "Upgrade: $entry"
done
for entry in "${DEP_NEW[@]:-}"; do
[ -n "$entry" ] || continue
log_info "Neu: $entry"
done
}
# Baut die venv neu: alte wegsichern, neue bauen, Requirements installieren,
# und erst bei Erfolg die alte entfernen. Scheitert etwas, wird die alte
# zurueckgerollt und hart abgebrochen.
rebuild_venv() {
local venv="$INSTALL_DIR/venv"
local saved sys_mm
saved="$INSTALL_DIR/venv.old-$(date +%Y%m%d-%H%M%S)"
sys_mm="$(py_mm "$(command -v python3 || echo /usr/bin/python3)")"
log_step "venv wird neu gebaut (System-Python ${sys_mm:-?})"
if [ -d "$venv" ]; then
mv "$venv" "$saved" || die "Alte venv liess sich nicht nach $saved verschieben."
log_info "Alte venv gesichert: $saved"
else
saved=""
log_info "Keine alte venv vorhanden — es wird frisch gebaut."
fi
if ! python3 -m venv "$venv"; then
log_error "python3 -m venv ist fehlgeschlagen."
log_error "Fehlt das Paket python3-venv? -> apt-get install -y python3-venv"
rm -rf "$venv"
if [ -n "$saved" ]; then
mv "$saved" "$venv" && log_warn "Alte venv wurde zurueckgerollt: $venv"
fi
die "venv-Neubau abgebrochen."
fi
if ! pip_install_requirements "$venv"; then
rm -rf "$venv"
if [ -n "$saved" ]; then
mv "$saved" "$venv" && log_warn "Alte venv wurde zurueckgerollt: $venv"
log_warn "Sie haengt weiterhin an einem Python, das es so nicht mehr gibt —"
log_warn "die Instanzen laufen damit nicht. Erst requirements.txt korrigieren."
fi
die "venv-Neubau abgebrochen — keine halb gefuellte venv zurueckgelassen."
fi
[ -n "$saved" ] && rm -rf "$saved"
VENV_REBUILT=1
log_info "venv neu gebaut ✓ (Python ${sys_mm:-?})"
}
# ============================================================
# System-Pakete
# ============================================================
# Holt die Paketliste aus install.sh (einzige Quelle) und installiert sie.
# apt-get install ist idempotent; bereits vorhandene Pakete bleiben unberuehrt.
# Nachinstallierte Tesseract-Sprachpakete werden NICHT angefasst (kein purge,
# kein autoremove).
sync_system_packages() {
local block pkgs
log_step "System-Pakete abgleichen"
block="$(sed -n '/^# --- BEGIN apt-packages/,/^# --- END apt-packages/p' "$REPO_DIR/install.sh" 2>/dev/null || true)"
if [ -z "$block" ] || ! printf '%s' "$block" | grep -q 'pdf_ocr_apt_packages()'; then
log_warn "Paketliste in install.sh nicht gefunden — System-Pakete werden nicht abgeglichen."
APT_WARN=1
return 0
fi
eval "$block"
if ! declare -F pdf_ocr_apt_packages >/dev/null; then
log_warn "pdf_ocr_apt_packages() liess sich nicht laden — uebersprungen."
APT_WARN=1
return 0
fi
mapfile -t pkgs < <(pdf_ocr_apt_packages)
if [ "${#pkgs[@]}" -eq 0 ]; then
log_warn "Paketliste ist leer — uebersprungen."
APT_WARN=1
return 0
fi
log_info "Pakete: ${pkgs[*]}"
if ! apt-get update -qq; then
log_warn "'apt-get update' fehlgeschlagen (kein Netz / kein Mirror?)."
APT_WARN=1
fi
if apt-get install -y --no-install-recommends "${pkgs[@]}"; then
log_info "System-Pakete ok ✓ (vorhandene Sprachpakete bleiben unangetastet)"
else
log_warn "Mindestens ein System-Paket liess sich nicht installieren."
log_warn "Falls eine neue Version ein neues Paket braucht, laufen die Instanzen evtl. nicht."
APT_WARN=1
fi
}
# ============================================================
# Instanzen erfassen
# ============================================================
unit_state() { systemctl is-active "$1" 2>/dev/null || true; }
unit_failed() { systemctl is-failed "$1" 2>/dev/null || true; }
unit_restarts() {
local n
n="$(systemctl show -p NRestarts --value "$1" 2>/dev/null || true)"
case "$n" in (''|*[!0-9]*) echo 0 ;; (*) echo "$n" ;; esac
}
# Sammelt alle Instanz-Units: geladene (aktiv, activating, failed), per
# list-unit-files enabled, und alle, fuer die eine Config existiert.
collect_instances() {
local -A seen=()
local unit name line state failed cfg
while read -r line; do
unit="$(printf '%s' "$line" | awk '{print $1}')"
unit="${unit#●}"
[ -n "$unit" ] || continue
case "$unit" in
pdf-ocr-hotfolder@.service) continue ;;
pdf-ocr-hotfolder@?*.service) seen["$unit"]=1 ;;
esac
done < <(systemctl list-units --all --no-legend --plain "$UNIT_GLOB" 2>/dev/null || true)
while read -r line; do
unit="$(printf '%s' "$line" | awk '{print $1}')"
[ -n "$unit" ] || continue
case "$unit" in
pdf-ocr-hotfolder@.service) continue ;;
pdf-ocr-hotfolder@?*.service) seen["$unit"]=1 ;;
esac
done < <(systemctl list-unit-files --no-legend --plain "$UNIT_GLOB" 2>/dev/null || true)
shopt -s nullglob
for cfg in "$CONFIG_DIR"/*.toml; do
name="$(basename "$cfg" .toml)"
[ -n "$name" ] || continue
seen["pdf-ocr-hotfolder@${name}.service"]=1
done
shopt -u nullglob
UNITS_ALL=(); PREV_OK=(); PREV_BROKEN=(); PREV_STOPPED=()
[ "${#seen[@]}" -gt 0 ] || return 0
mapfile -t UNITS_ALL < <(printf '%s\n' "${!seen[@]}" | sort)
for unit in "${UNITS_ALL[@]}"; do
state="$(unit_state "$unit")"
failed="$(unit_failed "$unit")"
case "$state" in
active)
if [ "$failed" = "failed" ]; then
PREV_BROKEN+=("$unit")
else
PREV_OK+=("$unit")
fi
;;
activating|reloading|deactivating|failed)
PREV_BROKEN+=("$unit")
;;
*)
if [ "$failed" = "failed" ]; then
PREV_BROKEN+=("$unit")
else
PREV_STOPPED+=("$unit")
fi
;;
esac
done
}
report_instances() {
if [ "${#PREV_OK[@]}" -gt 0 ]; then
log_info "Lief vorher sauber (${#PREV_OK[@]}): ${PREV_OK[*]}"
else
log_info "Keine sauber laufende Instanz gefunden."
fi
if [ "${#PREV_BROKEN[@]}" -gt 0 ]; then
log_warn "War vorher KAPUTT (${#PREV_BROKEN[@]}): ${PREV_BROKEN[*]}"
log_warn " (failed oder Crash-Loop/activating — wird mit gestartet, gilt aber"
log_warn " erst als Erfolg, wenn sie nach dem Update wirklich laeuft.)"
fi
if [ "${#PREV_STOPPED[@]}" -gt 0 ]; then
log_info "Bewusst gestoppt, bleibt gestoppt (${#PREV_STOPPED[@]}): ${PREV_STOPPED[*]}"
fi
}
stop_instances() {
local -a units=()
[ "${#PREV_OK[@]}" -gt 0 ] && units+=("${PREV_OK[@]}")
[ "${#PREV_BROKEN[@]}" -gt 0 ] && units+=("${PREV_BROKEN[@]}")
[ "${#units[@]}" -gt 0 ] || { log_info "Nichts zu stoppen."; return 0; }
log_info "Stoppe Instanzen: ${units[*]}"
local unit
for unit in "${units[@]}"; do
systemctl stop "$unit" >/dev/null 2>&1 || log_warn " stop fehlgeschlagen: $unit"
done
}
# ============================================================
# Verifikation nach dem Start
# ============================================================
# Wartet mindestens VERIFY_WAIT Sekunden und prueft danach is-active,
# is-failed und den Restart-Zaehler. Ein Crash-Loop (Type=simple meldet
# sofort "active") faellt damit auf.
verify_unit() {
local unit="$1"
local r0 r1 state failed waited=0
r0="$(unit_restarts "$unit")"
while [ "$waited" -lt "$VERIFY_WAIT" ]; do
sleep 1
waited=$((waited + 1))
[ "$(unit_state "$unit")" = "failed" ] && break
done
state="$(unit_state "$unit")"
failed="$(unit_failed "$unit")"
r1="$(unit_restarts "$unit")"
if [ "$state" != "active" ]; then
log_error " ❌ $unit — Status '$state' nach ${waited}s"
return 1
fi
if [ "$failed" = "failed" ]; then
log_error " ❌ $unit — systemd meldet 'failed'"
return 1
fi
if [ "$r1" -gt "$r0" ]; then
log_error " ❌ $unit — Crash-Loop (NRestarts $r0 -> $r1 in ${waited}s)"
return 1
fi
return 0
}
start_instances() {
local -a units=()
[ "${#PREV_OK[@]}" -gt 0 ] && units+=("${PREV_OK[@]}")
[ "${#PREV_BROKEN[@]}" -gt 0 ] && units+=("${PREV_BROKEN[@]}")
STARTED_OK=(); STARTED_FAIL=()
[ "${#units[@]}" -gt 0 ] || { log_info "Keine Instanz zu starten."; return 0; }
local unit
for unit in "${units[@]}"; do
# Alten failed-Zustand raeumen, damit is-failed/NRestarts danach
# wirklich etwas ueber diesen Start aussagen.
systemctl reset-failed "$unit" >/dev/null 2>&1 || true
systemctl start "$unit" >/dev/null 2>&1 || log_warn " start meldete einen Fehler: $unit"
if verify_unit "$unit"; then
log_info " ✅ $unit laeuft (nach ${VERIFY_WAIT}s stabil)"
STARTED_OK+=("$unit")
else
log_error " Logs: journalctl -u $unit -n 50 --no-pager"
STARTED_FAIL+=("$unit")
fi
done
}
# ============================================================
# Config-Pruefung (CLI des Python-Teils)
# ============================================================
# Nutzt: venv/bin/python -m pdf_ocr_hotfolder --check-config --config <datei>
# Exit 0 = sauber, 1 = Warnungen, 2 = Fehler.
# Kennt der installierte Code das Subkommando nicht, wird die Pruefung
# uebersprungen (Warnung), das Update laeuft aber weiter.
check_all_configs() {
local py="$INSTALL_DIR/venv/bin/python"
local cfg name out rc
local -a cfgs=()
log_step "Configs pruefen (--check-config)"
CFG_WARN=(); CFG_ERR=()
if [ ! -x "$py" ]; then
log_warn "venv-Python nicht ausfuehrbar — Config-Pruefung uebersprungen."
return 0
fi
shopt -s nullglob
cfgs=("$CONFIG_DIR"/*.toml)
shopt -u nullglob
if [ "${#cfgs[@]}" -eq 0 ]; then
log_info "Keine Instanz-Configs unter $CONFIG_DIR."
return 0
fi
for cfg in "${cfgs[@]}"; do
name="$(basename "$cfg" .toml)"
out="$(cd "$INSTALL_DIR" && "$py" -m pdf_ocr_hotfolder --check-config --config "$cfg" 2>&1)" && rc=0 || rc=$?
case "$rc" in
0)
log_info " ✅ $name — Config ok"
[ -n "$out" ] && printf '%s\n' "$out"
;;
1)
log_warn " ⚠ $name — Warnungen:"
printf '%s\n' "$out"
CFG_WARN+=("$name")
;;
2)
# argparse beendet sich bei unbekannten Optionen ebenfalls mit 2 —
# das ist kein Config-Fehler, sondern aelterer Code.
if printf '%s' "$out" | grep -qiE 'unrecognized arguments|invalid choice|no such option|unknown option|unrecognized option'; then
log_warn "Der installierte Code kennt --check-config nicht (aeltere Version)."
log_warn "Config-Pruefung wird uebersprungen — Update laeuft weiter."
CFG_WARN=(); CFG_ERR=()
return 0
fi
log_error " ❌ $name — Config-FEHLER:"
printf '%s\n' "$out"
CFG_ERR+=("$name")
;;
*)
log_warn " ⚠ $name — --check-config lieferte unerwarteten Exit-Code $rc; ignoriert."
[ -n "$out" ] && printf '%s\n' "$out" | tail -n 10
;;
esac
done
}
# ============================================================
# Rauchtest
# ============================================================
#
# systemd sagt "active", --check-config sagt "ok" — und trotzdem landet jede
# PDF in error/ (Issue #7). Das sieht man nur, wenn man wirklich eine Datei
# durch die Pipeline schiebt. Deshalb: eine Mini-PDF nach incoming/, warten,
# bis sie in outgoing/ ankommt, danach restlos aufraeumen.
#
# Die Test-PDF steckt als base64 hier drin — kein Pillow, kein gs, kein
# convert, damit der Test keine Abhaengigkeit mitbringt, die auf dem Zielsystem
# fehlen kann. 694 Bytes, eine Seite, eine Textzeile.
smoke_pdf_base64() {
cat <<'SMOKEPDF'
JVBERi0xLjQKJeLjz9MKMSAwIG9iago8PCAvVHlwZSAvQ2F0YWxvZyAvUGFnZXMgMiAwIFIgPj4K
ZW5kb2JqCjIgMCBvYmoKPDwgL1R5cGUgL1BhZ2VzIC9LaWRzIFszIDAgUl0gL0NvdW50IDEgPj4K
ZW5kb2JqCjMgMCBvYmoKPDwgL1R5cGUgL1BhZ2UgL1BhcmVudCAyIDAgUiAvTWVkaWFCb3ggWzAg
MCA1OTUgODQyXSAvUmVzb3VyY2VzIDw8IC9Gb250IDw8IC9GMSA0IDAgUiA+PiA+PiAvQ29udGVu
dHMgNSAwIFIgPj4KZW5kb2JqCjQgMCBvYmoKPDwgL1R5cGUgL0ZvbnQgL1N1YnR5cGUgL1R5cGUx
IC9CYXNlRm9udCAvSGVsdmV0aWNhID4+CmVuZG9iago1IDAgb2JqCjw8IC9MZW5ndGggMTQ0ID4+
CnN0cmVhbQpCVCAvRjEgMjQgVGYgNzIgNzAwIFRkIChQREYgT0NSIEhPVEZPTERFUiBTTU9LRVRF
U1QpIFRqIEVUCkJUIC9GMSAxMSBUZiA3MiA2NzAgVGQgKEF1dG9tYXRpc2NoIGVyemV1Z3Qgdm9u
IHVwZGF0ZS5zaCAtIGJpdHRlIGlnbm9yaWVyZW4uKSBUaiBFVAplbmRzdHJlYW0KZW5kb2JqCnhy
ZWYKMCA2CjAwMDAwMDAwMDAgNjU1MzUgZiAKMDAwMDAwMDAxNSAwMDAwMCBuIAowMDAwMDAwMDY0
IDAwMDAwIG4gCjAwMDAwMDAxMjEgMDAwMDAgbiAKMDAwMDAwMDI0NyAwMDAwMCBuIAowMDAwMDAw
MzE3IDAwMDAwIG4gCnRyYWlsZXIKPDwgL1NpemUgNiAvUm9vdCAxIDAgUiA+PgpzdGFydHhyZWYK
NTExCiUlRU9GCg==
SMOKEPDF
}
# Liest die fuer den Rauchtest noetigen Werte aus einer Instanz-Config.
# Ausgabe: "SCHLUESSEL<TAB>Wert" je Zeile.
smoke_read_config() {
local py="$1" cfg="$2"
"$py" - "$cfg" <<'SMOKECFG'
import sys
import tomllib
with open(sys.argv[1], "rb") as fh:
data = tomllib.load(fh)
def sec(*keys):
cur = data
for key in keys:
cur = cur.get(key, {}) if isinstance(cur, dict) else {}
return cur if isinstance(cur, dict) else {}
paths = sec("paths")
out = sec("output")
folder = sec("upload", "folder")
nextcloud = sec("upload", "nextcloud")
sftp = sec("upload", "sftp")
mail = sec("notify", "email")
# Alles, was die Testdatei nach aussen tragen wuerde. Defaults wie in config.py.
blockers = []
if nextcloud.get("enabled", False):
blockers.append("upload.nextcloud")
if sftp.get("enabled", False):
blockers.append("upload.sftp")
if mail.get("enabled", False):
blockers.append("notify.email")
# Ordner-Upload ohne target schreibt nach outgoing/ (harmlos); mit target
# geht die Datei irgendwo anders hin - oft eine Kundenfreigabe.
if folder.get("enabled", True) and str(folder.get("target", "")).strip():
blockers.append("upload.folder (target gesetzt)")
mode = str(out.get("original_on_success", "delete"))
archive = str(out.get("archive_dir", "")) if mode == "archive" else ""
for key, value in (
("INCOMING", paths.get("incoming", "")),
("OUTGOING", paths.get("outgoing", "")),
("WORKING", paths.get("working", "")),
("ERROR", paths.get("error", "")),
("ARCHIVE", archive),
("NAME_MODE", out.get("name_mode", "prefix")),
("NAME_TAG", out.get("name_tag", "OCR_")),
("BLOCKERS", ", ".join(blockers)),
):
print(f"{key}\t{value}")
SMOKECFG
}
# Bildet build_output_name() aus processor.py nach.
smoke_output_name() {
local src="$1" mode="$2" tag="$3"
if [ "$mode" = "none" ] || [ -z "$tag" ]; then
printf '%s' "$src"; return 0
fi
case "$mode" in
prefix) printf '%s%s' "$tag" "$src" ;;
suffix) printf '%s%s%s' "${src%.*}" "$tag" ".${src##*.}" ;;
*) printf '%s' "$src" ;;
esac
}
# Raeumt alle Spuren des Rauchtests weg — in JEDEM Ausgang. Nach dieser
# Funktion darf in keinem der Verzeichnisse etwas vom Test liegen bleiben.
smoke_cleanup() {
local in_dir="$1" out_dir="$2" work_dir="$3" err_dir="$4" arch_dir="$5"
local src_name="$6" out_name="$7"
local f
for f in "$in_dir/$src_name" \
"$work_dir/$src_name" \
"$work_dir/__ocr_$out_name" \
"$out_dir/$out_name" \
"$err_dir/$src_name" \
"$err_dir/$out_name" \
"${arch_dir:+$arch_dir/$src_name}"; do
[ -n "$f" ] || continue
[ -e "$f" ] && rm -f "$f" 2>/dev/null
done
return 0
}
# Schiebt eine Test-PDF durch eine laufende Instanz.
# 0 = bestanden, 1 = durchgefallen, 2 = uebersprungen
smoke_test_instance() {
local name="$1"
local py="$INSTALL_DIR/venv/bin/python"
local cfg="$CONFIG_DIR/$name.toml"
local key value
local INCOMING="" OUTGOING="" WORKING="" ERROR="" ARCHIVE=""
local NAME_MODE="prefix" NAME_TAG="OCR_" BLOCKERS=""
[ -f "$cfg" ] || { SMOKE_SKIP+=("$name (keine Config)"); return 2; }
[ -x "$py" ] || { SMOKE_SKIP+=("$name (kein venv-Python)"); return 2; }
local cfg_out
if ! cfg_out="$(smoke_read_config "$py" "$cfg" 2>&1)"; then
log_warn " ⚠ $name — Config liess sich fuer den Rauchtest nicht lesen:"
printf '%s\n' "$cfg_out" | tail -n 5
SMOKE_SKIP+=("$name (Config nicht lesbar)")
return 2
fi
while IFS="$(printf '\t')" read -r key value; do
case "$key" in
INCOMING) INCOMING="$value" ;;
OUTGOING) OUTGOING="$value" ;;
WORKING) WORKING="$value" ;;
ERROR) ERROR="$value" ;;
ARCHIVE) ARCHIVE="$value" ;;
NAME_MODE) NAME_MODE="$value" ;;
NAME_TAG) NAME_TAG="$value" ;;
BLOCKERS) BLOCKERS="$value" ;;
esac
done <<< "$cfg_out"
if [ -n "$BLOCKERS" ]; then
log_info " ⏭ $name — Rauchtest uebersprungen: aktive Ziele ($BLOCKERS)."
log_info " Eine Testdatei wuerde sonst beim Empfaenger landen."
SMOKE_SKIP+=("$name (aktive Ziele: $BLOCKERS)")
return 2
fi
if [ -z "$INCOMING" ] || [ -z "$OUTGOING" ]; then
SMOKE_SKIP+=("$name ([paths] unvollstaendig)")
return 2
fi
if [ ! -d "$INCOMING" ]; then
log_warn " ⚠ $name — $INCOMING existiert nicht; Rauchtest uebersprungen."
SMOKE_SKIP+=("$name (incoming fehlt)")
return 2
fi
# Eindeutiger Name, der mit keiner Kundendatei kollidieren kann.
local src_name out_name
src_name="__smoketest_update_$(date +%Y%m%d-%H%M%S)_$$.pdf"
out_name="$(smoke_output_name "$src_name" "$NAME_MODE" "$NAME_TAG")"
# Als Instanz-User anlegen, damit Rechte und Eigentuemer denen einer
# echten Scan-Datei entsprechen.
local unit_user
unit_user="$(systemctl show -p User --value "pdf-ocr-hotfolder@${name}.service" 2>/dev/null || true)"
[ -n "$unit_user" ] || unit_user="$PRIMARY_USER"
local tmp_pdf
tmp_pdf="$(mktemp)"
if ! smoke_pdf_base64 | base64 -d > "$tmp_pdf" 2>/dev/null; then
rm -f "$tmp_pdf"
log_warn " ⚠ $name — Test-PDF liess sich nicht erzeugen (base64 fehlt?)."
SMOKE_SKIP+=("$name (base64 fehlt)")
return 2
fi
# Erst fertig schreiben, dann hineinbewegen: der Watcher soll keine
# halb geschriebene Datei sehen.
local staged="$INCOMING/.$src_name.part"
cp "$tmp_pdf" "$staged" 2>/dev/null || {
rm -f "$tmp_pdf" "$staged"
log_warn " ⚠ $name — nach $INCOMING liess sich nicht schreiben."
SMOKE_SKIP+=("$name (incoming nicht beschreibbar)")
return 2
}
rm -f "$tmp_pdf"
chown "$unit_user":"$unit_user" "$staged" 2>/dev/null || true
chmod 644 "$staged" 2>/dev/null || true
if ! mv "$staged" "$INCOMING/$src_name" 2>/dev/null; then
rm -f "$staged"
log_warn " ⚠ $name — Testdatei liess sich nicht in $INCOMING einstellen."
SMOKE_SKIP+=("$name (incoming nicht beschreibbar)")
return 2
fi
# Warten: fertiges PDF in outgoing/ oder Abbruch in error/.
local waited=0 result=1
while [ "$waited" -lt "$SMOKE_TIMEOUT" ]; do
if [ -f "$OUTGOING/$out_name" ]; then result=0; break; fi
if [ -n "$ERROR" ] && { [ -f "$ERROR/$src_name" ] || [ -f "$ERROR/$out_name" ]; }; then
result=1; break
fi
sleep 1
waited=$((waited + 1))
done
smoke_cleanup "$INCOMING" "$OUTGOING" "$WORKING" "$ERROR" "$ARCHIVE" \
"$src_name" "$out_name"
if [ "$result" -eq 0 ]; then
log_info " ✅ $name — Test-PDF kam nach ${waited}s in outgoing/ an"
SMOKE_OK+=("$name")
return 0
fi
if [ "$waited" -ge "$SMOKE_TIMEOUT" ]; then
log_error " ❌ $name — Test-PDF war nach ${SMOKE_TIMEOUT}s nicht in $OUTGOING"
else
log_error " ❌ $name — Test-PDF landete in $ERROR statt in $OUTGOING"
fi
log_error " Der Dienst laeuft, verarbeitet aber nichts. Ursache im Journal:"
log_error " journalctl -u pdf-ocr-hotfolder@${name}.service -n 80 --no-pager"
SMOKE_FAIL+=("$name")
return 1
}
# Faehrt den Rauchtest fuer alle Instanzen, die nach dem Update laufen.
run_smoke_tests() {
SMOKE_OK=(); SMOKE_FAIL=(); SMOKE_SKIP=()
if [ "$SMOKE_TEST" -ne 1 ]; then
log_step "Rauchtest"
log_warn "Uebersprungen (--no-smoke-test). Ein Totalausfall bei der"
log_warn "Verarbeitung faellt damit erst der ersten echten Datei auf."
return 0
fi
if [ "${#STARTED_OK[@]}" -eq 0 ]; then
log_step "Rauchtest"
log_info "Keine laufende Instanz — nichts zu testen."
return 0
fi
log_step "Rauchtest (Test-PDF durch die echte Pipeline)"
local unit name
for unit in "${STARTED_OK[@]}"; do
name="${unit#pdf-ocr-hotfolder@}"
name="${name%.service}"
smoke_test_instance "$name" || true
done
}
# ============================================================
# Backup
# ============================================================
# Pfad relativ zu TAR_ROOT (fuer tar -C "$TAR_ROOT").
strip_root() {
local p="$1" root="${TAR_ROOT%/}"
[ -n "$root" ] && p="${p#"$root"}"
printf '%s' "${p#/}"
}
# Sichert Code, Instanz-Configs, Template-Unit, Drop-ins und ein pip freeze
# der alten venv. Datenverzeichnisse (/var/lib/...) bleiben bewusst draussen.
# Das Archiv enthaelt Klartext-Passwoerter aus den Instanz-Configs und wird
# deshalb root-only (0600) abgelegt.
create_backup() {
local stage tmp d
local -a items=()
log_step "Backup erstellen"
mkdir -p "$BACKUP_DIR" || die "Backup-Verzeichnis $BACKUP_DIR nicht anlegbar."
chmod 700 "$BACKUP_DIR" 2>/dev/null || true
stage="$(mktemp -d)" || die "mktemp -d fehlgeschlagen."
{
echo "# pip freeze der alten venv vor dem Update"
echo "# Zeitpunkt: $(date -Is)"
echo "# Version: ${OLD_VERSION:-unknown} -> ${NEW_VERSION:-unknown}"
if [ -x "$INSTALL_DIR/venv/bin/pip" ]; then
"$INSTALL_DIR/venv/bin/pip" freeze 2>/dev/null || echo "# pip freeze fehlgeschlagen (venv defekt?)"
else
echo "# keine funktionierende venv vorhanden"
fi
} > "$stage/pip-freeze.txt"
[ -d "$INSTALL_DIR" ] && items+=("$(strip_root "$INSTALL_DIR")")
[ -d "$CONFIG_DIR" ] && items+=("$(strip_root "$CONFIG_DIR")")
[ -f "$SYSTEMD_DIR/$SERVICE_TEMPLATE" ] && items+=("$(strip_root "$SYSTEMD_DIR/$SERVICE_TEMPLATE")")
shopt -s nullglob
for d in "$SYSTEMD_DIR"/pdf-ocr-hotfolder@*.service.d; do
items+=("$(strip_root "$d")")
done
shopt -u nullglob
if [ "${#items[@]}" -eq 0 ]; then
rm -rf "$stage"
die "Nichts zu sichern gefunden — das sieht nach einer kaputten Installation aus."
fi
tmp="$BACKUP_DIR/backup-$(date +%Y%m%d-%H%M%S).tar.gz"
local old_umask; old_umask="$(umask)"
umask 077
if ! tar -czf "$tmp" \
--exclude="$(strip_root "$INSTALL_DIR")/venv" \
--exclude="$(strip_root "$INSTALL_DIR")/venv.old-*" \
--exclude='*/__pycache__' \
--exclude='*.pyc' \
-C "$TAR_ROOT" "${items[@]}" \
-C "$stage" pip-freeze.txt; then
umask "$old_umask"
rm -f "$tmp"
rm -rf "$stage"
die "Backup fehlgeschlagen (volle Platte?). Es wurde NICHTS veraendert."
fi
umask "$old_umask"
rm -rf "$stage"
chmod 600 "$tmp" || true
chown root:root "$tmp" 2>/dev/null || true
BACKUP_FILE="$tmp"
log_info "Backup: $tmp ($(du -h "$tmp" 2>/dev/null | awk '{print $1}'))"
log_info " Enthalten: Code, $CONFIG_DIR, Template-Unit, Drop-ins, pip-freeze.txt"
log_info " NICHT enthalten: venv und die Datenverzeichnisse (/var/lib/pdf-ocr-hotfolder)"
log_warn " Achtung: die Instanz-Configs enthalten Klartext-Passwoerter"
log_warn " (SMTP/Nextcloud/SFTP) — Archiv ist deshalb root-only (0600)."
rotate_backups
}
# Behaelt die letzten $BACKUP_KEEP Archive, loescht aeltere.
rotate_backups() {
local -a all=() old=()
shopt -s nullglob
mapfile -t all < <(printf '%s\n' "$BACKUP_DIR"/backup-*.tar.gz | sort)
shopt -u nullglob
[ "${#all[@]}" -gt "$BACKUP_KEEP" ] || return 0
old=("${all[@]:0:$(( ${#all[@]} - BACKUP_KEEP ))}")
local f
for f in "${old[@]}"; do
rm -f "$f" && log_info " Altes Backup entfernt: $(basename "$f")"
done
log_info " Rotation: ${BACKUP_KEEP} Backups behalten, ${#old[@]} entfernt."
}
# ============================================================
# systemd
# ============================================================
install_units() {
log_step "systemd-Units aktualisieren"
cp "$REPO_DIR/systemd/$SERVICE_TEMPLATE" "$SYSTEMD_DIR/$SERVICE_TEMPLATE"
log_info "Template-Unit aktualisiert ✓"
# Issue #4 redux: existiert das LXC-Drop-in, muss es mit der Template-Unit
# mitwachsen — sonst reisst ein neu ergaenzter Hardening-Schalter alle
# Container-Instanzen (Error 226/NAMESPACE).
if [ -f "$LXC_DROPIN" ]; then
if [ -f "$REPO_DIR/systemd/lxc-compat.conf" ]; then
cp "$REPO_DIR/systemd/lxc-compat.conf" "$LXC_DROPIN"
LXC_SYNCED=1
log_info "LXC-Drop-in nachgezogen: $LXC_DROPIN ✓"
else
log_warn "systemd/lxc-compat.conf fehlt im Repo — Drop-in bleibt alt."
fi
elif systemd-detect-virt --container -q 2>/dev/null; then
log_warn "Container-Umgebung erkannt, aber kein LXC-Drop-in installiert."
log_warn "Bei Error 226/NAMESPACE: install.sh erneut laufen lassen oder"
log_warn " cp '$REPO_DIR/systemd/lxc-compat.conf' '$LXC_DROPIN'"
fi
systemctl daemon-reload
}
# ============================================================
# LIB_ONLY: bis hierher nur Definitionen. Mit
# PDF_OCR_UPDATE_LIB_ONLY=1 source ./update.sh
# laesst sich alles oben isoliert testen, ohne dass etwas passiert.
# ============================================================
if [ "${PDF_OCR_UPDATE_LIB_ONLY:-0}" = "1" ]; then
# shellcheck disable=SC2317 # 'exit' greift nur, wenn das Skript nicht gesourct wurde
return 0 2>/dev/null || exit 0
fi
# ============================================================
# Main
# ============================================================
while [ "$#" -gt 0 ]; do
case "$1" in
--rebuild-venv) REBUILD_VENV=1 ;;
--no-smoke-test) SMOKE_TEST=0 ;;
-h|--help) usage; exit 0 ;;
*) log_error "Unbekannte Option: $1"; echo; usage; exit 1 ;;
esac
shift
done
if [ "${EUID}" -ne 0 ]; then
log_error "Bitte als root ausfuehren: sudo ./update.sh"
exit 1
fi
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
if [ -f "$SCRIPT_DIR/pdf_ocr_hotfolder/__init__.py" ]; then
REPO_DIR="$SCRIPT_DIR"
elif [ -f "$INSTALL_DIR/.repo_path" ]; then
REPO_DIR="$(cat "$INSTALL_DIR/.repo_path")"
[ -d "$REPO_DIR" ] || { log_error "Gespeicherter Repo-Pfad existiert nicht: $REPO_DIR"; exit 1; }
else
log_error "Repo nicht gefunden. update.sh aus dem Repo ausfuehren."
exit 1
fi
[ -d "$INSTALL_DIR" ] || { log_error "Installation nicht gefunden. Erst install.sh ausfuehren."; exit 1; }
OLD_VERSION="$(cat "$INSTALL_DIR/VERSION" 2>/dev/null || echo unknown)"
NEW_VERSION="$(cat "$REPO_DIR/VERSION" 2>/dev/null || echo unknown)"
echo
echo "=========================================="
echo " PDF OCR Hotfolder — Update"
echo "=========================================="
log_info "Repo: $REPO_DIR"
log_info "Install: $INSTALL_DIR"
log_info "Version: $OLD_VERSION → $NEW_VERSION"
echo
# Ab hier kann ein Abbruch Instanzen gestoppt zuruecklassen.
trap 'abort_handler $? $LINENO' ERR
trap 'abort_handler 130 $LINENO' INT
trap 'abort_handler 143 $LINENO' TERM
# --- Instanzen erfassen ---
log_step "Instanzen erfassen"
collect_instances
report_instances
# --- Eigentuemer des Codes merken (vor dem venv-Neubau) ---
if [ -d "$INSTALL_DIR/venv" ]; then
PRIMARY_USER="$(stat -c '%U' "$INSTALL_DIR/venv" 2>/dev/null || echo pdfocr)"
else
PRIMARY_USER="$(stat -c '%U' "$INSTALL_DIR" 2>/dev/null || echo pdfocr)"
fi
[ "$PRIMARY_USER" = "root" ] && PRIMARY_USER="pdfocr"
# --- System-Pakete (auch beim Update, nicht nur bei install.sh) ---
sync_system_packages
# --- venv-Gesundheit pruefen (vor dem Stoppen, damit man es frueh sieht) ---
log_step "venv pruefen"
NEED_REBUILD=0
if [ "$REBUILD_VENV" -eq 1 ]; then
log_info "--rebuild-venv gesetzt: venv wird in jedem Fall neu gebaut."
NEED_REBUILD=1
elif venv_is_healthy "$INSTALL_DIR/venv"; then
log_info "venv ok ✓ (Python $(py_mm "$INSTALL_DIR/venv/bin/python"))"
else
log_warn "venv passt nicht mehr:"
for issue in "${VENV_ISSUES[@]}"; do
log_warn " - $issue"
done
log_warn "Typische Ursache: Debian-Major-Upgrade (z.B. 12 -> 13). Die venv haengt"
log_warn "am alten Interpreter, systemd quittiert das mit 203/EXEC."
log_warn "-> venv wird neu gebaut."
NEED_REBUILD=1
fi
# --- Ab hier wird angefasst ---
log_step "Instanzen stoppen"
stop_instances
create_backup
# Versionen der gepinnten Pakete festhalten, SOLANGE die alte
# requirements.txt noch liegt — sonst verschwindet ein Downgrade
# (z.B. ocrmypdf 17.4.1 -> 16.13.0) wortlos hinter "Dependencies ok ✓".
DEP_BEFORE="$(mktemp)"
DEP_AFTER="$(mktemp)"
snapshot_pinned_versions "$INSTALL_DIR/venv" \
"$INSTALL_DIR/requirements.txt" "$REPO_DIR/requirements.txt" > "$DEP_BEFORE" || true
log_step "Code aktualisieren"
TOUCHED=1
rm -rf "$INSTALL_DIR/pdf_ocr_hotfolder"
cp -r "$REPO_DIR/pdf_ocr_hotfolder" "$INSTALL_DIR/"
cp "$REPO_DIR/requirements.txt" "$INSTALL_DIR/"
cp "$REPO_DIR/VERSION" "$INSTALL_DIR/"
cp "$REPO_DIR/config.example.toml" "$INSTALL_DIR/"
echo "$REPO_DIR" > "$INSTALL_DIR/.repo_path"
log_info "Code auf $NEW_VERSION ✓"
log_step "Dependencies aktualisieren"
if [ "$NEED_REBUILD" -eq 1 ]; then
rebuild_venv
else
pip_install_requirements "$INSTALL_DIR/venv" || die "Dependencies liessen sich nicht aktualisieren."
log_info "Dependencies ok ✓"
fi
snapshot_pinned_versions "$INSTALL_DIR/venv" \
"$INSTALL_DIR/requirements.txt" "$REPO_DIR/requirements.txt" > "$DEP_AFTER" || true
diff_pinned_versions "$DEP_BEFORE" "$DEP_AFTER"
report_dependency_changes
rm -f "$DEP_BEFORE" "$DEP_AFTER"
install_units
log_step "Berechtigungen setzen"
# Eigentuemer des Codes bleibt der primaere User (pdfocr); Instanzen laufen
# ggf. als anderer User, lesen aber nur den Code.
chown -R "$PRIMARY_USER":"$PRIMARY_USER" "$INSTALL_DIR"
log_info "Eigentuemer: $PRIMARY_USER"
check_all_configs
log_step "Instanzen starten"
start_instances
# Ab hier wird nichts mehr getauscht. Der Rauchtest laeuft bewusst OHNE
# ERR-Trap: ein durchgefallener Test setzt den Exit-Code, loest aber keinen
# Abbruch und kein Zurueckrollen aus.
trap - ERR INT TERM
run_smoke_tests
# ============================================================
# Zusammenfassung: Ist gegen Soll
# ============================================================
RC=0
echo
echo "=========================================="
echo " Zusammenfassung"
echo "=========================================="
log_info "Version: $OLD_VERSION → $NEW_VERSION"
log_info "Backup: ${BACKUP_FILE:-keines}"
[ "$VENV_REBUILT" -eq 1 ] && log_info "venv: neu gebaut (Python $(py_mm "$INSTALL_DIR/venv/bin/python"))"
[ "$LXC_SYNCED" -eq 1 ] && log_info "LXC: Drop-in nachgezogen"
SOLL=$(( ${#PREV_OK[@]} + ${#PREV_BROKEN[@]} ))
log_info "Soll: $SOLL Instanz(en) sollen laufen | Ist: ${#STARTED_OK[@]} laufen"
if [ "${#PREV_STOPPED[@]}" -gt 0 ]; then
log_info "Gestoppt gelassen: ${PREV_STOPPED[*]}"
fi
# Vorher kaputte Instanzen, die auch jetzt nicht laufen: klar benennen.
FIXED=(); STILL_BROKEN=()
for unit in "${PREV_BROKEN[@]:-}"; do
[ -n "$unit" ] || continue
if printf '%s\n' "${STARTED_OK[@]:-}" | grep -qxF "$unit"; then
FIXED+=("$unit")
else
STILL_BROKEN+=("$unit")
fi
done
[ "${#FIXED[@]}" -gt 0 ] && log_info "Vorher kaputt, laeuft jetzt: ${FIXED[*]}"
if [ "${#STILL_BROKEN[@]}" -gt 0 ]; then
log_error "Vorher kaputt und immer noch kaputt: ${STILL_BROKEN[*]}"
RC=1
fi
VORHER_OK_JETZT_NICHT=()
for unit in "${PREV_OK[@]:-}"; do
[ -n "$unit" ] || continue
printf '%s\n' "${STARTED_OK[@]:-}" | grep -qxF "$unit" || VORHER_OK_JETZT_NICHT+=("$unit")
done
if [ "${#VORHER_OK_JETZT_NICHT[@]}" -gt 0 ]; then
log_error "REGRESSION — lief vorher, laeuft jetzt nicht: ${VORHER_OK_JETZT_NICHT[*]}"
log_error "Rollback: tar -xzf '${BACKUP_FILE:-<backup>}' -C / && systemctl daemon-reload"
RC=1
fi
if [ "${#CFG_ERR[@]}" -gt 0 ]; then
log_error "Config-FEHLER (Exit 2) bei: ${CFG_ERR[*]}"
log_error " Diese Instanzen gelten NICHT als erfolgreich aktualisiert."
RC=1
fi
if [ "${#CFG_WARN[@]}" -gt 0 ]; then
log_warn "Config-Warnungen (Exit 1) bei: ${CFG_WARN[*]}"
log_warn " Kein Abbruchgrund, aber bitte nachsehen:"
for name in "${CFG_WARN[@]}"; do
log_warn " $INSTALL_DIR/venv/bin/python -m pdf_ocr_hotfolder --check-config --config $CONFIG_DIR/$name.toml"
done
fi
if [ "$APT_WARN" -eq 1 ]; then
log_warn "System-Pakete konnten nicht vollstaendig abgeglichen werden (siehe oben)."
fi
# Versionsspruenge der Kernabhaengigkeiten hier noch einmal — im Log weiter
# oben gehen sie zwischen pip-Ausgaben unter.
if [ "${#DEP_DOWNGRADES[@]}" -gt 0 ]; then
log_warn "Abhaengigkeiten DOWNGEGRADED:"
for entry in "${DEP_DOWNGRADES[@]}"; do
log_warn " $entry"
done
log_warn " Das kommt aus requirements.txt. War es nicht beabsichtigt: Pin"
log_warn " korrigieren und update.sh --rebuild-venv erneut fahren."
fi
if [ "${#DEP_UPGRADES[@]}" -gt 0 ]; then
log_info "Abhaengigkeiten angehoben:"
for entry in "${DEP_UPGRADES[@]}"; do
log_info " $entry"
done
fi
if [ "${#DEP_NEW[@]}" -gt 0 ]; then
log_info "Abhaengigkeiten neu dazugekommen: ${DEP_NEW[*]}"
fi
if [ "$SMOKE_TEST" -ne 1 ]; then
log_warn "Rauchtest: uebersprungen (--no-smoke-test)"
else
[ "${#SMOKE_OK[@]}" -gt 0 ] && log_info "Rauchtest bestanden: ${SMOKE_OK[*]}"
[ "${#SMOKE_SKIP[@]}" -gt 0 ] && log_info "Rauchtest uebersprungen: ${SMOKE_SKIP[*]}"
if [ "${#SMOKE_FAIL[@]}" -gt 0 ]; then
log_error "RAUCHTEST FEHLGESCHLAGEN: ${SMOKE_FAIL[*]}"
log_error " Diese Instanzen laufen, verarbeiten aber keine PDFs."
log_error " Es wurde NICHT zurueckgerollt. Journal ansehen:"
for name in "${SMOKE_FAIL[@]}"; do
log_error " journalctl -u pdf-ocr-hotfolder@${name}.service -n 80 --no-pager"
done
RC=1
fi
fi
echo
if [ "$RC" -eq 0 ]; then
log_info "Update auf $NEW_VERSION abgeschlossen ✓"
else
log_error "Update abgeschlossen, aber mit Problemen (siehe oben)."
fi
exit "$RC"