wrolpi/controller/lib/mount_executor.py
Roland 719905cb77 Moving WROLPi-managed mounts off /etc/fstab onto a drive-resident fstab.yaml.
A WROLPi drive can move between hosts and across deployment styles
(Portable, RPi, Debian).  /etc/fstab is host-specific and on the Portable
image it's part of a read-only squashfs, so persisting secondary mounts
there was both wrong-host and lost-on-reboot.  This change makes
/media/wrolpi/config/fstab.yaml the single source of truth for WROLPi-
managed mounts and introduces a new wrolpi-mounts.service that reconciles
live state to match.

Architecture:

- The Controller writes intent (fstab.yaml) but never calls mount(8) or
  umount(8) itself.  Every mutation triggers `systemctl restart
  wrolpi-mounts.service`; the Controller then verifies the outcome via
  the same MountExecutor abstraction the service uses.

- wrolpi-mounts.service runs a thin entry point that constructs a real
  Reconciler with a SubprocessMountExecutor and calls apply().  The
  reconciler mounts what's in fstab.yaml but missing from the live state,
  and unmounts paths it previously claimed that are no longer in
  fstab.yaml.  A managed-set file in /run/wrolpi-mounts/managed prevents
  the reconciler from ever touching user-mounted paths it didn't own.

- One-time migration (marker-gated) lifts both /etc/fstab WROLPi entries
  and any controller.yaml drives.mounts entries into fstab.yaml.
  /etc/fstab is backed up to /etc/fstab.wrolpi-migration.backup;
  migrated lines are commented in place with a "migrated to fstab.yaml:"
  prefix so a host admin can hand-recover.

Test approach: dependency injection in place of patching.  The
MountExecutor protocol has SubprocessMountExecutor (production) and
FakeMountExecutor (in-memory, records calls).  The Reconciler accepts an
executor in its constructor, so the 20 reconciler tests + 13 migration
tests + 18 fstab_yaml tests use real files in tmp_path with zero
mock.patch calls.  test_disks_api.py drops to mocks only for the
host-touching read helpers; the mount/unmount paths now run the real
Reconciler against the fake executor.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 12:44:05 -06:00

182 lines
5.8 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
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:
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",
)
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)}