7 Commits
Author SHA1 Message Date
Florian WildeandClaude Opus 5.5 2d2238ce4e chore(release): 0.1.1
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 22:03:27 +02:00
Florian WildeandClaude Opus 5.5 4f9c5bdc1d fix(mounts): bei Zeitüberschreitung ganze Prozessgruppe von mount beenden
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 21:56:18 +02:00
Florian WildeandClaude Opus 5.5 1bf0fae2fc fix(mounts): gescheiterte Start-Wiederherstellung ebenfalls minütlich neu versuchen
Bootet die VM schneller als das NAS (Stromausfall), käme die Freigabe sonst
nie zurück. Ein Scheduler-Job prüft fällige Neuversuche jede Minute, auch
für Anbindungen ohne Regel.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 21:51:58 +02:00
Florian WildeandClaude Opus 5.5 ea50eacc9d docs: Unit-Härtung, Neuversuch und install.sh ohne debconf-Warnungen
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 21:50:06 +02:00
Florian WildeandClaude Opus 5.5 c836f58189 fix(mounts): nach gescheiterter Wiederherstellung minütlich neu versuchen
Ein NAS-Neustart dauert länger als die drei Wiederherstellungsversuche.
Von NetMover selbst ausgehängte Mounts mit auto_mount werden höchstens
einmal pro Minute erneut eingehängt, mit Entwarnung per Webhook. Von außen
ausgehängte bleiben unangetastet. Hinweistexte werden nicht mehr
mitgeschwärzt.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 21:50:06 +02:00
Florian WildeandClaude Opus 5.5 8276ce2afb fix(mover): Stabilität mit frischen Attributen (open + fstat)
CIFS lieferte per stat() sekundenlang eine veraltete Größe einer noch
wachsenden Datei; erst open() erzwingt frische Attribute. Gesperrte
Dateien gelten als in Benutzung.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 21:50:06 +02:00
Florian WildeandClaude Opus 5.5 48a52c5290 fix(deploy): systemd-Unit ohne eigenen Mount-Namespace
ProtectSystem/PrivateTmp/ProtectControlGroups erzeugen einen Namespace als
slave; Mounts blieben dort gefangen (auf der Test-VM nachgewiesen) und
verschwanden bei jedem Neustart. MountFlags=shared hilft nicht. Nur noch
Härtungen ohne Namespace.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 21:50:06 +02:00
17 changed files with 513 additions and 31 deletions
+31 -1
View File
@@ -8,6 +8,35 @@ Die Version steht an genau einer Stelle: `src/netmover/__init__.py`.
## [Unreleased] ## [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 ## [0.1.0] – 2026-10-10
Erste Version. Erste Version.
@@ -99,5 +128,6 @@ Erste Version.
- Löschen von Zugangsdaten, die noch von einem Mount benutzt werden, trennt den - Löschen von Zugangsdaten, die noch von einem Mount benutzt werden, trennt den
Mount nicht mehr stillschweigend ab, sondern wird von der Datenbank abgelehnt. 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 [0.1.0]: https://git.wilde-server.de/florian.wilde/netmover/releases/tag/v0.1.0
+46 -1
View File
@@ -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: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: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 | | 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 ## 2026-10-10 · 19:40–19:50 · Schritt 6: Deployment, README, Release 0.1.0
**Erledigt** **Erledigt**
+12 -5
View File
@@ -69,13 +69,18 @@ Was wo liegt:
**Warum als root?** Ein- und Aushängen braucht `CAP_SYS_ADMIN`. Außerdem soll **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 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 Eigentümern anlegen können.
Mounten verträgt: `ProtectSystem=full`, `PrivateTmp`, `NoNewPrivileges`, Zustand nur
für root lesbar. Weil diese Optionen einen eigenen Mount-Namespace erzeugen, steht **Warum so wenig systemd-Härtung?** Optionen wie `ProtectSystem=`, `PrivateTmp=`
`MountFlags=shared` in der Unit. Nur so sieht das System die Mounts: 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 ```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 **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. | | „… 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. | | „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. | | „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. | | „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. | | „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). | | 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. | | 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. | | 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. | | Einrichtungscode verloren | `systemctl restart netmover` erzeugt einen neuen. |
+1 -1
View File
@@ -4,7 +4,7 @@
services: services:
netmover: netmover:
build: . build: .
image: netmover:0.1.0 image: netmover:0.1.1
container_name: netmover container_name: netmover
restart: unless-stopped restart: unless-stopped
cap_add: cap_add:
+1
View File
@@ -14,6 +14,7 @@ if [ "$(id -u)" -ne 0 ]; then
fi fi
echo "==> Pakete" echo "==> Pakete"
export DEBIAN_FRONTEND=noninteractive
apt-get update -qq apt-get update -qq
apt-get install -y -qq python3 python3-venv cifs-utils nfs-common >/dev/null apt-get install -y -qq python3 python3-venv cifs-utils nfs-common >/dev/null
+8 -9
View File
@@ -27,18 +27,17 @@ RuntimeDirectory=netmover
RuntimeDirectoryMode=0700 RuntimeDirectoryMode=0700
# Härtung, soweit mit Mounten vereinbar. # Härtung, soweit mit Mounten vereinbar.
# Die Optionen unten erzeugen einen eigenen Mount-Namespace für den Dienst. # WICHTIG: Keine Optionen, die einen eigenen Mount-Namespace erzeugen
# MountFlags=shared sorgt dafür, dass Mounts unter /mnt/netmover trotzdem im # (ProtectSystem=, ProtectHome=, PrivateTmp=, ProtectControlGroups=,
# System sichtbar sind (prüfen mit: findmnt -R /mnt/netmover). # ReadOnlyPaths=, BindPaths= …). Sonst landen die Mounts im Namespace des
MountFlags=shared # Dienstes: das System und andere Programme (Paperless!) sähen sie nicht, und
# /usr, /boot und /etc schreibgeschützt; /mnt, /opt, /srv, /var bleiben schreibbar # jeder Neustart des Dienstes würde sie verwerfen. MountFlags=shared hilft dagegen
# (lokale Ziele wie /opt/paperless/consume funktionieren also). # nicht – systemd setzt den Namespace als „slave“ auf, Mounts fließen nur hinein.
ProtectSystem=full # Auf dem Testsystem nachgewiesen; prüfen mit: findmnt -l | grep /mnt/netmover
PrivateTmp=yes
NoNewPrivileges=yes NoNewPrivileges=yes
ProtectControlGroups=yes
LockPersonality=yes LockPersonality=yes
RestrictRealtime=yes RestrictRealtime=yes
RestrictSUIDSGID=yes
UMask=0022 UMask=0022
[Install] [Install]
+1 -1
View File
@@ -1,3 +1,3 @@
"""NetMover – NFS/SMB-Mounts verwalten und Dateien zeitgesteuert verschieben.""" """NetMover – NFS/SMB-Mounts verwalten und Dateien zeitgesteuert verschieben."""
__version__ = "0.1.0" __version__ = "0.1.1"
+3 -1
View File
@@ -58,7 +58,9 @@ class NetMover:
) )
self.runner = RuleRunner(self.session_factory, self.mounts) self.runner = RuleRunner(self.session_factory, self.mounts)
self.runs = RunService(self.session_factory, self.runner, self.notifier) 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.started_at: datetime = utcnow()
self.restore_results: list[RestoreResult] = [] self.restore_results: list[RestoreResult] = []
+25 -1
View File
@@ -8,7 +8,9 @@ Optionszeile – die stünde sonst in der Prozessliste.
from __future__ import annotations from __future__ import annotations
import contextlib
import os import os
import signal
import subprocess import subprocess
import uuid import uuid
from collections.abc import Callable, Sequence from collections.abc import Callable, Sequence
@@ -24,6 +26,28 @@ log = get_logger(__name__)
Runner = Callable[..., "subprocess.CompletedProcess[str]"] 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"} _ENV = {"LC_ALL": "C", "PATH": "/usr/sbin:/usr/bin:/sbin:/bin"}
@@ -60,7 +84,7 @@ class LinuxMountBackend(MountBackend):
self, self,
run_dir: Path, run_dir: Path,
mountinfo_path: Path = MOUNTINFO, mountinfo_path: Path = MOUNTINFO,
runner: Runner = subprocess.run, runner: Runner = run_process_group,
) -> None: ) -> None:
self.run_dir = run_dir self.run_dir = run_dir
self.mountinfo_path = mountinfo_path self.mountinfo_path = mountinfo_path
+89 -1
View File
@@ -15,7 +15,9 @@ Fallstricke:
from __future__ import annotations from __future__ import annotations
import contextlib
import errno import errno
import math
import os import os
import threading import threading
import time import time
@@ -104,6 +106,13 @@ class RestoreResult:
message: str 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: def build_spec(cfg: MountConfig, mountpoint: Path, cred: CredentialData | None) -> MountSpec:
"""Baut den ``mount``-Aufruf. Validiert erneut (Verteidigung in der Tiefe) – """Baut den ``mount``-Aufruf. Validiert erneut (Verteidigung in der Tiefe) –
auch Daten aus der Datenbank laufen hier noch einmal durch die Prüfung.""" 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, sleep: Callable[[float], None] = time.sleep,
euid: Callable[[], int] = os.geteuid, euid: Callable[[], int] = os.geteuid,
notifier: Notifier | None = None, notifier: Notifier | None = None,
retry_interval: float = 60,
clock: Callable[[], float] = time.monotonic,
) -> None: ) -> None:
self.session_factory = session_factory self.session_factory = session_factory
self.backend = backend self.backend = backend
@@ -177,6 +188,12 @@ class MountManager:
self._sleep = sleep self._sleep = sleep
self._euid = euid self._euid = euid
self._notifier = notifier 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: dict[int, threading.RLock] = {}
self._locks_guard = threading.Lock() self._locks_guard = threading.Lock()
@@ -267,6 +284,7 @@ class MountManager:
def mount(self, mount_id: int) -> None: def mount(self, mount_id: int) -> None:
with self._lock(mount_id): with self._lock(mount_id):
self._retry_at.pop(mount_id, None)
m, cred = self._load(mount_id) m, cred = self._load(mount_id)
if not m.type.is_kernel_mount: if not m.type.is_kernel_mount:
raise MountError("SFTP-Anbindungen werden nicht eingehängt, nur getestet") raise MountError("SFTP-Anbindungen werden nicht eingehängt, nur getestet")
@@ -292,6 +310,7 @@ class MountManager:
def unmount(self, mount_id: int) -> None: def unmount(self, mount_id: int) -> None:
with self._lock(mount_id): with self._lock(mount_id):
self._retry_at.pop(mount_id, None)
m, _ = self._load(mount_id) m, _ = self._load(mount_id)
if not m.type.is_kernel_mount: if not m.type.is_kernel_mount:
self._set_state(mount_id, MountState.UNMOUNTED) self._set_state(mount_id, MountState.UNMOUNTED)
@@ -325,6 +344,8 @@ class MountManager:
self.mount(mount_id) self.mount(mount_id)
results.append(RestoreResult(name, True, "eingehängt")) results.append(RestoreResult(name, True, "eingehängt"))
except (MountError, ValidationError, LookupError) as exc: 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))) results.append(RestoreResult(name, False, str(exc)))
self._notify("mount.restore_failed", name, str(exc)) self._notify("mount.restore_failed", name, str(exc))
for r in results: for r in results:
@@ -391,7 +412,7 @@ class MountManager:
self.backend.mount(build_spec(cfg, temp, cred), timeout=self.mount_timeout) self.backend.mount(build_spec(cfg, temp, cred), timeout=self.mount_timeout)
except (MountError, ValidationError) as exc: except (MountError, ValidationError) as exc:
self._remove_mountpoint(temp) 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("read", None)
report.step("write", None) report.step("write", None)
return report return report
@@ -501,6 +522,9 @@ class MountManager:
return None return None
mp = self.mountpoint_for(m.slug) mp = self.mountpoint_for(m.slug)
if not self.backend.is_mounted(mp): 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." msg = f"„{m.name}“ ist nicht eingehängt – Lauf abgebrochen, nichts angefasst."
self._set_state(mount_id, MountState.ERROR, msg) self._set_state(mount_id, MountState.ERROR, msg)
raise MountUnavailable(msg) raise MountUnavailable(msg)
@@ -568,10 +592,74 @@ class MountManager:
f"„{m.name}“ gestört ({reason}); Neu-Einhängen nach " f"„{m.name}“ gestört ({reason}); Neu-Einhängen nach "
f"{len(self.recover_backoff)} Versuchen gescheitert: {last}" 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._set_state(mount_id, MountState.ERROR, msg)
self._notify("mount.error", m.name, msg) self._notify("mount.error", m.name, msg)
raise MountUnavailable(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: def usage(self, mount_id: int, timeout: float | None = None) -> tuple[int, int] | None:
"""(gesamt, frei) in Bytes – nur bei eingehängten Kernel-Mounts.""" """(gesamt, frei) in Bytes – nur bei eingehängten Kernel-Mounts."""
m, _ = self._load(mount_id) m, _ = self._load(mount_id)
+15 -8
View File
@@ -13,6 +13,7 @@ Reihenfolge pro Lauf:
from __future__ import annotations from __future__ import annotations
import dataclasses
import errno import errno
import os import os
import stat import stat
@@ -39,7 +40,7 @@ from netmover.mover.destinations import (
) )
from netmover.mover.ledger import FailureInfo, Ledger from netmover.mover.ledger import FailureInfo, Ledger
from netmover.mover.naming import collision_candidates 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 from netmover.timeouts import Cancelled, CancelToken
log = get_logger(__name__) log = get_logger(__name__)
@@ -222,26 +223,32 @@ class Engine:
else: else:
pending.append(c) 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: if pending and rule.stability_seconds > 0:
self._sleep(rule.stability_seconds) self._sleep(rule.stability_seconds)
token.check() token.check()
stable: list[Candidate] = [] stable: list[Candidate] = []
for c in pending: for c in pending:
st = current_state(c.path) before, st = first[c.relpath], current_state(c.path)
if st is None: if before is None or st is None:
continue # inzwischen verschwunden – nichts zu tun continue # inzwischen verschwunden – nichts zu tun
if st != c.state: if st != before or st == BUSY:
result.add( result.add(
ItemResult( ItemResult(
c.relpath, c.relpath,
ItemStatus.SKIPPED, ItemStatus.SKIPPED,
size=st.size, size=max(st.size, 0),
message="wird noch geschrieben (Größe/Zeit geändert)", message="wird noch geschrieben (Größe/Zeit geändert)"
if st != BUSY
else "Datei ist gerade in Benutzung",
) )
) )
continue 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) failures = self.ledger.failures(rule.id)
for c in stable: for c in stable:
+22 -1
View File
@@ -10,6 +10,7 @@
from __future__ import annotations from __future__ import annotations
import errno
import fnmatch import fnmatch
import os import os
import re import re
@@ -100,11 +101,31 @@ def scan(
yield from walk(root, "") 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: 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: try:
st = os.stat(path, follow_symlinks=False) fd = os.open(path, os.O_RDONLY | os.O_NOFOLLOW | os.O_NONBLOCK)
except FileNotFoundError: except FileNotFoundError:
return None 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): if not stat.S_ISREG(st.st_mode):
return None return None
return FileState(st.st_size, st.st_mtime_ns) return FileState(st.st_size, st.st_mtime_ns)
+6 -1
View File
@@ -125,4 +125,9 @@ class Notifier:
def mount_event(self, kind: str, mount: str, message: str) -> None: def mount_event(self, kind: str, mount: str, message: str) -> None:
"""Passt zur ``Notifier``-Signatur des :class:`MountManager`.""" """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
View File
@@ -13,6 +13,7 @@ from __future__ import annotations
import asyncio import asyncio
import random import random
from collections.abc import Callable
from concurrent.futures import ThreadPoolExecutor from concurrent.futures import ThreadPoolExecutor
from datetime import UTC, datetime, timedelta from datetime import UTC, datetime, timedelta
@@ -31,6 +32,7 @@ log = get_logger(__name__)
MAX_START_OFFSET = 10.0 MAX_START_OFFSET = 10.0
HEARTBEAT_SECONDS = 30 HEARTBEAT_SECONDS = 30
CLEANUP_SECONDS = 3600 CLEANUP_SECONDS = 3600
RETRY_SECONDS = 60
def _job_id(rule_id: int) -> str: def _job_id(rule_id: int) -> str:
@@ -43,11 +45,13 @@ class RuleScheduler:
session_factory: sessionmaker[Session], session_factory: sessionmaker[Session],
service: RunService, service: RunService,
*, *,
retry_mounts: Callable[[], object] | None = None,
rng: random.Random | None = None, rng: random.Random | None = None,
workers: int = 8, workers: int = 8,
) -> None: ) -> None:
self.session_factory = session_factory self.session_factory = session_factory
self.service = service self.service = service
self._retry_mounts = retry_mounts
self._rng = rng or random.Random() # noqa: S311 – nur Startversatz self._rng = rng or random.Random() # noqa: S311 – nur Startversatz
self._executor = ThreadPoolExecutor(max_workers=workers, thread_name_prefix="nm-run") self._executor = ThreadPoolExecutor(max_workers=workers, thread_name_prefix="nm-run")
self.scheduler = AsyncIOScheduler( self.scheduler = AsyncIOScheduler(
@@ -70,6 +74,10 @@ class RuleScheduler:
IntervalTrigger(seconds=CLEANUP_SECONDS, start_date=utcnow() + timedelta(minutes=1)), IntervalTrigger(seconds=CLEANUP_SECONDS, start_date=utcnow() + timedelta(minutes=1)),
id="cleanup", id="cleanup",
) )
if self._retry_mounts is not None:
self.scheduler.add_job(
self._retry, IntervalTrigger(seconds=RETRY_SECONDS), id="mount-retry"
)
self.sync() self.sync()
event(log, "scheduler.started", rules=len(self._intervals)) 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 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: async def _cleanup(self) -> None:
loop = asyncio.get_running_loop() loop = asyncio.get_running_loop()
await loop.run_in_executor(self._executor, cleanup, self.session_factory) await loop.run_in_executor(self._executor, cleanup, self.session_factory)
+49
View File
@@ -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 (dst / "a.pdf").read_text() == "alt" and (dst / "a-1.pdf").read_text() == "neu"
assert bool(links) is not supported assert bool(links) is not supported
assert [p.name for p in dst.iterdir() if p.name.endswith(".part")] == [] 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()
+35
View File
@@ -115,3 +115,38 @@ def test_is_mounted_reads_mountinfo(tmp_path: Path) -> None:
) )
def test_explain(fstype: str, msg: str, expect: str) -> None: def test_explain(fstype: str, msg: str, expect: str) -> None:
assert expect in explain_mount_error(fstype, msg) 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")
+156
View File
@@ -367,3 +367,159 @@ def test_usage(
manager.mount(mid) manager.mount(mid)
usage = manager.usage(mid) usage = manager.usage(mid)
assert usage is not None and usage[0] >= usage[1] > 0 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