wrolpi/controller/lib/mount_executor.py

210 lines
7.2 KiB
Python
Raw Normal View History

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-22 22:02:30 -06:00
"""
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
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-22 22:02:30 -06:00
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)
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-22 22:02:30 -06:00
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}")
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-22 22:02:30 -06:00
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)}