#!/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 < 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 # 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: "SCHLUESSELWert" 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:-}' -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 # Das Paket ist nicht pip-installiert, sondern liegt in $INSTALL_DIR — # ohne das cd findet Python das Modul nicht. log_warn " cd $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"