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:
co-authored by
Claude Opus 5.5
parent
5633d3057a
commit
642def66bd
@@ -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"]
|
||||
Reference in New Issue
Block a user