cd803a3dfe
Vor dem Rollout durchgesehen und die verbliebenen Stellen geschlossen, an denen etwas schiefgehen konnte, ohne dass es irgendwo sichtbar wurde. Datenverlust: - veraPDF: das in [verapdf].binary konfigurierte Programm wird im Preflight geprueft. Bisher galt bei falschem Pfad JEDE Datei als "nicht konform" — Ergebnis nach error/, Original geloescht (Default delete). run_verapdf() trennt jetzt ausserdem ein echtes FAIL-Urteil von einer Stoerung (VeraPdfUnavailable: nicht startbar, abgestuerzt, kein PASS/FAIL in der Ausgabe). Bei Stoerung wandern Original UND Ergebnis nach error/, das Original wird nicht entsorgt. - Gleichnamige Dateien wurden in outgoing/, error/ und beim Ordner-Upload mit abweichendem target kommentarlos ueberschrieben. Jetzt Zeitstempel daneben, mit Warnung; ProcessResult.output traegt den echten Pfad. Robustheit: - Kaputtes oder nicht lesbares TOML beim Start: Exit 2 statt Traceback. - RestartPreventExitStatus=2 in der Unit — Exit 2 (Config/Preflight) laeuft nicht mehr endlos neu, die Instanz bleibt sichtbar failed stehen. - Toter watchdog-Observer wird erkannt: Exit 3, systemd setzt den Watch neu auf. Vorher blieb die Unit "active" und verarbeitete nichts mehr. - Relative Pfade in [paths]/archive_dir/target sind ein Config-Fehler statt still unter /opt zu landen. - Fehler beim Archivieren entwertet den Durchlauf nicht mehr: Upload und Mail laufen, Sichtbarkeit ueber log.error + "OK mit Warnung"-Mail. - Nicht-PDFs in incoming/ werden beim Start-Scan gesammelt gemeldet. - Logging explizit nach stdout (die Doku versprach das schon). Struktur: - Neue lib/common.sh, von install.sh und update.sh gesourct. Die doppelte venv_is_healthy() gibt es nur noch einmal, in der gruendlichen Fassung — die schlanke in install.sh haette eine nach einem Distro-Sprung kaputte venv als gesund durchgewunken (nachgewiesen). - install.sh warnt in Containern, wenn systemd-journald nicht laeuft. Doku: Dateisystem-Festlegung (ext4/xfs/zfs, kein CIFS/NFS wegen inotify), Debian 13 in LXC auf Proxmox scheitert an journald (243/CREDENTIALS, AppArmor blockiert sd-mkdcreds) inkl. Abhilfe, echte Speicher-Messwerte, Exit-Code-Tabelle. 254 Tests gruen (vorher 152). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1270 lines
47 KiB
Bash
Executable File
1270 lines
47 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
|
|
|
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
|
|
# ============================================================
|
|
# Gemeinsame Funktionen (Logging, Pfade, venv-Pruefung, Paketliste)
|
|
# ============================================================
|
|
# Relativ zum Skript aufgeloest, nicht zum Arbeitsverzeichnis — update.sh wird
|
|
# auch mit absolutem Pfad aufgerufen. Zuerst neben diesem Skript suchen (Repo),
|
|
# danach in der Installation, wohin install.sh/update.sh die Datei mitkopieren.
|
|
# Fehlt sie ueberall, ist hier Schluss: ein "command not found" mitten im Lauf
|
|
# waere die schlechtere Nachricht.
|
|
COMMON_LIB=""
|
|
for _pdf_ocr_cand in \
|
|
"$SCRIPT_DIR/lib/common.sh" \
|
|
"${INSTALL_DIR:-/opt/pdf-ocr-hotfolder}/lib/common.sh"
|
|
do
|
|
if [ -r "$_pdf_ocr_cand" ]; then
|
|
COMMON_LIB="$_pdf_ocr_cand"
|
|
break
|
|
fi
|
|
done
|
|
unset _pdf_ocr_cand
|
|
if [ -z "$COMMON_LIB" ]; then
|
|
echo "[ERROR] Gemeinsame Funktionsbibliothek lib/common.sh nicht gefunden." >&2
|
|
echo " Gesucht in: $SCRIPT_DIR/lib/ und ${INSTALL_DIR:-/opt/pdf-ocr-hotfolder}/lib/" >&2
|
|
echo " update.sh aus dem Repo ausfuehren (dort liegt lib/common.sh)." >&2
|
|
# shellcheck disable=SC2317 # 'exit' greift nur, wenn das Skript nicht gesourct wurde
|
|
return 1 2>/dev/null || exit 1
|
|
fi
|
|
# shellcheck source=lib/common.sh
|
|
. "$COMMON_LIB"
|
|
|
|
# Pfade sind ueberschreibbar, damit die Funktionen dieses Skripts isoliert
|
|
# gegen eine Fake-Umgebung getestet werden koennen (siehe LIB_ONLY unten).
|
|
# INSTALL_DIR/CONFIG_DIR/SYSTEMD_DIR und die Unit-Namen kommen aus lib/common.sh.
|
|
: "${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}"
|
|
UNIT_GLOB='pdf-ocr-hotfolder@*.service'
|
|
|
|
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="$DEFAULT_USER"
|
|
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
|
|
# ============================================================
|
|
#
|
|
# py_mm(), pyvenv_cfg_mm() und venv_is_healthy() stehen in lib/common.sh —
|
|
# install.sh braucht dieselbe Pruefung.
|
|
|
|
# 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 lib/common.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).
|
|
#
|
|
# Die Liste wird bewusst noch einmal aus der REPO-Fassung herausgeschnitten:
|
|
# gesourct wurde evtl. die aeltere Kopie aus der Installation, beim Update
|
|
# soll aber die neue Liste aus dem Repo gelten.
|
|
sync_system_packages() {
|
|
local block pkgs repo_lib
|
|
log_step "System-Pakete abgleichen"
|
|
|
|
repo_lib="$REPO_DIR/$COMMON_LIB_REL"
|
|
block="$(sed -n '/^# --- BEGIN apt-packages/,/^# --- END apt-packages/p' "$repo_lib" 2>/dev/null || true)"
|
|
if [ -n "$block" ] && printf '%s' "$block" | grep -q 'pdf_ocr_apt_packages()'; then
|
|
eval "$block"
|
|
else
|
|
log_warn "Paketliste in $repo_lib nicht gefunden — es gilt die geladene Fassung."
|
|
APT_WARN=1
|
|
fi
|
|
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 ($DATA_ROOT)"
|
|
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
|
|
|
|
require_root "sudo ./update.sh"
|
|
|
|
# SCRIPT_DIR steht schon ganz oben (fuer das Sourcen von lib/common.sh).
|
|
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 "$DEFAULT_USER")"
|
|
else
|
|
PRIMARY_USER="$(stat -c '%U' "$INSTALL_DIR" 2>/dev/null || echo "$DEFAULT_USER")"
|
|
fi
|
|
[ "$PRIMARY_USER" = "root" ] && PRIMARY_USER="$DEFAULT_USER"
|
|
|
|
# --- 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:"
|
|
report_venv_issues
|
|
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/"
|
|
# lib/ muss mit: ein spaeterer Lauf aus dem Installationsverzeichnis heraus
|
|
# sucht die gemeinsamen Funktionen genau dort.
|
|
rm -rf "${INSTALL_DIR:?}/lib"
|
|
cp -r "$REPO_DIR/lib" "$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 (Vorgabe: 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
|
|
# 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"
|