feat: Regellauf mit Lock, Lebenszeichen und Zeitlimit

RuleRunner prüft Anbindungen vor jedem Lauf, baut das Ziel auf und führt
die Engine im Worker mit Inaktivitäts-Watchdog aus. Der Lock wird erst
beim echten Ende des Workers freigegeben.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Florian Wilde
2026-10-10 19:06:14 +02:00
co-authored by Claude Opus 5.5
parent 5633d3057a
commit 642def66bd
6 changed files with 650 additions and 2 deletions
+307
View File
@@ -0,0 +1,307 @@
"""Regellauf mit FakeMountBackend – Fallstricke 2, 8 und 13 sowie SFTP-Ziel."""
from __future__ import annotations
import threading
import time
from collections.abc import Iterator
from pathlib import Path
import pytest
from sqlalchemy.orm import Session, sessionmaker
from netmover.crypto import SecretBox
from netmover.db import session_scope
from netmover.models import (
Credential,
CredentialKind,
ItemStatus,
Mount,
MountType,
Rule,
RunStatus,
)
from netmover.mounting.fake import FakeMountBackend
from netmover.mounting.manager import MountManager
from netmover.mover.runner import RuleRunner
from tests.conftest import MountFactory
from tests.sftp_stub import StubSFTPServer
@pytest.fixture
def setup(
manager: MountManager,
fake_backend: FakeMountBackend,
make_mount: MountFactory,
session_factory: sessionmaker[Session],
) -> tuple[int, int, int, Path, Path]:
scans = fake_backend.export("nas", "scans")
archiv = fake_backend.export("nas2", "archiv")
src_id = make_mount("scans", server="nas", share="scans")
dst_id = make_mount("archiv", server="nas2", share="archiv")
manager.mount(src_id)
manager.mount(dst_id)
with session_scope(session_factory) as s:
r = Rule(
name="scans→archiv",
src_mount_id=src_id,
dst_mount_id=dst_id,
dst_path="eingang",
stability_seconds=1,
)
s.add(r)
s.flush()
rid = r.id
return rid, src_id, dst_id, scans, archiv
@pytest.fixture
def runner(session_factory: sessionmaker[Session], manager: MountManager) -> RuleRunner:
return RuleRunner(session_factory, manager, sleep=lambda _: None)
def test_moves_between_two_mounts(
runner: RuleRunner, setup: tuple[int, int, int, Path, Path]
) -> None:
rid, _, _, scans, archiv = setup
(scans / "scan001.pdf").write_text("x")
result = runner.run(rid)
assert result.status is RunStatus.OK
assert [(i.src, i.status) for i in result.items] == [("scan001.pdf", ItemStatus.MOVED)]
assert (archiv / "eingang" / "scan001.pdf").read_text() == "x"
assert not (scans / "scan001.pdf").exists()
# -- Fallstrick 2 ---------------------------------------------------------------------------
def test_unmounted_destination_aborts_and_writes_nothing_locally(
runner: RuleRunner,
setup: tuple[int, int, int, Path, Path],
fake_backend: FakeMountBackend,
manager: MountManager,
) -> None:
rid, _, _, scans, _archiv = setup
(scans / "scan001.pdf").write_text("x")
mp = manager.mountpoint_for("archiv")
fake_backend.vanish(mp) # Freigabe weg, Mountpoint ist jetzt ein leeres lokales Verzeichnis
result = runner.run(rid)
assert result.status is RunStatus.ERROR
assert "nicht eingehängt" in (result.message or "")
assert list(mp.iterdir()) == [] # nichts auf die lokale Platte geschrieben
assert (scans / "scan001.pdf").exists()
def test_unmounted_source_aborts(
runner: RuleRunner,
setup: tuple[int, int, int, Path, Path],
fake_backend: FakeMountBackend,
manager: MountManager,
) -> None:
rid, *_ = setup
fake_backend.vanish(manager.mountpoint_for("scans"))
result = runner.run(rid)
assert result.status is RunStatus.ERROR and result.items == []
def test_local_destination_under_mount_base_is_refused(
runner: RuleRunner,
setup: tuple[int, int, int, Path, Path],
manager: MountManager,
session_factory: sessionmaker[Session],
) -> None:
rid, *_ = setup
with session_scope(session_factory) as s:
r = s.get(Rule, rid)
assert r is not None
r.dst_mount_id = None
r.dst_path = str(manager.mountpoint_for("archiv"))
result = runner.run(rid)
assert result.status is RunStatus.ERROR
assert "Anbindung als Ziel" in (result.message or "")
def test_local_destination(
runner: RuleRunner,
setup: tuple[int, int, int, Path, Path],
tmp_path: Path,
session_factory: sessionmaker[Session],
) -> None:
rid, _, _, scans, _ = setup
consume = tmp_path / "consume"
consume.mkdir()
with session_scope(session_factory) as s:
r = s.get(Rule, rid)
assert r is not None
r.dst_mount_id = None
r.dst_path = str(consume)
(scans / "a.pdf").write_text("x")
assert runner.run(rid).status is RunStatus.OK
assert (consume / "a.pdf").exists()
# -- Fallstrick 8 / 13: Überlappung und Zeitlimit ---------------------------------------------
@pytest.fixture
def blocking_runner(
session_factory: sessionmaker[Session], manager: MountManager
) -> Iterator[tuple[RuleRunner, threading.Event, threading.Event]]:
entered = threading.Event()
release = threading.Event()
def sleep(_: float) -> None: # Stabilitätsfenster „hängt“
entered.set()
release.wait(10)
yield RuleRunner(session_factory, manager, sleep=sleep), entered, release
release.set()
def test_overlapping_run_is_skipped(
blocking_runner: tuple[RuleRunner, threading.Event, threading.Event],
setup: tuple[int, int, int, Path, Path],
) -> None:
runner, entered, release = blocking_runner
rid, _, _, scans, archiv = setup
(scans / "a.pdf").write_text("x")
results = []
t = threading.Thread(target=lambda: results.append(runner.run(rid)))
t.start()
assert entered.wait(5)
skipped = runner.run(rid)
assert skipped.status is RunStatus.SKIPPED
assert "läuft noch" in (skipped.message or "")
release.set()
t.join(5)
assert results[0].status is RunStatus.OK
assert (archiv / "eingang" / "a.pdf").exists()
def test_hanging_run_times_out_and_blocks_rule_until_worker_ends(
blocking_runner: tuple[RuleRunner, threading.Event, threading.Event],
setup: tuple[int, int, int, Path, Path],
session_factory: sessionmaker[Session],
) -> None:
runner, _entered, release = blocking_runner
rid, _, _, scans, archiv = setup
with session_scope(session_factory) as s:
r = s.get(Rule, rid)
assert r is not None
r.op_timeout_seconds = 1
(scans / "a.pdf").write_text("x")
t0 = time.monotonic()
result = runner.run(rid)
assert time.monotonic() - t0 < 5 # Scheduler wird nicht blockiert
assert result.status is RunStatus.ERROR
assert "Keine Reaktion" in (result.message or "")
assert runner.running_since(rid) is not None
# solange der alte Worker hängt: neue Termine überspringen
assert runner.run(rid).status is RunStatus.SKIPPED
# Worker wacht auf → sieht den Abbruch, verschiebt nichts mehr, gibt Lock frei
release.set()
for _ in range(50):
if runner.running_since(rid) is None:
break
time.sleep(0.05)
assert runner.running_since(rid) is None
assert (scans / "a.pdf").exists()
assert not (archiv / "eingang" / "a.pdf").exists()
def test_stale_source_is_recovered_before_run(
runner: RuleRunner,
setup: tuple[int, int, int, Path, Path],
fake_backend: FakeMountBackend,
manager: MountManager,
) -> None:
import errno
rid, _, _, scans, archiv = setup
(scans / "a.pdf").write_text("x")
fake_backend.inject_fault(manager.mountpoint_for("scans"), errno.ESTALE)
result = runner.run(rid)
assert result.status is RunStatus.OK
assert (archiv / "eingang" / "a.pdf").exists()
assert ("unmount", True) in [(c.action, c.lazy) for c in fake_backend.calls]
def test_dry_run_flag_from_rule_and_override(
runner: RuleRunner,
setup: tuple[int, int, int, Path, Path],
session_factory: sessionmaker[Session],
) -> None:
rid, _, _, scans, _archiv = setup
(scans / "a.pdf").write_text("x")
with session_scope(session_factory) as s:
r = s.get(Rule, rid)
assert r is not None
r.dry_run = True
result = runner.run(rid)
assert result.dry_run and result.items[0].status is ItemStatus.PLANNED
assert (scans / "a.pdf").exists()
assert runner.run(rid, dry_run=False).items[0].status is ItemStatus.MOVED
def test_unknown_rule(runner: RuleRunner) -> None:
assert runner.run(999).status is RunStatus.ERROR
# -- SFTP-Ziel --------------------------------------------------------------------------------
@pytest.fixture
def sftp_server(tmp_path: Path) -> Iterator[StubSFTPServer]:
root = tmp_path / "sftp-root"
(root / "consume").mkdir(parents=True)
srv = StubSFTPServer(root).start()
yield srv
srv.stop()
def test_move_to_sftp_destination_with_collision(
runner: RuleRunner,
setup: tuple[int, int, int, Path, Path],
sftp_server: StubSFTPServer,
session_factory: sessionmaker[Session],
secretbox: SecretBox,
) -> None:
rid, _, _, scans, _ = setup
with session_scope(session_factory) as s:
c = Credential(
name="pk",
kind=CredentialKind.SSH_KEY,
username=sftp_server.username,
secret_enc=secretbox.encrypt(sftp_server.client_private_key),
)
s.add(c)
s.flush()
m = Mount(
name="paperless",
slug="paperless",
type=MountType.SFTP,
server="127.0.0.1",
share="/consume",
port=sftp_server.port,
cred_id=c.id,
host_key=sftp_server.host_key_string,
)
s.add(m)
s.flush()
r = s.get(Rule, rid)
assert r is not None
r.dst_mount_id = m.id
r.dst_path = ""
consume = sftp_server.root / "consume"
(consume / "scan.pdf").write_text("alt")
(scans / "scan.pdf").write_bytes(b"%PDF neu" * 50_000)
result = runner.run(rid)
assert result.status is RunStatus.OK, result.message
assert result.items[0].dst == "paperless:/consume/scan-1.pdf"
assert (consume / "scan-1.pdf").read_bytes() == b"%PDF neu" * 50_000
assert (consume / "scan.pdf").read_text() == "alt"
assert not (scans / "scan.pdf").exists()
assert sorted(p.name for p in consume.iterdir()) == ["scan-1.pdf", "scan.pdf"]