feat: Mover-Engine mit atomarem Schreiben, Stabilität und Quarantäne

Lokale/Kernel-Mount- und SFTP-Ziele schreiben in .part, fsyncen, prüfen
Größe und Quelle und benennen ohne Überschreiben um; erst dann wird die
Quelle gelöscht. Stabilitätsfenster, Platzprüfung, Metadaten,
Berechtigungen, Kopiermodus mit Gedächtnis, Quarantäne.

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 c3a42ce6f5
commit 5633d3057a
3 changed files with 1603 additions and 0 deletions
+489
View File
@@ -0,0 +1,489 @@
"""Ziele: lokales Verzeichnis bzw. Kernel-Mount, oder SFTP.
Fallstrick 3 – nie eine wachsende Datei unter ihrem Endnamen, nie beide Kopien
verlieren. Ablauf für jede Datei:
1. in ``<ziel>/.netmover-<uuid>.part`` schreiben, ``flush`` + ``fsync``,
Größe prüfen,
2. Metadaten setzen (mtime, Modus, Eigentümer – Fehler sind nur Hinweise),
3. prüfen, dass sich die Quelle seit der Stabilitätsmessung nicht verändert hat,
4. atomar auf den Endnamen umbenennen – ohne zu überschreiben (``link`` bzw.
SFTP-``rename``), außer „überschreiben“ ist ausdrücklich eingestellt,
5. erst danach löscht der Aufrufer die Quelle.
Scheitert irgendein Schritt vor 4, wird nur die ``.part``-Datei entfernt; die
Quelle ist unangetastet. Bricht der Prozess hart ab, bleibt höchstens eine
``.part``-Datei zurück, die :meth:`Destination.prepare` später aufräumt.
"""
from __future__ import annotations
import contextlib
import errno
import os
import posixpath
import stat
import time
import uuid
from abc import ABC, abstractmethod
from collections.abc import Callable
from dataclasses import dataclass, field
from pathlib import Path
from typing import Literal
from netmover.models import CollisionPolicy
from netmover.mounting.sftp import SftpSession
from netmover.mover.naming import collision_candidates
from netmover.timeouts import CancelToken
CHUNK = 1024 * 1024
STALE_PART_SECONDS = 3600
PART_PREFIX = ".netmover-"
PART_SUFFIX = ".part"
FsKind = Literal["local", "cifs", "nfs", "sftp"]
# Fehler, bei denen ``link`` nicht geht und wir auf „prüfen + rename“ ausweichen.
_NO_LINK_ERRNOS = frozenset(
{errno.EPERM, errno.ENOTSUP, errno.EOPNOTSUPP, errno.EMLINK, errno.EXDEV, errno.ENOSYS}
)
class TransferError(Exception):
"""Übertragung dieser Datei gescheitert (Meldung im Klartext)."""
class SourceChanged(TransferError):
"""Quelle hat sich seit der Stabilitätsmessung verändert – später erneut versuchen."""
class CollisionSkip(Exception):
"""Ziel existiert und die Regel sagt „überspringen“."""
@dataclass(frozen=True)
class MetaOptions:
atime_ns: int
mtime_ns: int
mode: int | None # Rechte-Bits, None = nicht setzen
owner: tuple[int, int] | None # None = nicht setzen
@dataclass
class CommitResult:
final_rel: str
notes: list[str] = field(default_factory=list)
source_consumed: bool = False # True: Quelle wurde per rename bereits „verschoben“
Verify = Callable[[], None]
class Destination(ABC):
kind: FsKind
@abstractmethod
def describe(self, rel: str) -> str:
"""Lesbarer Zielpfad für Protokoll und Probelauf."""
@abstractmethod
def prepare(self) -> None:
"""Vor dem Lauf: Zielordner prüfen, alte ``.part``-Reste entfernen."""
@abstractmethod
def exists(self, rel: str) -> bool: ...
@abstractmethod
def free_bytes(self) -> int | None: ...
def close(self) -> None: # noqa: B027 – absichtlich optional
pass
# -- Primitive der Unterklassen -------------------------------------------------
@abstractmethod
def _ensure_parent(self, rel: str) -> None: ...
@abstractmethod
def _upload(self, src: Path, tmp: str, size: int, token: CancelToken) -> list[str]: ...
@abstractmethod
def _apply_meta(self, rel: str, meta: MetaOptions) -> list[str]: ...
@abstractmethod
def _link_noreplace(self, tmp: str, final: str) -> None:
"""Atomar umbenennen; ``FileExistsError``, wenn ``final`` existiert."""
@abstractmethod
def _replace(self, tmp: str, final: str) -> list[str]: ...
@abstractmethod
def _remove(self, rel: str) -> None: ...
def _sync_dir(self, rel_dir: str) -> None: # noqa: B027
pass
# -- gemeinsamer Ablauf ---------------------------------------------------------
def plan(self, rel: str, policy: CollisionPolicy) -> str | None:
"""Voraussichtlicher Endname (Probelauf / Vorabprüfung); None = überspringen."""
if policy is CollisionPolicy.OVERWRITE:
return rel
if policy is CollisionPolicy.SKIP:
return None if self.exists(rel) else rel
for cand in collision_candidates(rel):
if not self.exists(cand):
return cand
raise TransferError(f"Kein freier Dateiname für {rel}")
def commit(
self,
src: Path,
size: int,
rel: str,
policy: CollisionPolicy,
meta: MetaOptions,
verify: Verify,
token: CancelToken,
) -> CommitResult:
self._ensure_parent(rel)
tmp = posixpath.join(
posixpath.dirname(rel), f"{PART_PREFIX}{uuid.uuid4().hex}{PART_SUFFIX}"
)
try:
notes = self._upload(src, tmp, size, token)
notes += self._apply_meta(tmp, meta)
verify()
token.check()
final, more = self._finalize(tmp, rel, policy)
notes += more
except BaseException:
with contextlib.suppress(Exception):
self._remove(tmp)
raise
self._sync_dir(posixpath.dirname(final))
return CommitResult(final, notes)
def move_same_device(
self, src: Path, rel: str, policy: CollisionPolicy, meta: MetaOptions
) -> CommitResult | None:
"""Schneller Weg ohne Kopie, wenn Quelle und Ziel auf demselben Gerät liegen."""
return None
def _finalize(self, tmp: str, rel: str, policy: CollisionPolicy) -> tuple[str, list[str]]:
if policy is CollisionPolicy.OVERWRITE:
return rel, self._replace(tmp, rel)
candidates = collision_candidates(rel) if policy is CollisionPolicy.RENAME else iter([rel])
for cand in candidates:
if self.exists(cand):
if policy is CollisionPolicy.SKIP:
raise CollisionSkip
continue
try:
self._link_noreplace(tmp, cand)
except FileExistsError:
# Wettlauf: jemand anderes hat den Namen gerade belegt
if policy is CollisionPolicy.SKIP:
raise CollisionSkip from None
continue
return cand, []
raise TransferError(f"Kein freier Dateiname für {rel}")
class LocalDestination(Destination):
"""Lokales Verzeichnis oder Verzeichnis auf einem (geprüften) Kernel-Mount."""
def __init__(
self,
root: Path,
kind: Literal["local", "cifs", "nfs"] = "local",
*,
create_root: bool = False,
) -> None:
self.root = root
self.kind = kind
self.create_root = create_root
def _p(self, rel: str) -> Path:
return self.root / rel
def describe(self, rel: str) -> str:
return str(self._p(rel))
def prepare(self) -> None:
if not self.root.is_dir():
if not self.create_root:
raise TransferError(f"Zielordner {self.root} existiert nicht")
self.root.mkdir(mode=0o755, parents=True, exist_ok=True)
cutoff = time.time() - STALE_PART_SECONDS
for entry in os.scandir(self.root):
if entry.name.startswith(PART_PREFIX) and entry.name.endswith(PART_SUFFIX):
with contextlib.suppress(OSError):
if entry.stat(follow_symlinks=False).st_mtime < cutoff:
os.unlink(entry.path)
def exists(self, rel: str) -> bool:
return os.path.lexists(self._p(rel))
def free_bytes(self) -> int | None:
st = os.statvfs(self.root)
return st.f_bavail * st.f_frsize
def _ensure_parent(self, rel: str) -> None:
self._p(rel).parent.mkdir(mode=0o755, parents=True, exist_ok=True)
def _upload(self, src: Path, tmp: str, size: int, token: CancelToken) -> list[str]:
written = 0
fd = os.open(self._p(tmp), os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600)
try:
with src.open("rb") as fin:
while chunk := fin.read(CHUNK):
token.check()
view = memoryview(chunk)
while view:
n = os.write(fd, view)
view = view[n:]
written += len(chunk)
if written != size:
raise SourceChanged(
"Quelle hat sich während der Übertragung geändert "
f"({written} statt {size} Bytes)"
)
os.fsync(fd)
if os.fstat(fd).st_size != size:
raise TransferError("Größe der Zieldatei stimmt nach dem Schreiben nicht")
finally:
os.close(fd)
return []
def _apply_meta(self, rel: str, meta: MetaOptions) -> list[str]:
notes: list[str] = []
path = self._p(rel)
if self.kind != "cifs": # auf CIFS bestimmen die Mount-Optionen Eigentümer/Rechte
if meta.owner is not None:
try:
os.chown(path, *meta.owner)
except PermissionError:
notes.append(
f"Eigentümer {meta.owner[0]}:{meta.owner[1]} nicht gesetzt – keine "
"Berechtigung (läuft NetMover als root? Root-Squash?)"
)
except OSError as exc:
notes.append(f"Eigentümer nicht gesetzt: {exc.strerror}")
if meta.mode is not None:
try:
os.chmod(path, meta.mode)
except OSError as exc:
notes.append(f"Modus {meta.mode:04o} nicht gesetzt: {exc.strerror}")
try:
os.utime(path, ns=(meta.atime_ns, meta.mtime_ns))
except OSError as exc:
notes.append(f"Änderungszeit nicht übernommen: {exc.strerror}")
return notes
def _link_noreplace(self, tmp: str, final: str) -> None:
src, dst = self._p(tmp), self._p(final)
try:
os.link(src, dst)
except FileExistsError:
raise
except OSError as exc:
if exc.errno not in _NO_LINK_ERRNOS:
raise
# Dateisystem ohne Hardlinks (häufig CIFS): prüfen + rename.
# Kleines Wettlauf-Fenster, aber nie eine halbe Datei unter dem Endnamen.
if os.path.lexists(dst):
raise FileExistsError(errno.EEXIST, "exists", str(dst)) from None
os.rename(src, dst)
return
with contextlib.suppress(OSError):
os.unlink(src) # Rest wird sonst später als alte .part-Datei aufgeräumt
def _replace(self, tmp: str, final: str) -> list[str]:
os.replace(self._p(tmp), self._p(final))
return []
def _remove(self, rel: str) -> None:
with contextlib.suppress(FileNotFoundError):
os.unlink(self._p(rel))
def _sync_dir(self, rel_dir: str) -> None:
with contextlib.suppress(OSError):
fd = os.open(self._p(rel_dir) if rel_dir else self.root, os.O_RDONLY | os.O_DIRECTORY)
try:
os.fsync(fd)
finally:
os.close(fd)
def move_same_device(
self, src: Path, rel: str, policy: CollisionPolicy, meta: MetaOptions
) -> CommitResult | None:
try:
same = os.stat(src).st_dev == os.stat(self.root).st_dev
except OSError:
return None
if not same:
return None
self._ensure_parent(rel)
consumed = True
if policy is CollisionPolicy.OVERWRITE:
os.replace(src, self._p(rel))
final = rel
else:
final = ""
candidates = (
collision_candidates(rel) if policy is CollisionPolicy.RENAME else iter([rel])
)
for cand in candidates:
if self.exists(cand):
if policy is CollisionPolicy.SKIP:
raise CollisionSkip
continue
try:
os.link(src, self._p(cand))
except FileExistsError:
if policy is CollisionPolicy.SKIP:
raise CollisionSkip from None
continue
except OSError as exc:
if exc.errno not in _NO_LINK_ERRNOS:
raise
if self.exists(cand):
continue
os.rename(src, self._p(cand))
final = cand
break
# Ziel ist vollständig da; den alten Namen entfernt der Aufrufer –
# mit derselben Fehlerbehandlung wie nach einer Kopie.
consumed = False
final = cand
break
if not final:
raise TransferError(f"Kein freier Dateiname für {rel}")
notes = self._apply_meta(final, meta)
self._sync_dir(posixpath.dirname(final))
return CommitResult(final, notes, source_consumed=consumed)
class SftpDestination(Destination):
kind: FsKind = "sftp"
def __init__(self, session: SftpSession, base: str, label: str = "") -> None:
self.session = session
self.client = session.client
self.base = base
self.label = label
def _p(self, rel: str) -> str:
return posixpath.join(self.base, rel) if rel else self.base
def describe(self, rel: str) -> str:
prefix = f"{self.label}:" if self.label else "sftp:"
return prefix + self._p(rel)
def close(self) -> None:
self.session.close()
def prepare(self) -> None:
try:
attrs = self.client.stat(self.base)
except OSError as exc:
raise TransferError(f"SFTP-Zielordner {self.base}: {exc}") from None
if attrs.st_mode is None or not stat.S_ISDIR(attrs.st_mode):
raise TransferError(f"SFTP-Ziel {self.base} ist kein Verzeichnis")
cutoff = time.time() - STALE_PART_SECONDS
for a in self.client.listdir_attr(self.base):
is_part = a.filename.startswith(PART_PREFIX) and a.filename.endswith(PART_SUFFIX)
if is_part and a.st_mtime is not None and a.st_mtime < cutoff:
with contextlib.suppress(OSError):
self.client.remove(self._p(a.filename))
def exists(self, rel: str) -> bool:
try:
self.client.lstat(self._p(rel))
except FileNotFoundError:
return False
return True
def free_bytes(self) -> int | None:
usage = self.session.statvfs(self.base)
return usage[1] if usage else None
def _ensure_parent(self, rel: str) -> None:
parent = posixpath.dirname(rel)
if not parent:
return
current = self.base
for part in parent.split("/"):
current = posixpath.join(current, part)
try:
self.client.stat(current)
except FileNotFoundError:
self.client.mkdir(current, mode=0o755)
def _upload(self, src: Path, tmp: str, size: int, token: CancelToken) -> list[str]:
notes: list[str] = []
written = 0
with self.client.open(self._p(tmp), "wb") as fh, src.open("rb") as fin:
fh.set_pipelined(True)
while chunk := fin.read(CHUNK // 4):
token.check()
fh.write(chunk)
written += len(chunk)
fh.flush()
if written != size:
raise SourceChanged(
"Quelle hat sich während der Übertragung geändert "
f"({written} statt {size} Bytes)"
)
if not self.session.fsync(fh):
notes.append("SFTP-Server unterstützt kein fsync")
remote = self.client.stat(self._p(tmp)).st_size
if remote != size:
raise TransferError(f"Zieldatei hat {remote} statt {size} Bytes")
return notes
def _apply_meta(self, rel: str, meta: MetaOptions) -> list[str]:
notes: list[str] = []
path = self._p(rel)
if meta.owner is not None:
try:
self.client.chown(path, *meta.owner)
except OSError as exc:
notes.append(f"Eigentümer per SFTP nicht gesetzt: {exc}")
if meta.mode is not None:
try:
self.client.chmod(path, meta.mode)
except OSError as exc:
notes.append(f"Modus per SFTP nicht gesetzt: {exc}")
try:
self.client.utime(path, (meta.atime_ns / 1e9, meta.mtime_ns / 1e9))
except OSError as exc:
notes.append(f"Änderungszeit nicht übernommen: {exc}")
return notes
def _link_noreplace(self, tmp: str, final: str) -> None:
if self.exists(final):
raise FileExistsError(errno.EEXIST, "exists", final)
try:
# SFTPv3-rename überschreibt nicht (OpenSSH: link + unlink).
self.client.rename(self._p(tmp), self._p(final))
except OSError:
if self.exists(final):
raise FileExistsError(errno.EEXIST, "exists", final) from None
raise
def _replace(self, tmp: str, final: str) -> list[str]:
try:
self.client.posix_rename(self._p(tmp), self._p(final))
except OSError:
# Server ohne posix-rename: löschen + umbenennen (kurz kein Ziel, nie halb)
with contextlib.suppress(FileNotFoundError):
self.client.remove(self._p(final))
self.client.rename(self._p(tmp), self._p(final))
return ["Server kann nicht atomar überschreiben – Ziel kurz nicht vorhanden"]
return []
def _remove(self, rel: str) -> None:
with contextlib.suppress(FileNotFoundError):
self.client.remove(self._p(rel))
+434
View File
@@ -0,0 +1,434 @@
"""Ablauf eines Regellaufs – ohne Scheduler, ohne Datenbank-Protokoll.
Reihenfolge pro Lauf:
1. Ziel vorbereiten, Quelle scannen (Filter, versteckte/unfertige Dateien raus),
2. Kopier-Buchführung: bereits übertragene Dateiversionen still auslassen,
3. Größe und Mindestalter prüfen,
4. Stabilität: nach dem Stabilitätsfenster erneut messen – nur Dateien mit
unveränderter Größe **und** mtime gehen weiter (Fallstrick 1),
5. pro Datei: ggf. Quarantäne, Kollisionsregel, Platzprüfung (Größe + 5 %,
Fallstrick 14), atomare Übertragung, erst dann Quelle löschen.
"""
from __future__ import annotations
import errno
import os
import stat
import time
from collections.abc import Callable
from dataclasses import dataclass, field
from pathlib import Path
from netmover.logging_setup import event, get_logger
from netmover.models import (
CollisionPolicy,
ItemStatus,
PatternType,
RuleAction,
RunStatus,
)
from netmover.mover.destinations import (
CollisionSkip,
Destination,
LocalDestination,
MetaOptions,
SourceChanged,
TransferError,
)
from netmover.mover.ledger import FailureInfo, Ledger
from netmover.mover.naming import collision_candidates
from netmover.mover.scan import Candidate, FileState, Matcher, current_state, scan
from netmover.timeouts import Cancelled, CancelToken
log = get_logger(__name__)
SPACE_RESERVE = 1.05
# Fehler, die auf eine gestörte Anbindung hindeuten: Lauf abbrechen statt jede
# weitere Datei scheitern zu lassen – der nächste Lauf prüft und hängt neu ein.
MOUNT_ERRNOS = frozenset({errno.ESTALE, errno.EIO, errno.ENOTCONN, errno.EHOSTDOWN})
class RunAborted(Exception):
pass
@dataclass(frozen=True)
class RuleSnapshot:
id: int
name: str
action: RuleAction = RuleAction.MOVE
on_collision: CollisionPolicy = CollisionPolicy.RENAME
pattern: str = "*"
pattern_type: PatternType = PatternType.GLOB
recursive: bool = False
stability_seconds: int = 5
min_age_seconds: int = 0
max_size_mb: int | None = None
set_owner: tuple[int, int] | None = None
set_mode: int | None = None
quarantine_after: int = 3
dst_root_squash: bool | None = None # nur NFS-Ziele: Ergebnis des Schreibtests
@dataclass
class ItemResult:
src: str
status: ItemStatus
dst: str | None = None
size: int | None = None
message: str | None = None
@dataclass
class RunResult:
status: RunStatus = RunStatus.OK
dry_run: bool = False
seen: int = 0
moved: int = 0
skipped: int = 0
failed: int = 0
message: str | None = None
items: list[ItemResult] = field(default_factory=list)
def add(self, item: ItemResult) -> None:
self.items.append(item)
if item.status in (ItemStatus.MOVED, ItemStatus.COPIED, ItemStatus.PLANNED):
self.moved += 1
elif item.status in (ItemStatus.SKIPPED, ItemStatus.QUARANTINED):
self.skipped += 1
else:
self.failed += 1
def _describe_os_error(exc: OSError) -> str:
where = f" ({exc.filename})" if exc.filename else ""
return f"{exc.strerror or exc}{where}"
def _mb(n: int) -> str:
return f"{n / 1024 / 1024:.1f} MB"
class Engine:
def __init__(
self,
ledger: Ledger,
*,
sleep: Callable[[float], None] = time.sleep,
clock: Callable[[], float] = time.time,
warn_once: Callable[[str], bool] | None = None,
) -> None:
self.ledger = ledger
self._sleep = sleep
self._clock = clock
self._warned: set[str] = set()
self._warn_once = warn_once or self._default_warn_once
def _default_warn_once(self, key: str) -> bool:
if key in self._warned:
return False
self._warned.add(key)
return True
# -- Lauf -------------------------------------------------------------------------
def execute(
self,
rule: RuleSnapshot,
src_root: Path,
dest: Destination,
token: CancelToken,
*,
dry_run: bool = False,
quarantine_root: Path | None = None,
) -> RunResult:
result = RunResult(dry_run=dry_run)
try:
self._execute(rule, src_root, dest, token, result, dry_run, quarantine_root)
except Cancelled:
result.status = RunStatus.ERROR
result.message = "Lauf abgebrochen"
except RunAborted as exc:
result.status = RunStatus.ERROR
result.message = str(exc)
except TransferError as exc:
result.status = RunStatus.ERROR
result.message = str(exc)
except OSError as exc:
result.status = RunStatus.ERROR
result.message = _describe_os_error(exc)
else:
if result.failed:
result.status = RunStatus.PARTIAL
return result
def _execute(
self,
rule: RuleSnapshot,
src_root: Path,
dest: Destination,
token: CancelToken,
result: RunResult,
dry_run: bool,
quarantine_root: Path | None,
) -> None:
if not src_root.is_dir():
raise RunAborted(f"Quellordner {src_root} existiert nicht")
if not dry_run:
dest.prepare()
token.check()
exclude: set[Path] = set()
if quarantine_root is not None:
exclude.add(quarantine_root.resolve())
if isinstance(dest, LocalDestination):
exclude.add(dest.root.resolve()) # Ziel innerhalb der Quelle nie erneut scannen
matcher = Matcher(rule.pattern, rule.pattern_type)
candidates = list(scan(src_root, matcher, rule.recursive, frozenset(exclude)))
result.seen = len(candidates)
token.check()
# Kopier-Buchführung (Fallstrick 11). Gilt auch für „verschieben“, falls
# dort einmal die Quelle nicht gelöscht werden konnte.
done = self.ledger.copied_keys(rule.id)
if not dry_run:
self.ledger.prune_copied(rule.id, {c.relpath for c in candidates})
fresh = [c for c in candidates if (c.relpath, c.state.size, c.state.mtime_ns) not in done]
unchanged = len(candidates) - len(fresh)
pending: list[Candidate] = []
now = self._clock()
for c in fresh:
if rule.max_size_mb is not None and c.state.size > rule.max_size_mb * 1024 * 1024:
result.add(
ItemResult(
c.relpath,
ItemStatus.SKIPPED,
size=c.state.size,
message=f"größer als {rule.max_size_mb} MB",
)
)
elif rule.min_age_seconds and now - c.state.mtime_ns / 1e9 < rule.min_age_seconds:
result.add(
ItemResult(
c.relpath,
ItemStatus.SKIPPED,
size=c.state.size,
message=f"jünger als {rule.min_age_seconds} s",
)
)
else:
pending.append(c)
# Stabilität (Fallstrick 1): zweite Messung nach dem Stabilitätsfenster.
if pending and rule.stability_seconds > 0:
self._sleep(rule.stability_seconds)
token.check()
stable: list[Candidate] = []
for c in pending:
st = current_state(c.path)
if st is None:
continue # inzwischen verschwunden – nichts zu tun
if st != c.state:
result.add(
ItemResult(
c.relpath,
ItemStatus.SKIPPED,
size=st.size,
message="wird noch geschrieben (Größe/Zeit geändert)",
)
)
continue
stable.append(c)
failures = self.ledger.failures(rule.id)
for c in stable:
token.check()
result.add(self._process(rule, c, dest, token, dry_run, quarantine_root, failures))
if unchanged:
result.message = f"{unchanged} bereits übertragen"
# -- einzelne Datei ------------------------------------------------------------------
def _meta(
self, rule: RuleSnapshot, path: Path, dest: Destination
) -> tuple[MetaOptions, list[str]]:
st = os.stat(path)
notes: list[str] = []
owner = rule.set_owner
if owner is not None:
if dest.kind == "cifs":
owner = None
if self._warn_once(f"{rule.id}:cifs-chown"):
notes.append(
"CIFS-Ziel: Eigentümer bestimmen die Mount-Optionen, chown übersprungen"
)
elif dest.kind == "nfs" and rule.dst_root_squash is not False:
owner = None
if self._warn_once(f"{rule.id}:nfs-chown"):
reason = "Root-Squash aktiv" if rule.dst_root_squash else "Schreibtest fehlt"
notes.append(f"NFS-Ziel: chown übersprungen ({reason})")
mode = rule.set_mode if rule.set_mode is not None else stat.S_IMODE(st.st_mode)
return MetaOptions(st.st_atime_ns, st.st_mtime_ns, mode, owner), notes
def _process(
self,
rule: RuleSnapshot,
c: Candidate,
dest: Destination,
token: CancelToken,
dry_run: bool,
quarantine_root: Path | None,
failures: dict[str, FailureInfo],
) -> ItemResult:
size = c.state.size
failure = failures.get(c.relpath)
# Quarantäne für wiederholt scheiternde Dateien
if (
quarantine_root is not None
and failure is not None
and failure.attempts >= rule.quarantine_after
and (failure.size, failure.mtime_ns) == (c.state.size, c.state.mtime_ns)
):
if dry_run:
return ItemResult(
c.relpath,
ItemStatus.PLANNED,
str(quarantine_root / c.relpath),
size,
"würde in Quarantäne verschoben",
)
return self._quarantine(rule, c, quarantine_root, failure.attempts)
try:
planned = dest.plan(c.relpath, rule.on_collision)
except OSError as exc:
return self._fail(rule, c, exc, dry_run)
if planned is None:
return ItemResult(
c.relpath,
ItemStatus.SKIPPED,
dest.describe(c.relpath),
size,
"existiert bereits im Ziel",
)
if dry_run:
msg = (
"würde überschreiben"
if (rule.on_collision is CollisionPolicy.OVERWRITE and dest.exists(planned))
else None
)
verb = "kopieren" if rule.action is RuleAction.COPY else "verschieben"
return ItemResult(
c.relpath, ItemStatus.PLANNED, dest.describe(planned), size, msg or f"würde {verb}"
)
try:
free = dest.free_bytes()
except OSError:
free = None
if free is not None and free < size * SPACE_RESERVE:
return ItemResult(
c.relpath,
ItemStatus.FAILED,
dest.describe(planned),
size,
f"zu wenig Platz am Ziel (frei {_mb(free)}, "
f"benötigt {_mb(int(size * SPACE_RESERVE))})",
)
def verify() -> None:
if current_state(c.path) != c.state:
raise SourceChanged("Quelle hat sich während der Übertragung geändert")
try:
meta, notes = self._meta(rule, c.path, dest)
res = None
if rule.action is RuleAction.MOVE:
res = dest.move_same_device(c.path, c.relpath, rule.on_collision, meta)
if res is None:
res = dest.commit(c.path, size, c.relpath, rule.on_collision, meta, verify, token)
except CollisionSkip:
return ItemResult(
c.relpath,
ItemStatus.SKIPPED,
dest.describe(c.relpath),
size,
"existiert bereits im Ziel",
)
except SourceChanged as exc:
return ItemResult(c.relpath, ItemStatus.SKIPPED, None, size, str(exc))
except (TransferError, OSError) as exc:
return self._fail(rule, c, exc, dry_run)
notes += res.notes
target = dest.describe(res.final_rel)
if c.relpath in failures:
self.ledger.clear_failure(rule.id, c.relpath)
if rule.action is RuleAction.COPY:
self.ledger.record_copy(rule.id, c.relpath, size, c.state.mtime_ns, target)
return ItemResult(c.relpath, ItemStatus.COPIED, target, size, "; ".join(notes) or None)
if not res.source_consumed:
try:
os.unlink(c.path)
except OSError as exc:
# Ziel ist vollständig – Buchführung verhindert Duplikate im nächsten Lauf.
self.ledger.record_copy(rule.id, c.relpath, size, c.state.mtime_ns, target)
return ItemResult(
c.relpath,
ItemStatus.FAILED,
target,
size,
f"Ziel geschrieben, aber Quelle nicht löschbar: {_describe_os_error(exc)} – "
"Datei wird nicht erneut übertragen",
)
return ItemResult(c.relpath, ItemStatus.MOVED, target, size, "; ".join(notes) or None)
def _fail(self, rule: RuleSnapshot, c: Candidate, exc: Exception, dry_run: bool) -> ItemResult:
if isinstance(exc, OSError) and exc.errno in MOUNT_ERRNOS:
raise RunAborted(f"Anbindung gestört: {_describe_os_error(exc)}") from exc
msg = _describe_os_error(exc) if isinstance(exc, OSError) else str(exc)
if not dry_run:
attempts = self.ledger.record_failure(
rule.id, c.relpath, c.state.size, c.state.mtime_ns, msg
)
msg = f"{msg} (Versuch {attempts})"
event(log, "file.failed", level=30, rule=rule.name, file=c.relpath, error=msg)
return ItemResult(c.relpath, ItemStatus.FAILED, None, c.state.size, msg)
def _quarantine(
self, rule: RuleSnapshot, c: Candidate, root: Path, attempts: int
) -> ItemResult:
try:
for cand in collision_candidates(c.relpath):
target = root / cand
if os.path.lexists(target):
continue
target.parent.mkdir(mode=0o755, parents=True, exist_ok=True)
os.rename(c.path, target)
break
else:
raise TransferError("kein freier Name im Quarantäneordner")
except (OSError, TransferError) as exc:
msg = _describe_os_error(exc) if isinstance(exc, OSError) else str(exc)
return ItemResult(
c.relpath, ItemStatus.FAILED, None, c.state.size, f"Quarantäne gescheitert: {msg}"
)
self.ledger.clear_failure(rule.id, c.relpath)
event(log, "file.quarantined", level=30, rule=rule.name, file=c.relpath)
return ItemResult(
c.relpath,
ItemStatus.QUARANTINED,
str(target),
c.state.size,
f"nach {attempts} Fehlversuchen in Quarantäne verschoben",
)
__all__ = ["Engine", "FileState", "ItemResult", "RuleSnapshot", "RunAborted", "RunResult"]