wrolpi/controller/lib/mount_executor.py
Roland 910503b641
Reconciler: uid/gid and ntfsfix for NTFS secondary mounts.
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>
2026-07-06 20:30:29 -06:00

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)}