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>
308 lines
9.8 KiB
Python
308 lines
9.8 KiB
Python
"""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"]
|