Compare commits
7
Commits
e1a917b974
...
v0.1.1
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2d2238ce4e | ||
|
|
4f9c5bdc1d | ||
|
|
1bf0fae2fc | ||
|
|
ea50eacc9d | ||
|
|
c836f58189 | ||
|
|
8276ce2afb | ||
|
|
48a52c5290 |
+31
-1
@@ -8,6 +8,35 @@ Die Version steht an genau einer Stelle: `src/netmover/__init__.py`.
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [0.1.1] – 2026-10-10
|
||||
|
||||
Korrekturen aus dem ersten Praxistest auf einer Debian-13-VM mit echten
|
||||
SMB-, NFS- und SFTP-Servern.
|
||||
|
||||
### Behoben
|
||||
- **systemd-Unit:** `ProtectSystem`, `PrivateTmp` und `ProtectControlGroups`
|
||||
gaben dem Dienst einen eigenen Mount-Namespace. Die Mounts waren für das System
|
||||
und andere Programme unsichtbar und verschwanden bei jedem Neustart des Dienstes
|
||||
(`MountFlags=shared` half nicht). Jetzt nur noch Härtungen ohne Namespace.
|
||||
- **Stabilitätsprüfung:** CIFS lieferte per `stat()` sekundenlang eine veraltete
|
||||
Größe einer noch wachsenden Datei. Gemessen wird jetzt per `open()` + `fstat()`,
|
||||
das erzwingt frische Attribute. Gesperrte Dateien gelten als „in Benutzung“.
|
||||
(Die zweite Sicherung, die Byte-Zählung beim Kopieren, hatte den Fall bereits
|
||||
abgefangen; es kam keine unvollständige Datei an.)
|
||||
- **Ausfall länger als die Wiederherstellung (z. B. NAS-Neustart, Stromausfall):**
|
||||
Von NetMover selbst ausgehängte Anbindungen mit „automatisch einhängen“ werden
|
||||
jetzt jede Minute erneut eingehängt, auch wenn das Einhängen schon beim
|
||||
Dienststart scheiterte. Entwarnung per Webhook. Von außen ausgehängte Anbindungen
|
||||
bleiben weiterhin unangetastet.
|
||||
- Bei einer Zeitüberschreitung von `mount` wird die ganze Prozessgruppe beendet, damit
|
||||
`mount.nfs` nicht später doch noch einhängt.
|
||||
- Hinweistexte zu `mount`-Fehlern werden nicht mehr mitgeschwärzt.
|
||||
- `install.sh` ohne `debconf`-Warnungen, wenn kein Terminal angeschlossen ist.
|
||||
|
||||
### Dokumentation
|
||||
- Fehlersuche: verzögert sichtbare Dateien auf CIFS → `nohandlecache`.
|
||||
- README erklärt, warum die Unit keine Namespace-Härtung nutzt.
|
||||
|
||||
## [0.1.0] – 2026-10-10
|
||||
|
||||
Erste Version.
|
||||
@@ -99,5 +128,6 @@ Erste Version.
|
||||
- Löschen von Zugangsdaten, die noch von einem Mount benutzt werden, trennt den
|
||||
Mount nicht mehr stillschweigend ab, sondern wird von der Datenbank abgelehnt.
|
||||
|
||||
[Unreleased]: https://git.wilde-server.de/florian.wilde/netmover/compare/v0.1.0...HEAD
|
||||
[Unreleased]: https://git.wilde-server.de/florian.wilde/netmover/compare/v0.1.1...HEAD
|
||||
[0.1.1]: https://git.wilde-server.de/florian.wilde/netmover/compare/v0.1.0...v0.1.1
|
||||
[0.1.0]: https://git.wilde-server.de/florian.wilde/netmover/releases/tag/v0.1.0
|
||||
|
||||
@@ -13,10 +13,55 @@ Zeiten in Ortszeit (Europe/Berlin).
|
||||
| 2026-10-10 | 19:10 | 19:17 | 0:07 | 4 | Scheduler, Protokoll, Benachrichtigung |
|
||||
| 2026-10-10 | 19:18 | 19:34 | 0:16 | 5 | Weboberfläche und Anmeldung |
|
||||
| 2026-10-10 | 19:40 | 19:50 | 0:10 | 6 | Deployment, README, Release 0.1.0 |
|
||||
| **Summe** | | | **1:06** | | |
|
||||
| 2026-10-10 | 21:36 | 22:05 | 0:29 | – | Praxistest auf VM, Release 0.1.1 |
|
||||
| **Summe** | | | **1:35** | | |
|
||||
|
||||
---
|
||||
|
||||
## 2026-10-10 · 21:36–22:05 · Praxistest auf der Debian-13-VM, Release 0.1.1
|
||||
|
||||
**Umgebung**
|
||||
- KVM-VM `srv-rz-netmover` (10.0.10.223), Debian 13, Python 3.13, 2 GB RAM.
|
||||
- Installation per `deploy/install.sh` aus einem `git archive` (VM ohne Gitea-Zugang).
|
||||
- Vorübergehende Testserver auf der VM: Samba (nur `lo`), NFS-Exporte für
|
||||
`127.0.0.1` (mit/ohne Root-Squash), SFTP-Benutzer `paperless` mit Schlüssel
|
||||
`restrict,command="/usr/lib/openssh/sftp-server"`.
|
||||
- Auf Wunsch `NETMOVER_HOST=0.0.0.0`, damit die Oberfläche im LAN erreichbar ist.
|
||||
|
||||
**Bestanden**
|
||||
- Ersteinrichtung mit Code aus dem Log, Cookie-Flags.
|
||||
- CIFS (Passwort mit Komma/Ausrufezeichen, Credentials-Datei danach weg), NFS 4,
|
||||
NFS 3, Root-Squash-Erkennung mit Synology-Hinweis, SFTP mit TOFU (Fingerabdruck
|
||||
= `ssh-keygen -lf`), echtes OpenSSH liefert `statvfs`.
|
||||
- Probelauf, Kollision `scan001-1.pdf`, `.txt`/`.part` ignoriert, keine `.part`-Reste,
|
||||
Eigentümer `paperless` im SFTP-Ziel.
|
||||
- Langsam wachsende Datei (20 × 50 KB/s): erst nach Ende verschoben, Prüfsumme gleich.
|
||||
- Kopiermodus rekursiv mit Eigentümer/Modus; zweiter Lauf „2 bereits übertragen“.
|
||||
- Samba gestoppt: Lebenszeichen 15 s → gestört → 3 Versuche → Fehler, `/healthz` 503,
|
||||
nichts angefasst. NFS-Server gestoppt (hängender `hard`-Mount): Oberfläche und
|
||||
andere Regel liefen weiter, Rückkehr automatisch.
|
||||
- Mounts überstehen Stopp/Start des Dienstes (gleiche Mount-IDs).
|
||||
- Neustart der VM: NFS sofort, SMB per Neuversuch nach ~1 min, Regel läuft weiter.
|
||||
|
||||
**Gefunden und behoben (v0.1.1)**
|
||||
1. **systemd-Unit mit eigenem Mount-Namespace** (ProtectSystem/PrivateTmp/…):
|
||||
Mounts nur im Dienst sichtbar, `shared,slave`. Härtung ohne Namespace.
|
||||
2. **CIFS-Attribut-Cache**: `stat()` meldete 3 s lang eine veraltete Größe einer
|
||||
wachsenden Datei; die Byte-Zählung beim Kopieren fing das ab. Jetzt
|
||||
`open()` + `fstat()` für beide Messungen.
|
||||
3. **Nach drei gescheiterten Versuchen endgültig aufgegeben** – zu kurz für einen
|
||||
NAS-Neustart, und beim Booten vor dem NAS kam die Freigabe nie zurück.
|
||||
Jetzt minütlicher Neuversuch (nur für von NetMover selbst ausgehängte
|
||||
Anbindungen mit `auto_mount`) plus Scheduler-Job.
|
||||
4. `mount`-Zeitlimit beendete nur `mount`, nicht `mount.nfs` → später doch
|
||||
eingehängt. Jetzt ganze Prozessgruppe.
|
||||
5. Hinweistext wurde mitgeschwärzt; `debconf`-Warnungen in `install.sh`.
|
||||
|
||||
**Nicht nachstellbar**
|
||||
- Einmalig 46 s Verzögerung direkt nach dem Booten (CIFS-Verzeichnis-Cache bei
|
||||
lokal statt per SMB angelegter Datei). Messungen danach 9–11 s, mit und ohne
|
||||
`nohandlecache`. Als Hinweis in die Fehlersuche aufgenommen.
|
||||
|
||||
## 2026-10-10 · 19:40–19:50 · Schritt 6: Deployment, README, Release 0.1.0
|
||||
|
||||
**Erledigt**
|
||||
|
||||
@@ -69,13 +69,18 @@ Was wo liegt:
|
||||
|
||||
**Warum als root?** Ein- und Aushängen braucht `CAP_SYS_ADMIN`. Außerdem soll
|
||||
NetMover Dateien auf beliebigen Freigaben lesen, löschen und mit den richtigen
|
||||
Eigentümern anlegen können. Die Unit härtet den Dienst so weit, wie es sich mit
|
||||
Mounten verträgt: `ProtectSystem=full`, `PrivateTmp`, `NoNewPrivileges`, Zustand nur
|
||||
für root lesbar. Weil diese Optionen einen eigenen Mount-Namespace erzeugen, steht
|
||||
`MountFlags=shared` in der Unit. Nur so sieht das System die Mounts:
|
||||
Eigentümern anlegen können.
|
||||
|
||||
**Warum so wenig systemd-Härtung?** Optionen wie `ProtectSystem=`, `PrivateTmp=`
|
||||
oder `ProtectHome=` geben dem Dienst einen eigenen Mount-Namespace. Die Mounts
|
||||
landeten dann *nur dort*: Das System und Paperless sähen sie nicht, und jeder
|
||||
Neustart des Dienstes würde sie verwerfen. `MountFlags=shared` hilft dagegen nicht.
|
||||
Auf der Test-VM nachgewiesen. Die Unit nutzt deshalb nur Härtungen ohne eigenen
|
||||
Namespace (`NoNewPrivileges`, `RestrictSUIDSGID`, `LockPersonality`,
|
||||
`RestrictRealtime`); Datenbank und Schlüssel sind nur für root lesbar. Prüfen:
|
||||
|
||||
```bash
|
||||
findmnt -R /mnt/netmover # sollte die eingehängten Freigaben zeigen
|
||||
findmnt -l | grep /mnt/netmover # die eingehängten Freigaben müssen hier stehen
|
||||
```
|
||||
|
||||
**Beim Beenden bleiben die Mounts eingehängt**, weil andere Programme sie benutzen
|
||||
@@ -330,10 +335,12 @@ findmnt -R /mnt/netmover # was ist wirklich eingehängt?
|
||||
| „… ist nicht leer“ beim Einhängen | Im Mountpoint liegen Dateien, die geschrieben wurden, als nichts eingehängt war. Inhalt prüfen und sichern, Verzeichnis leeren, erneut einhängen. |
|
||||
| „nicht eingehängt – Lauf abgebrochen“ | Die Freigabe ist weg (z. B. von Hand ausgehängt). Auf der Anbindungsseite **Einhängen**. NetMover hängt verschwundene Mounts absichtlich nicht selbst neu ein. |
|
||||
| „gestört (ESTALE …)“, danach wieder OK | NAS-Neustart; NetMover hat neu eingehängt. Bleibt der Fehler: Netzwerk/NAS prüfen. |
|
||||
| „weiterhin nicht verfügbar – neuer Versuch in 60 s“ | Das NAS ist (noch) weg, z. B. während eines DSM-Updates. Bei Anbindungen mit „automatisch einhängen“ versucht NetMover es jede Minute erneut und meldet per Webhook, wenn es wieder klappt. |
|
||||
| „Keine Reaktion seit 120 s“ | Ein Dateizugriff hängt (meist NFS mit `hard`). Die Regel bleibt blockiert („läuft seit …“), bis der Zugriff zurückkehrt; die Anbindung wird vor dem nächsten Lauf geprüft. |
|
||||
| „Hostschlüssel … hat sich geändert“ (SFTP) | Wurde der Zielserver neu aufgesetzt? Dann bei der Anbindung „Hostschlüssel zurücksetzen“ und neu testen. Sonst **nicht** verbinden. |
|
||||
| SFTP: „Anmeldung abgelehnt“ | Öffentlicher Schlüssel fehlt in `authorized_keys`, oder Rechte von `~/.ssh` stimmen nicht (700/600, Eigentümer). |
|
||||
| Paperless bemerkt neue Dateien nicht | Ältere Paperless-Versionen: `PAPERLESS_CONSUMER_POLLING=10` setzen. |
|
||||
| Neue Dateien erscheinen erst nach bis zu 30 s | Der CIFS-Client speichert Verzeichnislisten zwischen. Gelangen Dateien nicht per SMB aufs NAS (z. B. per FTP vom Scanner), erfährt er nichts davon. Abhilfe: Zusatzoption `nohandlecache`. |
|
||||
| Regel gelb „länger als 3 × Intervall“ | Seit dem Dreifachen des Intervalls kein erfolgreicher Lauf; Protokoll der Regel ansehen. |
|
||||
| Einrichtungscode verloren | `systemctl restart netmover` erzeugt einen neuen. |
|
||||
|
||||
|
||||
+1
-1
@@ -4,7 +4,7 @@
|
||||
services:
|
||||
netmover:
|
||||
build: .
|
||||
image: netmover:0.1.0
|
||||
image: netmover:0.1.1
|
||||
container_name: netmover
|
||||
restart: unless-stopped
|
||||
cap_add:
|
||||
|
||||
@@ -14,6 +14,7 @@ if [ "$(id -u)" -ne 0 ]; then
|
||||
fi
|
||||
|
||||
echo "==> Pakete"
|
||||
export DEBIAN_FRONTEND=noninteractive
|
||||
apt-get update -qq
|
||||
apt-get install -y -qq python3 python3-venv cifs-utils nfs-common >/dev/null
|
||||
|
||||
|
||||
@@ -27,18 +27,17 @@ RuntimeDirectory=netmover
|
||||
RuntimeDirectoryMode=0700
|
||||
|
||||
# Härtung, soweit mit Mounten vereinbar.
|
||||
# Die Optionen unten erzeugen einen eigenen Mount-Namespace für den Dienst.
|
||||
# MountFlags=shared sorgt dafür, dass Mounts unter /mnt/netmover trotzdem im
|
||||
# System sichtbar sind (prüfen mit: findmnt -R /mnt/netmover).
|
||||
MountFlags=shared
|
||||
# /usr, /boot und /etc schreibgeschützt; /mnt, /opt, /srv, /var bleiben schreibbar
|
||||
# (lokale Ziele wie /opt/paperless/consume funktionieren also).
|
||||
ProtectSystem=full
|
||||
PrivateTmp=yes
|
||||
# WICHTIG: Keine Optionen, die einen eigenen Mount-Namespace erzeugen
|
||||
# (ProtectSystem=, ProtectHome=, PrivateTmp=, ProtectControlGroups=,
|
||||
# ReadOnlyPaths=, BindPaths= …). Sonst landen die Mounts im Namespace des
|
||||
# Dienstes: das System und andere Programme (Paperless!) sähen sie nicht, und
|
||||
# jeder Neustart des Dienstes würde sie verwerfen. MountFlags=shared hilft dagegen
|
||||
# nicht – systemd setzt den Namespace als „slave“ auf, Mounts fließen nur hinein.
|
||||
# Auf dem Testsystem nachgewiesen; prüfen mit: findmnt -l | grep /mnt/netmover
|
||||
NoNewPrivileges=yes
|
||||
ProtectControlGroups=yes
|
||||
LockPersonality=yes
|
||||
RestrictRealtime=yes
|
||||
RestrictSUIDSGID=yes
|
||||
UMask=0022
|
||||
|
||||
[Install]
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
"""NetMover – NFS/SMB-Mounts verwalten und Dateien zeitgesteuert verschieben."""
|
||||
|
||||
__version__ = "0.1.0"
|
||||
__version__ = "0.1.1"
|
||||
|
||||
@@ -58,7 +58,9 @@ class NetMover:
|
||||
)
|
||||
self.runner = RuleRunner(self.session_factory, self.mounts)
|
||||
self.runs = RunService(self.session_factory, self.runner, self.notifier)
|
||||
self.scheduler = RuleScheduler(self.session_factory, self.runs)
|
||||
self.scheduler = RuleScheduler(
|
||||
self.session_factory, self.runs, retry_mounts=self.mounts.retry_pending
|
||||
)
|
||||
self.started_at: datetime = utcnow()
|
||||
self.restore_results: list[RestoreResult] = []
|
||||
|
||||
|
||||
@@ -8,7 +8,9 @@ Optionszeile – die stünde sonst in der Prozessliste.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import contextlib
|
||||
import os
|
||||
import signal
|
||||
import subprocess
|
||||
import uuid
|
||||
from collections.abc import Callable, Sequence
|
||||
@@ -24,6 +26,28 @@ log = get_logger(__name__)
|
||||
|
||||
Runner = Callable[..., "subprocess.CompletedProcess[str]"]
|
||||
|
||||
|
||||
def run_process_group(argv: list[str], **kwargs: Any) -> subprocess.CompletedProcess[str]:
|
||||
"""Wie ``subprocess.run``, aber in eigener Prozessgruppe: Bei Zeitüberschreitung
|
||||
wird die ganze Gruppe beendet. ``mount`` startet Hilfsprogramme wie
|
||||
``mount.nfs``; würde nur ``mount`` beendet, liefe ``mount.nfs`` weiter und
|
||||
hängte womöglich Minuten später doch noch ein (auf der Test-VM beobachtet)."""
|
||||
timeout = kwargs.pop("timeout", None)
|
||||
kwargs.pop("check", None)
|
||||
capture = kwargs.pop("capture_output", False)
|
||||
if capture:
|
||||
kwargs["stdout"] = kwargs["stderr"] = subprocess.PIPE
|
||||
with subprocess.Popen(argv, start_new_session=True, **kwargs) as proc:
|
||||
try:
|
||||
out, err = proc.communicate(timeout=timeout)
|
||||
except subprocess.TimeoutExpired:
|
||||
with contextlib.suppress(ProcessLookupError):
|
||||
os.killpg(proc.pid, signal.SIGKILL)
|
||||
proc.communicate()
|
||||
raise
|
||||
return subprocess.CompletedProcess(argv, proc.returncode, out, err)
|
||||
|
||||
|
||||
_ENV = {"LC_ALL": "C", "PATH": "/usr/sbin:/usr/bin:/sbin:/bin"}
|
||||
|
||||
|
||||
@@ -60,7 +84,7 @@ class LinuxMountBackend(MountBackend):
|
||||
self,
|
||||
run_dir: Path,
|
||||
mountinfo_path: Path = MOUNTINFO,
|
||||
runner: Runner = subprocess.run,
|
||||
runner: Runner = run_process_group,
|
||||
) -> None:
|
||||
self.run_dir = run_dir
|
||||
self.mountinfo_path = mountinfo_path
|
||||
|
||||
@@ -15,7 +15,9 @@ Fallstricke:
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import contextlib
|
||||
import errno
|
||||
import math
|
||||
import os
|
||||
import threading
|
||||
import time
|
||||
@@ -104,6 +106,13 @@ class RestoreResult:
|
||||
message: str
|
||||
|
||||
|
||||
def _redact_output(message: str, secrets: Sequence[str]) -> str:
|
||||
"""Schwärzt nur die Ausgabe von ``mount``, nicht den angehängten Hinweistext
|
||||
(sonst würde z. B. das Passwort „falsch“ auch im Hinweis geschwärzt)."""
|
||||
output, sep, hint = message.partition("\n→ ")
|
||||
return redact(output, secrets) + sep + hint
|
||||
|
||||
|
||||
def build_spec(cfg: MountConfig, mountpoint: Path, cred: CredentialData | None) -> MountSpec:
|
||||
"""Baut den ``mount``-Aufruf. Validiert erneut (Verteidigung in der Tiefe) –
|
||||
auch Daten aus der Datenbank laufen hier noch einmal durch die Prüfung."""
|
||||
@@ -166,6 +175,8 @@ class MountManager:
|
||||
sleep: Callable[[float], None] = time.sleep,
|
||||
euid: Callable[[], int] = os.geteuid,
|
||||
notifier: Notifier | None = None,
|
||||
retry_interval: float = 60,
|
||||
clock: Callable[[], float] = time.monotonic,
|
||||
) -> None:
|
||||
self.session_factory = session_factory
|
||||
self.backend = backend
|
||||
@@ -177,6 +188,12 @@ class MountManager:
|
||||
self._sleep = sleep
|
||||
self._euid = euid
|
||||
self._notifier = notifier
|
||||
self.retry_interval = retry_interval
|
||||
self._clock = clock
|
||||
# Mounts, die NetMover nach gescheiterter Wiederherstellung selbst ausgehängt
|
||||
# hat: nächster erlaubter Versuch (monotone Zeit). Nur diese werden
|
||||
# automatisch neu eingehängt – von außen ausgehängte nie (Fallstrick 2).
|
||||
self._retry_at: dict[int, float] = {}
|
||||
self._locks: dict[int, threading.RLock] = {}
|
||||
self._locks_guard = threading.Lock()
|
||||
|
||||
@@ -267,6 +284,7 @@ class MountManager:
|
||||
|
||||
def mount(self, mount_id: int) -> None:
|
||||
with self._lock(mount_id):
|
||||
self._retry_at.pop(mount_id, None)
|
||||
m, cred = self._load(mount_id)
|
||||
if not m.type.is_kernel_mount:
|
||||
raise MountError("SFTP-Anbindungen werden nicht eingehängt, nur getestet")
|
||||
@@ -292,6 +310,7 @@ class MountManager:
|
||||
|
||||
def unmount(self, mount_id: int) -> None:
|
||||
with self._lock(mount_id):
|
||||
self._retry_at.pop(mount_id, None)
|
||||
m, _ = self._load(mount_id)
|
||||
if not m.type.is_kernel_mount:
|
||||
self._set_state(mount_id, MountState.UNMOUNTED)
|
||||
@@ -325,6 +344,8 @@ class MountManager:
|
||||
self.mount(mount_id)
|
||||
results.append(RestoreResult(name, True, "eingehängt"))
|
||||
except (MountError, ValidationError, LookupError) as exc:
|
||||
# z. B. Stromausfall: die VM bootet schneller als das NAS
|
||||
self._retry_at[mount_id] = self._clock() + self.retry_interval
|
||||
results.append(RestoreResult(name, False, str(exc)))
|
||||
self._notify("mount.restore_failed", name, str(exc))
|
||||
for r in results:
|
||||
@@ -391,7 +412,7 @@ class MountManager:
|
||||
self.backend.mount(build_spec(cfg, temp, cred), timeout=self.mount_timeout)
|
||||
except (MountError, ValidationError) as exc:
|
||||
self._remove_mountpoint(temp)
|
||||
report.step("mount", False, redact(str(exc), secrets))
|
||||
report.step("mount", False, _redact_output(str(exc), secrets))
|
||||
report.step("read", None)
|
||||
report.step("write", None)
|
||||
return report
|
||||
@@ -501,6 +522,9 @@ class MountManager:
|
||||
return None
|
||||
mp = self.mountpoint_for(m.slug)
|
||||
if not self.backend.is_mounted(mp):
|
||||
if mount_id in self._retry_at:
|
||||
self._retry_mount(mount_id, m, cred, mp)
|
||||
return mp
|
||||
msg = f"„{m.name}“ ist nicht eingehängt – Lauf abgebrochen, nichts angefasst."
|
||||
self._set_state(mount_id, MountState.ERROR, msg)
|
||||
raise MountUnavailable(msg)
|
||||
@@ -568,10 +592,74 @@ class MountManager:
|
||||
f"„{m.name}“ gestört ({reason}); Neu-Einhängen nach "
|
||||
f"{len(self.recover_backoff)} Versuchen gescheitert: {last}"
|
||||
)
|
||||
if m.auto_mount:
|
||||
self._retry_at[mount_id] = self._clock() + self.retry_interval
|
||||
msg += f"\n→ Neuer Versuch automatisch alle {self.retry_interval:g} s."
|
||||
self._set_state(mount_id, MountState.ERROR, msg)
|
||||
self._notify("mount.error", m.name, msg)
|
||||
raise MountUnavailable(msg)
|
||||
|
||||
def retry_pending(self) -> list[str]:
|
||||
"""Fällige Neuversuche ausführen (Scheduler-Job, jede Minute) – auch für
|
||||
Anbindungen, die gerade keine Regel benutzt. Liefert die zurückgeholten Namen."""
|
||||
recovered: list[str] = []
|
||||
for mount_id in list(self._retry_at):
|
||||
if self._retry_at.get(mount_id, float("inf")) > self._clock():
|
||||
continue
|
||||
with self._lock(mount_id):
|
||||
if mount_id not in self._retry_at:
|
||||
continue
|
||||
try:
|
||||
m, cred = self._load(mount_id)
|
||||
except LookupError:
|
||||
self._retry_at.pop(mount_id, None)
|
||||
continue
|
||||
if not (m.enabled and m.auto_mount and m.type.is_kernel_mount):
|
||||
self._retry_at.pop(mount_id, None)
|
||||
continue
|
||||
mp = self.mountpoint_for(m.slug)
|
||||
if self.backend.is_mounted(mp):
|
||||
self._retry_at.pop(mount_id, None)
|
||||
continue
|
||||
try:
|
||||
self._retry_mount(mount_id, m, cred, mp)
|
||||
recovered.append(m.name)
|
||||
except MountUnavailable:
|
||||
pass
|
||||
return recovered
|
||||
|
||||
def _retry_mount(self, mount_id: int, m: Mount, cred: CredentialData | None, mp: Path) -> None:
|
||||
"""Ein Versuch, einen von NetMover selbst ausgehängten Mount zurückzuholen –
|
||||
höchstens einmal pro ``retry_interval``, damit kein Lauf alle 10 s hängt."""
|
||||
wait = self._retry_at[mount_id] - self._clock()
|
||||
if wait > 0:
|
||||
raise MountUnavailable(
|
||||
f"„{m.name}“ nicht verfügbar – nächster Versuch, neu einzuhängen, "
|
||||
f"in {math.ceil(wait)} s. Lauf abgebrochen, nichts angefasst."
|
||||
)
|
||||
try:
|
||||
self._prepare_mountpoint(mp)
|
||||
self.backend.mount(
|
||||
build_spec(MountConfig.from_model(m), mp, cred), timeout=self.mount_timeout
|
||||
)
|
||||
self._probe(m.name, mp)
|
||||
except (MountError, OSError, OperationTimeout, ValidationError) as exc:
|
||||
if self.backend.is_mounted(mp):
|
||||
with contextlib.suppress(MountError):
|
||||
self.backend.unmount(mp, lazy=True, timeout=self.mount_timeout)
|
||||
self._retry_at[mount_id] = self._clock() + self.retry_interval
|
||||
msg = (
|
||||
f"„{m.name}“ weiterhin nicht verfügbar: {exc}\n"
|
||||
f"→ Neuer Versuch in {self.retry_interval:g} s."
|
||||
)
|
||||
self._set_state(mount_id, MountState.ERROR, msg)
|
||||
event(log, "mount.retry_failed", level=30, mount=m.name, error=str(exc))
|
||||
raise MountUnavailable(msg) from None
|
||||
del self._retry_at[mount_id]
|
||||
self._set_state(mount_id, MountState.MOUNTED, mounted=True)
|
||||
event(log, "mount.recovered", mount=m.name, attempt="retry")
|
||||
self._notify("mount.recovered", m.name, f"„{m.name}“ ist wieder eingehängt.")
|
||||
|
||||
def usage(self, mount_id: int, timeout: float | None = None) -> tuple[int, int] | None:
|
||||
"""(gesamt, frei) in Bytes – nur bei eingehängten Kernel-Mounts."""
|
||||
m, _ = self._load(mount_id)
|
||||
|
||||
@@ -13,6 +13,7 @@ Reihenfolge pro Lauf:
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import dataclasses
|
||||
import errno
|
||||
import os
|
||||
import stat
|
||||
@@ -39,7 +40,7 @@ from netmover.mover.destinations import (
|
||||
)
|
||||
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.mover.scan import BUSY, Candidate, FileState, Matcher, current_state, scan
|
||||
from netmover.timeouts import Cancelled, CancelToken
|
||||
|
||||
log = get_logger(__name__)
|
||||
@@ -222,26 +223,32 @@ class Engine:
|
||||
else:
|
||||
pending.append(c)
|
||||
|
||||
# Stabilität (Fallstrick 1): zweite Messung nach dem Stabilitätsfenster.
|
||||
# Stabilität (Fallstrick 1): zwei frische Messungen (öffnen + fstat, nicht
|
||||
# der Verzeichnis-Cache) im Abstand des Stabilitätsfensters.
|
||||
first: dict[str, FileState | None] = {c.relpath: current_state(c.path) for c in pending}
|
||||
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:
|
||||
before, st = first[c.relpath], current_state(c.path)
|
||||
if before is None or st is None:
|
||||
continue # inzwischen verschwunden – nichts zu tun
|
||||
if st != c.state:
|
||||
if st != before or st == BUSY:
|
||||
result.add(
|
||||
ItemResult(
|
||||
c.relpath,
|
||||
ItemStatus.SKIPPED,
|
||||
size=st.size,
|
||||
message="wird noch geschrieben (Größe/Zeit geändert)",
|
||||
size=max(st.size, 0),
|
||||
message="wird noch geschrieben (Größe/Zeit geändert)"
|
||||
if st != BUSY
|
||||
else "Datei ist gerade in Benutzung",
|
||||
)
|
||||
)
|
||||
continue
|
||||
stable.append(c)
|
||||
# ab hier gilt der frisch gemessene Zustand (für Prüfung vor dem Umbenennen
|
||||
# und für die Kopier-Buchführung)
|
||||
stable.append(dataclasses.replace(c, state=st))
|
||||
|
||||
failures = self.ledger.failures(rule.id)
|
||||
for c in stable:
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import errno
|
||||
import fnmatch
|
||||
import os
|
||||
import re
|
||||
@@ -100,11 +101,31 @@ def scan(
|
||||
yield from walk(root, "")
|
||||
|
||||
|
||||
# Datei ist geöffnet, aber gerade nicht lesbar (z. B. exklusiv vom Schreiber
|
||||
# gesperrt): gilt als „noch nicht fertig“ – gleicht nie einem echten Zustand.
|
||||
BUSY = FileState(-1, -1)
|
||||
|
||||
|
||||
def current_state(path: Path) -> FileState | None:
|
||||
"""Aktuelle Größe und mtime – **frisch vom Server**.
|
||||
|
||||
``stat()`` beantworten CIFS- und NFS-Clients aus ihrem Attribut-Cache; im
|
||||
Praxistest lieferte CIFS so sekundenlang eine veraltete Größe, während die
|
||||
Datei noch wuchs. Erst ``open()`` erzwingt frische Attribute (NFS:
|
||||
Close-to-open-Konsistenz, SMB: Antwort auf CREATE). Darum öffnen + ``fstat``.
|
||||
"""
|
||||
try:
|
||||
st = os.stat(path, follow_symlinks=False)
|
||||
fd = os.open(path, os.O_RDONLY | os.O_NOFOLLOW | os.O_NONBLOCK)
|
||||
except FileNotFoundError:
|
||||
return None
|
||||
except OSError as exc:
|
||||
if exc.errno in (errno.ELOOP, errno.ENOTDIR):
|
||||
return None # Symlink oder kein Pfad mehr
|
||||
return BUSY
|
||||
try:
|
||||
st = os.fstat(fd)
|
||||
finally:
|
||||
os.close(fd)
|
||||
if not stat.S_ISREG(st.st_mode):
|
||||
return None
|
||||
return FileState(st.st_size, st.st_mtime_ns)
|
||||
|
||||
@@ -125,4 +125,9 @@ class Notifier:
|
||||
|
||||
def mount_event(self, kind: str, mount: str, message: str) -> None:
|
||||
"""Passt zur ``Notifier``-Signatur des :class:`MountManager`."""
|
||||
self.send(Notification(kind, f"NetMover: Anbindung „{mount}“ gestört", message))
|
||||
title = (
|
||||
f"NetMover: Anbindung „{mount}“ wieder verfügbar"
|
||||
if kind == "mount.recovered"
|
||||
else f"NetMover: Anbindung „{mount}“ gestört"
|
||||
)
|
||||
self.send(Notification(kind, title, message))
|
||||
|
||||
@@ -13,6 +13,7 @@ from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import random
|
||||
from collections.abc import Callable
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from datetime import UTC, datetime, timedelta
|
||||
|
||||
@@ -31,6 +32,7 @@ log = get_logger(__name__)
|
||||
MAX_START_OFFSET = 10.0
|
||||
HEARTBEAT_SECONDS = 30
|
||||
CLEANUP_SECONDS = 3600
|
||||
RETRY_SECONDS = 60
|
||||
|
||||
|
||||
def _job_id(rule_id: int) -> str:
|
||||
@@ -43,11 +45,13 @@ class RuleScheduler:
|
||||
session_factory: sessionmaker[Session],
|
||||
service: RunService,
|
||||
*,
|
||||
retry_mounts: Callable[[], object] | None = None,
|
||||
rng: random.Random | None = None,
|
||||
workers: int = 8,
|
||||
) -> None:
|
||||
self.session_factory = session_factory
|
||||
self.service = service
|
||||
self._retry_mounts = retry_mounts
|
||||
self._rng = rng or random.Random() # noqa: S311 – nur Startversatz
|
||||
self._executor = ThreadPoolExecutor(max_workers=workers, thread_name_prefix="nm-run")
|
||||
self.scheduler = AsyncIOScheduler(
|
||||
@@ -70,6 +74,10 @@ class RuleScheduler:
|
||||
IntervalTrigger(seconds=CLEANUP_SECONDS, start_date=utcnow() + timedelta(minutes=1)),
|
||||
id="cleanup",
|
||||
)
|
||||
if self._retry_mounts is not None:
|
||||
self.scheduler.add_job(
|
||||
self._retry, IntervalTrigger(seconds=RETRY_SECONDS), id="mount-retry"
|
||||
)
|
||||
self.sync()
|
||||
event(log, "scheduler.started", rules=len(self._intervals))
|
||||
|
||||
@@ -141,6 +149,11 @@ class RuleScheduler:
|
||||
self._executor, self.service.execute, rule_id, RunTrigger.MANUAL, dry_run
|
||||
)
|
||||
|
||||
async def _retry(self) -> None:
|
||||
if self._retry_mounts is not None:
|
||||
loop = asyncio.get_running_loop()
|
||||
await loop.run_in_executor(self._executor, self._retry_mounts)
|
||||
|
||||
async def _cleanup(self) -> None:
|
||||
loop = asyncio.get_running_loop()
|
||||
await loop.run_in_executor(self._executor, cleanup, self.session_factory)
|
||||
|
||||
@@ -730,3 +730,52 @@ def test_final_step_is_a_real_rename_when_possible(
|
||||
assert (dst / "a.pdf").read_text() == "alt" and (dst / "a-1.pdf").read_text() == "neu"
|
||||
assert bool(links) is not supported
|
||||
assert [p.name for p in dst.iterdir() if p.name.endswith(".part")] == []
|
||||
|
||||
|
||||
# -- Praxisbefund: veralteter Attribut-Cache (CIFS/NFS) -------------------------------------
|
||||
|
||||
|
||||
def test_stability_uses_fresh_attributes_not_stat_cache(
|
||||
engine: Engine,
|
||||
rule_id: int,
|
||||
src: Path,
|
||||
dst: Path,
|
||||
hooks: list[Callable[[], None]],
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""Auf dem Testhost lieferte CIFS per stat() sekundenlang eine veraltete Größe,
|
||||
während die Datei wuchs. Simuliert: os.stat liefert für die Datei immer den
|
||||
alten Stand (Cache), nur öffnen + fstat sieht die Wahrheit."""
|
||||
growing = write(src / "langsam.pdf", "a" * 10)
|
||||
stale = os.stat(growing)
|
||||
real_stat = os.stat
|
||||
|
||||
def cached_stat(path: object, *a: object, **kw: object) -> os.stat_result:
|
||||
if str(path) == str(growing):
|
||||
return stale
|
||||
return real_stat(path, *a, **kw) # type: ignore[arg-type]
|
||||
|
||||
monkeypatch.setattr(os, "stat", cached_stat)
|
||||
hooks.append(lambda: growing.write_text("a" * 20))
|
||||
result = run(engine, snap(rule_id), src, dst)
|
||||
assert statuses(result) == {"langsam.pdf": ItemStatus.SKIPPED}
|
||||
assert "wird noch geschrieben" in (result.items[0].message or "")
|
||||
assert growing.exists() and not (dst / "langsam.pdf").exists()
|
||||
|
||||
|
||||
def test_locked_file_counts_as_in_use(
|
||||
engine: Engine, rule_id: int, src: Path, dst: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
f = write(src / "gesperrt.pdf")
|
||||
real_open = os.open
|
||||
|
||||
def locked(path: object, flags: int, *a: object, **kw: object) -> int:
|
||||
if str(path) == str(f):
|
||||
raise PermissionError(errno.EACCES, "Permission denied", str(path))
|
||||
return real_open(path, flags, *a, **kw) # type: ignore[arg-type]
|
||||
|
||||
monkeypatch.setattr("netmover.mover.scan.os.open", locked)
|
||||
result = run(engine, snap(rule_id), src, dst)
|
||||
assert statuses(result) == {"gesperrt.pdf": ItemStatus.SKIPPED}
|
||||
assert result.items[0].message == "Datei ist gerade in Benutzung"
|
||||
assert f.exists()
|
||||
|
||||
@@ -115,3 +115,38 @@ def test_is_mounted_reads_mountinfo(tmp_path: Path) -> None:
|
||||
)
|
||||
def test_explain(fstype: str, msg: str, expect: str) -> None:
|
||||
assert expect in explain_mount_error(fstype, msg)
|
||||
|
||||
|
||||
def _alive(pid: int) -> bool:
|
||||
try:
|
||||
with open(f"/proc/{pid}/stat") as fh:
|
||||
return fh.read().split(")")[-1].split()[0] != "Z"
|
||||
except FileNotFoundError:
|
||||
return False
|
||||
|
||||
|
||||
def test_timeout_kills_whole_process_group(tmp_path: Path) -> None:
|
||||
"""mount startet Helfer (mount.nfs) – die dürfen nach dem Zeitlimit nicht weiterlaufen."""
|
||||
from netmover.mounting.linux import run_process_group
|
||||
|
||||
pidfile = tmp_path / "helper.pid"
|
||||
script = f"sleep 30 & echo $! > {pidfile}; wait"
|
||||
with pytest.raises(subprocess.TimeoutExpired):
|
||||
run_process_group(["sh", "-c", script], capture_output=True, text=True, timeout=0.5)
|
||||
helper = int(pidfile.read_text())
|
||||
for _ in range(20):
|
||||
if not _alive(helper):
|
||||
break
|
||||
import time
|
||||
|
||||
time.sleep(0.05)
|
||||
assert not _alive(helper)
|
||||
|
||||
|
||||
def test_process_group_runner_returns_output() -> None:
|
||||
from netmover.mounting.linux import run_process_group
|
||||
|
||||
r = run_process_group(
|
||||
["sh", "-c", "echo out; echo err >&2; exit 3"], capture_output=True, text=True, timeout=5
|
||||
)
|
||||
assert (r.returncode, r.stdout, r.stderr) == (3, "out\n", "err\n")
|
||||
|
||||
@@ -367,3 +367,159 @@ def test_usage(
|
||||
manager.mount(mid)
|
||||
usage = manager.usage(mid)
|
||||
assert usage is not None and usage[0] >= usage[1] > 0
|
||||
|
||||
|
||||
# -- Praxisbefund: NAS-Neustart dauert länger als die Wiederherstellung ----------------------
|
||||
|
||||
|
||||
class Clock:
|
||||
def __init__(self) -> None:
|
||||
self.now = 1000.0
|
||||
|
||||
def __call__(self) -> float:
|
||||
return self.now
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def clock() -> Clock:
|
||||
return Clock()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def retry_manager(
|
||||
session_factory: sessionmaker[Session],
|
||||
fake_backend: FakeMountBackend,
|
||||
secretbox: object,
|
||||
mount_base: Path,
|
||||
notifications: list[tuple[str, str, str]],
|
||||
clock: Clock,
|
||||
) -> MountManager:
|
||||
return MountManager(
|
||||
session_factory,
|
||||
fake_backend,
|
||||
secretbox,
|
||||
mount_base, # type: ignore[arg-type]
|
||||
probe_timeout=0.5,
|
||||
recover_backoff=(0, 0, 0),
|
||||
sleep=lambda _: None,
|
||||
notifier=lambda *a: notifications.append(a),
|
||||
retry_interval=60,
|
||||
clock=clock,
|
||||
)
|
||||
|
||||
|
||||
def test_failed_recovery_retries_every_interval_until_nas_is_back(
|
||||
retry_manager: MountManager,
|
||||
fake_backend: FakeMountBackend,
|
||||
make_mount: MountFactory,
|
||||
session_factory: sessionmaker[Session],
|
||||
notifications: list[tuple[str, str, str]],
|
||||
clock: Clock,
|
||||
) -> None:
|
||||
fake_backend.export("nas", "scans")
|
||||
mid = make_mount()
|
||||
retry_manager.mount(mid)
|
||||
mp = retry_manager.mountpoint_for("scans")
|
||||
fake_backend.inject_fault(mp, errno.ESTALE)
|
||||
fake_backend.fail_next_mount(times=3) # NAS startet neu
|
||||
with pytest.raises(MountUnavailable, match="alle 60 s"):
|
||||
retry_manager.check_alive(mid)
|
||||
assert not fake_backend.is_mounted(mp)
|
||||
|
||||
mounts_before = sum(1 for c in fake_backend.calls if c.action == "mount")
|
||||
clock.now += 30 # zu früh: kein Versuch, Lauf abgebrochen
|
||||
with pytest.raises(MountUnavailable, match="in 30 s"):
|
||||
retry_manager.check_alive(mid)
|
||||
assert sum(1 for c in fake_backend.calls if c.action == "mount") == mounts_before
|
||||
|
||||
clock.now += 30 # NAS noch nicht zurück
|
||||
fake_backend.fail_next_mount()
|
||||
with pytest.raises(MountUnavailable, match="weiterhin nicht verfügbar"):
|
||||
retry_manager.check_alive(mid)
|
||||
|
||||
clock.now += 60 # NAS wieder da
|
||||
assert retry_manager.check_alive(mid) == mp
|
||||
assert fake_backend.is_mounted(mp)
|
||||
assert _state(session_factory, mid).state is MountState.MOUNTED
|
||||
assert notifications[-1][0] == "mount.recovered"
|
||||
|
||||
|
||||
def test_no_retry_without_auto_mount(
|
||||
retry_manager: MountManager,
|
||||
fake_backend: FakeMountBackend,
|
||||
make_mount: MountFactory,
|
||||
clock: Clock,
|
||||
) -> None:
|
||||
fake_backend.export("nas", "scans")
|
||||
mid = make_mount(auto_mount=False)
|
||||
retry_manager.mount(mid)
|
||||
fake_backend.inject_fault(retry_manager.mountpoint_for("scans"), errno.ESTALE)
|
||||
fake_backend.fail_next_mount(times=3)
|
||||
with pytest.raises(MountUnavailable):
|
||||
retry_manager.check_alive(mid)
|
||||
clock.now += 600
|
||||
with pytest.raises(MountUnavailable, match="nicht eingehängt"):
|
||||
retry_manager.check_alive(mid)
|
||||
|
||||
|
||||
def test_externally_unmounted_is_still_never_remounted(
|
||||
retry_manager: MountManager,
|
||||
fake_backend: FakeMountBackend,
|
||||
make_mount: MountFactory,
|
||||
clock: Clock,
|
||||
) -> None:
|
||||
fake_backend.export("nas", "scans")
|
||||
mid = make_mount()
|
||||
retry_manager.mount(mid)
|
||||
fake_backend.vanish(retry_manager.mountpoint_for("scans"))
|
||||
clock.now += 600
|
||||
with pytest.raises(MountUnavailable, match="nicht eingehängt"):
|
||||
retry_manager.check_alive(mid)
|
||||
assert [c.action for c in fake_backend.calls] == ["mount"]
|
||||
|
||||
|
||||
def test_hint_text_is_not_redacted() -> None:
|
||||
from netmover.mounting.manager import _redact_output
|
||||
|
||||
msg = "mount error(13): Permission denied falsch\n→ Benutzername/Passwort falsch oder …"
|
||||
out = _redact_output(msg, ["falsch"])
|
||||
assert out.startswith("mount error(13): Permission denied ••••••")
|
||||
assert out.endswith("→ Benutzername/Passwort falsch oder …")
|
||||
|
||||
|
||||
def test_restore_failure_at_boot_is_retried_by_background_job(
|
||||
retry_manager: MountManager,
|
||||
fake_backend: FakeMountBackend,
|
||||
make_mount: MountFactory,
|
||||
session_factory: sessionmaker[Session],
|
||||
notifications: list[tuple[str, str, str]],
|
||||
clock: Clock,
|
||||
) -> None:
|
||||
"""Stromausfall: die VM bootet schneller als das NAS."""
|
||||
fake_backend.export("nas", "scans")
|
||||
mid = make_mount() # keine Regel benutzt die Anbindung
|
||||
fake_backend.fail_next_mount()
|
||||
assert [r.ok for r in retry_manager.restore_all()] == [False]
|
||||
assert retry_manager.retry_pending() == [] # noch nicht fällig
|
||||
clock.now += 60
|
||||
assert retry_manager.retry_pending() == ["scans"]
|
||||
assert fake_backend.is_mounted(retry_manager.mountpoint_for("scans"))
|
||||
assert _state(session_factory, mid).state is MountState.MOUNTED
|
||||
assert notifications[-1][0] == "mount.recovered"
|
||||
clock.now += 60
|
||||
assert retry_manager.retry_pending() == [] # erledigt
|
||||
|
||||
|
||||
def test_manual_unmount_stops_retrying(
|
||||
retry_manager: MountManager,
|
||||
fake_backend: FakeMountBackend,
|
||||
make_mount: MountFactory,
|
||||
clock: Clock,
|
||||
) -> None:
|
||||
mid = make_mount() # nicht exportiert → Wiederherstellung scheitert
|
||||
retry_manager.restore_all()
|
||||
retry_manager.unmount(mid)
|
||||
fake_backend.export("nas", "scans")
|
||||
clock.now += 120
|
||||
assert retry_manager.retry_pending() == []
|
||||
assert not fake_backend.mounted
|
||||
|
||||
Reference in New Issue
Block a user