mirror of
https://github.com/lrnselfreliance/wrolpi.git
synced 2026-10-26 10:51:36 -04:00
QA on 10.0.0.8 (mount-issues.md findings 2 and 3) showed the reconciler path missed both NTFS accommodations that mount_drive() and add_fstab_entry() already have: secondary NTFS drives mounted owned by root (writable only via ntfs-3g's permissive default), and a dirty NTFS volume would fail to mount at boot with no cleanup attempted. Inject uid/gid for ntfs/ntfs3 in the reconciler, and run ntfsfix -d in SubprocessMountExecutor before NTFS mounts (translating UUID=/LABEL= specs to /dev/disk/by-* paths, which ntfsfix needs). Also return an error MountResult instead of raising when a binary is missing, per the executor protocol. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
209 lines
7.2 KiB
Python
209 lines
7.2 KiB
Python
"""
|
|
The narrow interface between WROLPi's mount reconciler and the operating
|
|
system. Pulling these three operations behind a single abstraction lets the
|
|
reconciler stay pure-orchestration: tests construct a FakeMountExecutor that
|
|
records calls + holds an in-memory mount-table, with no patches against
|
|
subprocess, /proc/mounts, or pwd/grp.
|
|
|
|
Two implementations live here:
|
|
|
|
- SubprocessMountExecutor — production. Calls mount(8) / umount(8) via
|
|
subprocess and reads /proc/mounts.
|
|
|
|
- FakeMountExecutor — tests. Tracks a mount-point set in memory, records
|
|
every mount/unmount call, and accepts predetermined per-call failures via
|
|
`fail_for`.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import logging
|
|
import subprocess
|
|
from dataclasses import dataclass, field
|
|
from typing import Optional, Protocol
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class MountResult:
|
|
"""Outcome of a single mount or unmount call."""
|
|
|
|
ok: bool
|
|
error: Optional[str] = None
|
|
|
|
|
|
class MountExecutor(Protocol):
|
|
"""The contract every executor must satisfy.
|
|
|
|
Implementations should never raise for I/O failures; instead, return a
|
|
MountResult(ok=False, error="…"). Raising is reserved for programmer
|
|
errors (bad arguments, etc.).
|
|
"""
|
|
|
|
def mount(
|
|
self,
|
|
device: str,
|
|
mount_point: str,
|
|
fstype: str,
|
|
options: str,
|
|
) -> MountResult: ...
|
|
|
|
def unmount(self, mount_point: str) -> MountResult: ...
|
|
|
|
def current_mount_points(self, prefix: str = "/media") -> set[str]: ...
|
|
|
|
|
|
# --- Production implementation -------------------------------------------
|
|
|
|
MOUNT_TIMEOUT_SECONDS = 30
|
|
|
|
|
|
def _device_path(device: str) -> str:
|
|
"""Translate a UUID=/LABEL= mount spec to a real device node.
|
|
|
|
mount(8) resolves these specs itself, but tools like ntfsfix need a
|
|
path. udev maintains the by-uuid/by-label symlinks."""
|
|
if device.startswith("UUID="):
|
|
return f"/dev/disk/by-uuid/{device[len('UUID='):]}"
|
|
if device.startswith("LABEL="):
|
|
return f"/dev/disk/by-label/{device[len('LABEL='):]}"
|
|
return device
|
|
|
|
|
|
class SubprocessMountExecutor:
|
|
"""Executes mount/umount via subprocess and parses /proc/mounts."""
|
|
|
|
def __init__(self, timeout_seconds: int = MOUNT_TIMEOUT_SECONDS):
|
|
self._timeout = timeout_seconds
|
|
|
|
def mount(
|
|
self,
|
|
device: str,
|
|
mount_point: str,
|
|
fstype: str,
|
|
options: str,
|
|
) -> MountResult:
|
|
# NTFS volumes are frequently left "dirty" (Windows Fast Startup, or
|
|
# an unclean unplug), which makes the driver refuse to mount them.
|
|
# ntfsfix -d clears the dirty flag and replays the journal; it is a
|
|
# no-op on clean volumes. Never fatal: the mount below reports the
|
|
# real error. Matches mount_drive() in lib/disks.py.
|
|
if fstype in ("ntfs", "ntfs3"):
|
|
fix = self._run(["ntfsfix", "-d", _device_path(device)],
|
|
label=f"ntfsfix {device}")
|
|
if not fix.ok:
|
|
logger.warning("ntfsfix failed for %s (mount will still be attempted): %s",
|
|
device, fix.error)
|
|
cmd = ["mount", "-t", fstype, "-o", options, device, mount_point]
|
|
return self._run(cmd, label=f"mount {device} {mount_point}")
|
|
|
|
def unmount(self, mount_point: str) -> MountResult:
|
|
# Plain umount. If the mount is busy, the reconciler will see it
|
|
# still present on the next pass and retry; we deliberately do not
|
|
# add `-l` (lazy) here because lazy unmounts can hide bugs.
|
|
return self._run(["umount", mount_point], label=f"umount {mount_point}")
|
|
|
|
def current_mount_points(self, prefix: str = "/media") -> set[str]:
|
|
out: set[str] = set()
|
|
try:
|
|
with open("/proc/mounts") as f:
|
|
for line in f:
|
|
parts = line.split()
|
|
if len(parts) >= 2 and parts[1].startswith(prefix):
|
|
out.add(parts[1])
|
|
except OSError as e:
|
|
logger.error("Could not read /proc/mounts: %s", e)
|
|
return out
|
|
|
|
def _run(self, cmd: list[str], *, label: str) -> MountResult:
|
|
logger.info("Running: %s", " ".join(cmd))
|
|
try:
|
|
result = subprocess.run(
|
|
cmd, capture_output=True, text=True, timeout=self._timeout,
|
|
)
|
|
except subprocess.TimeoutExpired:
|
|
return MountResult(
|
|
ok=False,
|
|
error=f"{label}: timed out after {self._timeout}s",
|
|
)
|
|
except OSError as e:
|
|
# e.g. FileNotFoundError for a missing binary — the executor
|
|
# protocol forbids raising for I/O failures.
|
|
return MountResult(ok=False, error=f"{label}: {e}")
|
|
if result.returncode != 0:
|
|
return MountResult(
|
|
ok=False,
|
|
error=f"{label}: {result.stderr.strip() or 'non-zero exit'}",
|
|
)
|
|
return MountResult(ok=True)
|
|
|
|
|
|
# --- Test fake ------------------------------------------------------------
|
|
|
|
@dataclass
|
|
class MountCall:
|
|
"""Recorded mount invocation, for test assertions."""
|
|
|
|
device: str
|
|
mount_point: str
|
|
fstype: str
|
|
options: str
|
|
|
|
|
|
@dataclass
|
|
class UnmountCall:
|
|
"""Recorded unmount invocation, for test assertions."""
|
|
|
|
mount_point: str
|
|
|
|
|
|
@dataclass
|
|
class FakeMountExecutor:
|
|
"""In-memory MountExecutor for tests.
|
|
|
|
Behaviour:
|
|
- mount(...) adds mount_point to the live set and records the call,
|
|
unless the call matches an entry in ``fail_for_mount`` (matched by
|
|
mount_point), in which case it returns a failure and the set is
|
|
unchanged.
|
|
- unmount(...) removes mount_point from the live set and records the
|
|
call, unless the call matches ``fail_for_unmount``.
|
|
- current_mount_points(prefix) returns a copy of the live set filtered
|
|
by prefix.
|
|
|
|
Tests can seed the live set via the ``initially_mounted`` argument.
|
|
"""
|
|
|
|
initially_mounted: frozenset[str] = frozenset()
|
|
fail_for_mount: frozenset[str] = frozenset()
|
|
fail_for_unmount: frozenset[str] = frozenset()
|
|
mount_calls: list[MountCall] = field(default_factory=list)
|
|
unmount_calls: list[UnmountCall] = field(default_factory=list)
|
|
_live: set[str] = field(init=False)
|
|
|
|
def __post_init__(self) -> None:
|
|
self._live = set(self.initially_mounted)
|
|
|
|
def mount(
|
|
self,
|
|
device: str,
|
|
mount_point: str,
|
|
fstype: str,
|
|
options: str,
|
|
) -> MountResult:
|
|
self.mount_calls.append(MountCall(device, mount_point, fstype, options))
|
|
if mount_point in self.fail_for_mount:
|
|
return MountResult(ok=False, error=f"fake: configured to fail mount {mount_point}")
|
|
self._live.add(mount_point)
|
|
return MountResult(ok=True)
|
|
|
|
def unmount(self, mount_point: str) -> MountResult:
|
|
self.unmount_calls.append(UnmountCall(mount_point))
|
|
if mount_point in self.fail_for_unmount:
|
|
return MountResult(ok=False, error=f"fake: configured to fail umount {mount_point}")
|
|
self._live.discard(mount_point)
|
|
return MountResult(ok=True)
|
|
|
|
def current_mount_points(self, prefix: str = "/media") -> set[str]:
|
|
return {mp for mp in self._live if mp.startswith(prefix)}
|