feat: stille Datenverlust-Pfade geschlossen, gemeinsame Shell-Lib (v0.7.0)
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>
This commit is contained in:
@@ -0,0 +1,199 @@
|
||||
"""Punkt 7: Ein toter watchdog-Observer muss auffallen.
|
||||
|
||||
Die Hauptschleife wartete nur auf `_stop` und fragte nie `is_alive()`. Stirbt
|
||||
der Observer im Betrieb (erschöpftes inotify-Watch-Limit, ersetztes oder neu
|
||||
gemountetes Verzeichnis), blieb die Unit `active (running)` und verarbeitete
|
||||
nichts mehr — kein Log, keine Mail, niemand merkt es.
|
||||
|
||||
Jetzt endet der Dienst mit `EXIT_OBSERVER_DEAD` (3), damit systemd ihn per
|
||||
`Restart=on-failure` neu startet. Bewusst nicht 2: die Unit setzt
|
||||
`RestartPreventExitStatus=2` für Config-/Preflight-Fehler.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import sys
|
||||
import threading
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from pdf_ocr_hotfolder.service import EXIT_OBSERVER_DEAD, HotfolderService
|
||||
|
||||
|
||||
class _NoSleepEvent(threading.Event):
|
||||
"""Event, dessen wait() nicht schläft — hält die Tests schnell."""
|
||||
|
||||
def wait(self, timeout: float | None = None) -> bool: # noqa: D102
|
||||
return self.is_set()
|
||||
|
||||
|
||||
class _StopAfter(_NoSleepEvent):
|
||||
"""Setzt sich nach n Warteschritten selbst — simuliert ein SIGTERM."""
|
||||
|
||||
def __init__(self, n: int) -> None:
|
||||
super().__init__()
|
||||
self._left = n
|
||||
|
||||
def wait(self, timeout: float | None = None) -> bool:
|
||||
self._left -= 1
|
||||
if self._left <= 0:
|
||||
self.set()
|
||||
return self.is_set()
|
||||
|
||||
|
||||
def _service(tmp_config, stop: threading.Event, alive) -> HotfolderService:
|
||||
service = HotfolderService(tmp_config)
|
||||
service._stop = stop
|
||||
observer = MagicMock()
|
||||
if isinstance(alive, list):
|
||||
observer.is_alive.side_effect = alive
|
||||
else:
|
||||
observer.is_alive.return_value = alive
|
||||
service._observer = observer
|
||||
return service
|
||||
|
||||
|
||||
def _wait_loop(service: HotfolderService) -> int:
|
||||
try:
|
||||
return service._wait_loop()
|
||||
finally:
|
||||
service._executor.shutdown(wait=False)
|
||||
|
||||
|
||||
# ---------------- _wait_loop ----------------
|
||||
|
||||
def test_dead_observer_returns_exit_code(tmp_config) -> None:
|
||||
service = _service(tmp_config, _NoSleepEvent(), alive=False)
|
||||
assert _wait_loop(service) == EXIT_OBSERVER_DEAD
|
||||
|
||||
|
||||
def test_exit_code_is_not_2(tmp_config) -> None:
|
||||
"""2 ist für Config-/Preflight-Fehler reserviert (RestartPreventExitStatus)."""
|
||||
assert EXIT_OBSERVER_DEAD != 2
|
||||
assert EXIT_OBSERVER_DEAD != 0
|
||||
|
||||
|
||||
def test_dead_observer_logs_clearly(tmp_config, caplog) -> None:
|
||||
service = _service(tmp_config, _NoSleepEvent(), alive=False)
|
||||
with caplog.at_level(logging.ERROR, logger="pdf_ocr_hotfolder.service"):
|
||||
_wait_loop(service)
|
||||
|
||||
text = caplog.text
|
||||
assert str(tmp_config.paths.incoming) in text
|
||||
assert "KEINE neuen Dateien" in text
|
||||
assert "inotify" in text
|
||||
|
||||
|
||||
def test_observer_is_checked_repeatedly(tmp_config) -> None:
|
||||
"""Der Observer wird nicht nur einmal beim Start geprüft."""
|
||||
service = _service(tmp_config, _NoSleepEvent(),
|
||||
alive=[True, True, True, False])
|
||||
assert _wait_loop(service) == EXIT_OBSERVER_DEAD
|
||||
assert service._observer.is_alive.call_count == 4
|
||||
|
||||
|
||||
def test_regular_stop_returns_zero(tmp_config) -> None:
|
||||
"""SIGTERM/SIGINT bei lebendem Observer: kein Fehlalarm."""
|
||||
service = _service(tmp_config, _StopAfter(3), alive=True)
|
||||
assert _wait_loop(service) == 0
|
||||
|
||||
|
||||
def test_stop_wins_over_dead_observer(tmp_config) -> None:
|
||||
"""Beim planmäßigen Stoppen darf ein gestoppter Observer nichts auslösen.
|
||||
|
||||
`shutdown()` stoppt den Observer — läuft die Schleife danach noch einen
|
||||
Takt, wäre das sonst ein Fehlalarm mit Exit 3 beim normalen Beenden.
|
||||
"""
|
||||
stop = _NoSleepEvent()
|
||||
stop.set()
|
||||
service = _service(tmp_config, stop, alive=False)
|
||||
assert _wait_loop(service) == 0
|
||||
service._observer.is_alive.assert_not_called()
|
||||
|
||||
|
||||
def test_missing_observer_does_not_crash(tmp_config) -> None:
|
||||
service = HotfolderService(tmp_config)
|
||||
service._stop = _StopAfter(2)
|
||||
service._observer = None
|
||||
assert _wait_loop(service) == 0
|
||||
|
||||
|
||||
# ---------------- run() / main() reichen den Code durch ----------------
|
||||
|
||||
def test_run_returns_exit_code(tmp_config) -> None:
|
||||
observer = MagicMock()
|
||||
observer.is_alive.return_value = False
|
||||
service = HotfolderService(tmp_config)
|
||||
service._stop = _NoSleepEvent()
|
||||
try:
|
||||
with patch("pdf_ocr_hotfolder.service.Observer", return_value=observer), \
|
||||
patch("pdf_ocr_hotfolder.service.check_preflight"):
|
||||
assert service.run() == EXIT_OBSERVER_DEAD
|
||||
finally:
|
||||
service._executor.shutdown(wait=False)
|
||||
# Auch im Fehlerfall wird sauber heruntergefahren
|
||||
observer.stop.assert_called_once()
|
||||
|
||||
|
||||
def test_run_returns_zero_on_regular_stop(tmp_config) -> None:
|
||||
observer = MagicMock()
|
||||
observer.is_alive.return_value = True
|
||||
service = HotfolderService(tmp_config)
|
||||
service._stop = _StopAfter(2)
|
||||
try:
|
||||
with patch("pdf_ocr_hotfolder.service.Observer", return_value=observer), \
|
||||
patch("pdf_ocr_hotfolder.service.check_preflight"):
|
||||
assert service.run() == 0
|
||||
finally:
|
||||
service._executor.shutdown(wait=False)
|
||||
|
||||
|
||||
def test_main_passes_exit_code_through(tmp_path, tmp_config, monkeypatch) -> None:
|
||||
from pdf_ocr_hotfolder.__main__ import main
|
||||
|
||||
cfg_file = tmp_path / "cfg.toml"
|
||||
cfg_file.write_text(f"""
|
||||
[paths]
|
||||
incoming = "{tmp_config.paths.incoming}"
|
||||
outgoing = "{tmp_config.paths.outgoing}"
|
||||
working = "{tmp_config.paths.working}"
|
||||
error = "{tmp_config.paths.error}"
|
||||
""")
|
||||
monkeypatch.setattr(sys, "argv",
|
||||
["pdf-ocr-hotfolder", "--config", str(cfg_file)])
|
||||
with patch.object(HotfolderService, "run", return_value=EXIT_OBSERVER_DEAD):
|
||||
assert main() == EXIT_OBSERVER_DEAD
|
||||
|
||||
|
||||
def test_main_returns_zero_on_regular_stop(tmp_path, tmp_config, monkeypatch) -> None:
|
||||
from pdf_ocr_hotfolder.__main__ import main
|
||||
|
||||
cfg_file = tmp_path / "cfg.toml"
|
||||
cfg_file.write_text(f"""
|
||||
[paths]
|
||||
incoming = "{tmp_config.paths.incoming}"
|
||||
outgoing = "{tmp_config.paths.outgoing}"
|
||||
working = "{tmp_config.paths.working}"
|
||||
error = "{tmp_config.paths.error}"
|
||||
""")
|
||||
monkeypatch.setattr(sys, "argv",
|
||||
["pdf-ocr-hotfolder", "--config", str(cfg_file)])
|
||||
with patch.object(HotfolderService, "run", return_value=0):
|
||||
assert main() == 0
|
||||
|
||||
|
||||
def test_main_returns_zero_on_keyboard_interrupt(tmp_path, tmp_config,
|
||||
monkeypatch) -> None:
|
||||
from pdf_ocr_hotfolder.__main__ import main
|
||||
|
||||
cfg_file = tmp_path / "cfg.toml"
|
||||
cfg_file.write_text(f"""
|
||||
[paths]
|
||||
incoming = "{tmp_config.paths.incoming}"
|
||||
outgoing = "{tmp_config.paths.outgoing}"
|
||||
working = "{tmp_config.paths.working}"
|
||||
error = "{tmp_config.paths.error}"
|
||||
""")
|
||||
monkeypatch.setattr(sys, "argv",
|
||||
["pdf-ocr-hotfolder", "--config", str(cfg_file)])
|
||||
with patch.object(HotfolderService, "run", side_effect=KeyboardInterrupt):
|
||||
assert main() == 0
|
||||
Reference in New Issue
Block a user