Files
pdf-ocr-hotfolder/update.sh
T
techadmin 3e24aa2ecd feat: Wiederaufnahme aus working/, --check-config, feldtauglicher Updater (v0.6.0)
Datenverlust behoben:
- Nach hartem Stopp blieb das Original in working/ liegen und wurde nie
  wieder angefasst (_scan_existing sah nur incoming/). Es wird jetzt beim
  Start an Ort und Stelle wieder aufgegriffen, mit Kollisionsschutz gegen
  gleichnamige neue Scans; angefangene __ocr_-Fragmente werden geloescht.
- TimeoutStopSec 30 -> 300, damit laufendes OCR zu Ende laufen darf.

Config-Drift sichtbar gemacht:
- Neues --check-config (Exit 0 sauber / 1 Warnungen / 2 Fehler), das
  update.sh vor dem Neustart ueber alle Instanz-Configs laufen laesst.
- Warnungen fuer [ocr].timeout >= 900 (seit 0.4.0 pro SEITE) und gesetztes
  pdfa_level, beim Dienststart wie im Check.
- Unbekannte Config-Keys werden nicht mehr still verworfen, sondern genannt.

Updater feldtauglich:
- venv-Health-Check erkennt toten Symlink UND Versions-Drift gegen das
  System-Python; --rebuild-venv als ausdruecklicher Weg nach einem Debian-
  Major-Upgrade. Neubau ist ganz-oder-gar-nicht mit Rollback.
- apt-Pakete werden auch beim Update synchronisiert (Quelle: install.sh).
- Instanz-Erfassung inkl. activating/failed, Verifikation prueft is-failed
  und NRestarts statt sleep 1 + is-active.
- Backup enthaelt Configs, Unit, Drop-ins und pip-freeze.txt, liegt auf
  0600 und rotiert auf 5; schlaegt es fehl, bricht das Update vorher ab.
- ERR-Trap faehrt die vorher laufenden Instanzen wieder hoch.
- lxc-compat.conf wird beim Update nachgezogen.
- requirements.txt gepinnt (ocrmypdf 16.13.0, geprueft fuer Python 3.11+3.13).

Doku in Installation / Update / OS-Upgrade aufgeteilt (docs/).
135 Tests gruen.

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

861 lines
31 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)
#
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}"
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
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=()
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.
-h, --help Diese Hilfe.
Ohne Option prueft das Skript selbst, ob die venv noch zum System-Python
passt, und baut sie bei Bedarf neu.
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
}
# Baut die venv neu: alte wegsichern, neue bauen, Requirements installieren,
# und erst bei Erfolg die alte entfernen. Scheitert etwas, wird die alte
# zurueckgerollt und hart abgebrochen.
rebuild_venv() {
local venv="$INSTALL_DIR/venv"
local saved sys_mm
saved="$INSTALL_DIR/venv.old-$(date +%Y%m%d-%H%M%S)"
sys_mm="$(py_mm "$(command -v python3 || echo /usr/bin/python3)")"
log_step "venv wird neu gebaut (System-Python ${sys_mm:-?})"
if [ -d "$venv" ]; then
mv "$venv" "$saved" || die "Alte venv liess sich nicht nach $saved verschieben."
log_info "Alte venv gesichert: $saved"
else
saved=""
log_info "Keine alte venv vorhanden — es wird frisch gebaut."
fi
if ! python3 -m venv "$venv"; then
log_error "python3 -m venv ist fehlgeschlagen."
log_error "Fehlt das Paket python3-venv? -> apt-get install -y python3-venv"
rm -rf "$venv"
if [ -n "$saved" ]; then
mv "$saved" "$venv" && log_warn "Alte venv wurde zurueckgerollt: $venv"
fi
die "venv-Neubau abgebrochen."
fi
if ! pip_install_requirements "$venv"; then
rm -rf "$venv"
if [ -n "$saved" ]; then
mv "$saved" "$venv" && log_warn "Alte venv wurde zurueckgerollt: $venv"
log_warn "Sie haengt weiterhin an einem Python, das es so nicht mehr gibt —"
log_warn "die Instanzen laufen damit nicht. Erst requirements.txt korrigieren."
fi
die "venv-Neubau abgebrochen — keine halb gefuellte venv zurueckgelassen."
fi
[ -n "$saved" ] && rm -rf "$saved"
VENV_REBUILT=1
log_info "venv neu gebaut ✓ (Python ${sys_mm:-?})"
}
# ============================================================
# System-Pakete
# ============================================================
# Holt die Paketliste aus install.sh (einzige Quelle) und installiert sie.
# apt-get install ist idempotent; bereits vorhandene Pakete bleiben unberuehrt.
# Nachinstallierte Tesseract-Sprachpakete werden NICHT angefasst (kein purge,
# kein autoremove).
sync_system_packages() {
local block pkgs
log_step "System-Pakete abgleichen"
block="$(sed -n '/^# --- BEGIN apt-packages/,/^# --- END apt-packages/p' "$REPO_DIR/install.sh" 2>/dev/null || true)"
if [ -z "$block" ] || ! printf '%s' "$block" | grep -q 'pdf_ocr_apt_packages()'; then
log_warn "Paketliste in install.sh nicht gefunden — System-Pakete werden nicht abgeglichen."
APT_WARN=1
return 0
fi
eval "$block"
if ! declare -F pdf_ocr_apt_packages >/dev/null; then
log_warn "pdf_ocr_apt_packages() liess sich nicht laden — uebersprungen."
APT_WARN=1
return 0
fi
mapfile -t pkgs < <(pdf_ocr_apt_packages)
if [ "${#pkgs[@]}" -eq 0 ]; then
log_warn "Paketliste ist leer — uebersprungen."
APT_WARN=1
return 0
fi
log_info "Pakete: ${pkgs[*]}"
if ! apt-get update -qq; then
log_warn "'apt-get update' fehlgeschlagen (kein Netz / kein Mirror?)."
APT_WARN=1
fi
if apt-get install -y --no-install-recommends "${pkgs[@]}"; then
log_info "System-Pakete ok ✓ (vorhandene Sprachpakete bleiben unangetastet)"
else
log_warn "Mindestens ein System-Paket liess sich nicht installieren."
log_warn "Falls eine neue Version ein neues Paket braucht, laufen die Instanzen evtl. nicht."
APT_WARN=1
fi
}
# ============================================================
# Instanzen erfassen
# ============================================================
unit_state() { systemctl is-active "$1" 2>/dev/null || true; }
unit_failed() { systemctl is-failed "$1" 2>/dev/null || true; }
unit_restarts() {
local n
n="$(systemctl show -p NRestarts --value "$1" 2>/dev/null || true)"
case "$n" in (''|*[!0-9]*) echo 0 ;; (*) echo "$n" ;; esac
}
# Sammelt alle Instanz-Units: geladene (aktiv, activating, failed), per
# list-unit-files enabled, und alle, fuer die eine Config existiert.
collect_instances() {
local -A seen=()
local unit name line state failed cfg
while read -r line; do
unit="$(printf '%s' "$line" | awk '{print $1}')"
unit="${unit#●}"
[ -n "$unit" ] || continue
case "$unit" in
pdf-ocr-hotfolder@.service) continue ;;
pdf-ocr-hotfolder@?*.service) seen["$unit"]=1 ;;
esac
done < <(systemctl list-units --all --no-legend --plain "$UNIT_GLOB" 2>/dev/null || true)
while read -r line; do
unit="$(printf '%s' "$line" | awk '{print $1}')"
[ -n "$unit" ] || continue
case "$unit" in
pdf-ocr-hotfolder@.service) continue ;;
pdf-ocr-hotfolder@?*.service) seen["$unit"]=1 ;;
esac
done < <(systemctl list-unit-files --no-legend --plain "$UNIT_GLOB" 2>/dev/null || true)
shopt -s nullglob
for cfg in "$CONFIG_DIR"/*.toml; do
name="$(basename "$cfg" .toml)"
[ -n "$name" ] || continue
seen["pdf-ocr-hotfolder@${name}.service"]=1
done
shopt -u nullglob
UNITS_ALL=(); PREV_OK=(); PREV_BROKEN=(); PREV_STOPPED=()
[ "${#seen[@]}" -gt 0 ] || return 0
mapfile -t UNITS_ALL < <(printf '%s\n' "${!seen[@]}" | sort)
for unit in "${UNITS_ALL[@]}"; do
state="$(unit_state "$unit")"
failed="$(unit_failed "$unit")"
case "$state" in
active)
if [ "$failed" = "failed" ]; then
PREV_BROKEN+=("$unit")
else
PREV_OK+=("$unit")
fi
;;
activating|reloading|deactivating|failed)
PREV_BROKEN+=("$unit")
;;
*)
if [ "$failed" = "failed" ]; then
PREV_BROKEN+=("$unit")
else
PREV_STOPPED+=("$unit")
fi
;;
esac
done
}
report_instances() {
if [ "${#PREV_OK[@]}" -gt 0 ]; then
log_info "Lief vorher sauber (${#PREV_OK[@]}): ${PREV_OK[*]}"
else
log_info "Keine sauber laufende Instanz gefunden."
fi
if [ "${#PREV_BROKEN[@]}" -gt 0 ]; then
log_warn "War vorher KAPUTT (${#PREV_BROKEN[@]}): ${PREV_BROKEN[*]}"
log_warn " (failed oder Crash-Loop/activating — wird mit gestartet, gilt aber"
log_warn " erst als Erfolg, wenn sie nach dem Update wirklich laeuft.)"
fi
if [ "${#PREV_STOPPED[@]}" -gt 0 ]; then
log_info "Bewusst gestoppt, bleibt gestoppt (${#PREV_STOPPED[@]}): ${PREV_STOPPED[*]}"
fi
}
stop_instances() {
local -a units=()
[ "${#PREV_OK[@]}" -gt 0 ] && units+=("${PREV_OK[@]}")
[ "${#PREV_BROKEN[@]}" -gt 0 ] && units+=("${PREV_BROKEN[@]}")
[ "${#units[@]}" -gt 0 ] || { log_info "Nichts zu stoppen."; return 0; }
log_info "Stoppe Instanzen: ${units[*]}"
local unit
for unit in "${units[@]}"; do
systemctl stop "$unit" >/dev/null 2>&1 || log_warn " stop fehlgeschlagen: $unit"
done
}
# ============================================================
# Verifikation nach dem Start
# ============================================================
# Wartet mindestens VERIFY_WAIT Sekunden und prueft danach is-active,
# is-failed und den Restart-Zaehler. Ein Crash-Loop (Type=simple meldet
# sofort "active") faellt damit auf.
verify_unit() {
local unit="$1"
local r0 r1 state failed waited=0
r0="$(unit_restarts "$unit")"
while [ "$waited" -lt "$VERIFY_WAIT" ]; do
sleep 1
waited=$((waited + 1))
[ "$(unit_state "$unit")" = "failed" ] && break
done
state="$(unit_state "$unit")"
failed="$(unit_failed "$unit")"
r1="$(unit_restarts "$unit")"
if [ "$state" != "active" ]; then
log_error " ❌ $unit — Status '$state' nach ${waited}s"
return 1
fi
if [ "$failed" = "failed" ]; then
log_error " ❌ $unit — systemd meldet 'failed'"
return 1
fi
if [ "$r1" -gt "$r0" ]; then
log_error " ❌ $unit — Crash-Loop (NRestarts $r0 -> $r1 in ${waited}s)"
return 1
fi
return 0
}
start_instances() {
local -a units=()
[ "${#PREV_OK[@]}" -gt 0 ] && units+=("${PREV_OK[@]}")
[ "${#PREV_BROKEN[@]}" -gt 0 ] && units+=("${PREV_BROKEN[@]}")
STARTED_OK=(); STARTED_FAIL=()
[ "${#units[@]}" -gt 0 ] || { log_info "Keine Instanz zu starten."; return 0; }
local unit
for unit in "${units[@]}"; do
# Alten failed-Zustand raeumen, damit is-failed/NRestarts danach
# wirklich etwas ueber diesen Start aussagen.
systemctl reset-failed "$unit" >/dev/null 2>&1 || true
systemctl start "$unit" >/dev/null 2>&1 || log_warn " start meldete einen Fehler: $unit"
if verify_unit "$unit"; then
log_info " ✅ $unit laeuft (nach ${VERIFY_WAIT}s stabil)"
STARTED_OK+=("$unit")
else
log_error " Logs: journalctl -u $unit -n 50 --no-pager"
STARTED_FAIL+=("$unit")
fi
done
}
# ============================================================
# Config-Pruefung (CLI des Python-Teils)
# ============================================================
# Nutzt: venv/bin/python -m pdf_ocr_hotfolder --check-config --config <datei>
# Exit 0 = sauber, 1 = Warnungen, 2 = Fehler.
# Kennt der installierte Code das Subkommando nicht, wird die Pruefung
# uebersprungen (Warnung), das Update laeuft aber weiter.
check_all_configs() {
local py="$INSTALL_DIR/venv/bin/python"
local cfg name out rc
local -a cfgs=()
log_step "Configs pruefen (--check-config)"
CFG_WARN=(); CFG_ERR=()
if [ ! -x "$py" ]; then
log_warn "venv-Python nicht ausfuehrbar — Config-Pruefung uebersprungen."
return 0
fi
shopt -s nullglob
cfgs=("$CONFIG_DIR"/*.toml)
shopt -u nullglob
if [ "${#cfgs[@]}" -eq 0 ]; then
log_info "Keine Instanz-Configs unter $CONFIG_DIR."
return 0
fi
for cfg in "${cfgs[@]}"; do
name="$(basename "$cfg" .toml)"
out="$(cd "$INSTALL_DIR" && "$py" -m pdf_ocr_hotfolder --check-config --config "$cfg" 2>&1)" && rc=0 || rc=$?
case "$rc" in
0)
log_info " ✅ $name — Config ok"
[ -n "$out" ] && printf '%s\n' "$out"
;;
1)
log_warn " ⚠ $name — Warnungen:"
printf '%s\n' "$out"
CFG_WARN+=("$name")
;;
2)
# argparse beendet sich bei unbekannten Optionen ebenfalls mit 2 —
# das ist kein Config-Fehler, sondern aelterer Code.
if printf '%s' "$out" | grep -qiE 'unrecognized arguments|invalid choice|no such option|unknown option|unrecognized option'; then
log_warn "Der installierte Code kennt --check-config nicht (aeltere Version)."
log_warn "Config-Pruefung wird uebersprungen — Update laeuft weiter."
CFG_WARN=(); CFG_ERR=()
return 0
fi
log_error " ❌ $name — Config-FEHLER:"
printf '%s\n' "$out"
CFG_ERR+=("$name")
;;
*)
log_warn " ⚠ $name — --check-config lieferte unerwarteten Exit-Code $rc; ignoriert."
[ -n "$out" ] && printf '%s\n' "$out" | tail -n 10
;;
esac
done
}
# ============================================================
# 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 ;;
-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
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
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
# ============================================================
# Zusammenfassung: Ist gegen Soll
# ============================================================
trap - ERR INT TERM
RC=0
echo
echo "=========================================="
echo " Zusammenfassung"
echo "=========================================="
log_info "Version: $OLD_VERSION → $NEW_VERSION"
log_info "Backup: ${BACKUP_FILE:-keines}"
[ "$VENV_REBUILT" -eq 1 ] && log_info "venv: neu gebaut (Python $(py_mm "$INSTALL_DIR/venv/bin/python"))"
[ "$LXC_SYNCED" -eq 1 ] && log_info "LXC: Drop-in nachgezogen"
SOLL=$(( ${#PREV_OK[@]} + ${#PREV_BROKEN[@]} ))
log_info "Soll: $SOLL Instanz(en) sollen laufen | Ist: ${#STARTED_OK[@]} laufen"
if [ "${#PREV_STOPPED[@]}" -gt 0 ]; then
log_info "Gestoppt gelassen: ${PREV_STOPPED[*]}"
fi
# Vorher kaputte Instanzen, die auch jetzt nicht laufen: klar benennen.
FIXED=(); STILL_BROKEN=()
for unit in "${PREV_BROKEN[@]:-}"; do
[ -n "$unit" ] || continue
if printf '%s\n' "${STARTED_OK[@]:-}" | grep -qxF "$unit"; then
FIXED+=("$unit")
else
STILL_BROKEN+=("$unit")
fi
done
[ "${#FIXED[@]}" -gt 0 ] && log_info "Vorher kaputt, laeuft jetzt: ${FIXED[*]}"
if [ "${#STILL_BROKEN[@]}" -gt 0 ]; then
log_error "Vorher kaputt und immer noch kaputt: ${STILL_BROKEN[*]}"
RC=1
fi
VORHER_OK_JETZT_NICHT=()
for unit in "${PREV_OK[@]:-}"; do
[ -n "$unit" ] || continue
printf '%s\n' "${STARTED_OK[@]:-}" | grep -qxF "$unit" || VORHER_OK_JETZT_NICHT+=("$unit")
done
if [ "${#VORHER_OK_JETZT_NICHT[@]}" -gt 0 ]; then
log_error "REGRESSION — lief vorher, laeuft jetzt nicht: ${VORHER_OK_JETZT_NICHT[*]}"
log_error "Rollback: tar -xzf '${BACKUP_FILE:-<backup>}' -C / && systemctl daemon-reload"
RC=1
fi
if [ "${#CFG_ERR[@]}" -gt 0 ]; then
log_error "Config-FEHLER (Exit 2) bei: ${CFG_ERR[*]}"
log_error " Diese Instanzen gelten NICHT als erfolgreich aktualisiert."
RC=1
fi
if [ "${#CFG_WARN[@]}" -gt 0 ]; then
log_warn "Config-Warnungen (Exit 1) bei: ${CFG_WARN[*]}"
log_warn " Kein Abbruchgrund, aber bitte nachsehen:"
for name in "${CFG_WARN[@]}"; do
log_warn " $INSTALL_DIR/venv/bin/python -m pdf_ocr_hotfolder --check-config --config $CONFIG_DIR/$name.toml"
done
fi
if [ "$APT_WARN" -eq 1 ]; then
log_warn "System-Pakete konnten nicht vollstaendig abgeglichen werden (siehe oben)."
fi
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"