wrolpi/controller/test/test_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

191 lines
7 KiB
Python

"""
Tests for controller.lib.mount_executor.
The fake is a load-bearing test double; if it lies the entire Reconciler
test suite is meaningless. These tests pin its behaviour without using
mocks. The Subprocess executor is exercised against its own narrow
abstraction (a subprocess.run replacement injected via a small seam) so
the production path is also covered without leaning on real mount(8).
"""
from controller.lib.mount_executor import (
FakeMountExecutor,
MountCall,
MountResult,
SubprocessMountExecutor,
UnmountCall,
)
# --- FakeMountExecutor --------------------------------------------------
class TestFakeMountExecutorState:
def test_starts_with_initially_mounted(self):
fake = FakeMountExecutor(initially_mounted=frozenset({"/media/a", "/media/b"}))
assert fake.current_mount_points() == {"/media/a", "/media/b"}
def test_mount_adds_to_live_set(self):
fake = FakeMountExecutor()
r = fake.mount("UUID=1", "/media/x", "ext4", "defaults")
assert r.ok is True
assert r.error is None
assert fake.current_mount_points() == {"/media/x"}
def test_unmount_removes_from_live_set(self):
fake = FakeMountExecutor(initially_mounted=frozenset({"/media/x"}))
r = fake.unmount("/media/x")
assert r.ok is True
assert fake.current_mount_points() == set()
def test_unmount_of_missing_path_is_still_recorded(self):
# We model umount(8): unmounting an unmounted path is allowed (it's
# not an error in the fake). The Reconciler is responsible for
# only calling unmount on currently-mounted paths.
fake = FakeMountExecutor()
r = fake.unmount("/media/never-mounted")
assert r.ok is True
assert fake.unmount_calls == [UnmountCall("/media/never-mounted")]
def test_prefix_filter(self):
fake = FakeMountExecutor(initially_mounted=frozenset(
{"/media/a", "/media/b", "/mnt/c"}))
# The fake stores anything but current_mount_points filters.
assert fake.current_mount_points("/media") == {"/media/a", "/media/b"}
assert fake.current_mount_points("/mnt") == {"/mnt/c"}
class TestFakeMountExecutorCallRecording:
def test_records_mount_arguments(self):
fake = FakeMountExecutor()
fake.mount("UUID=1234", "/media/x", "ext4", "defaults,nofail")
assert fake.mount_calls == [
MountCall("UUID=1234", "/media/x", "ext4", "defaults,nofail"),
]
def test_records_unmount_arguments(self):
fake = FakeMountExecutor(initially_mounted=frozenset({"/media/x"}))
fake.unmount("/media/x")
assert fake.unmount_calls == [UnmountCall("/media/x")]
class TestFakeMountExecutorConfiguredFailures:
def test_mount_failure_leaves_set_unchanged(self):
fake = FakeMountExecutor(fail_for_mount=frozenset({"/media/bad"}))
r = fake.mount("UUID=1", "/media/bad", "ext4", "defaults")
assert r.ok is False
assert "fake" in (r.error or "")
assert fake.current_mount_points() == set()
# Call still recorded so tests can assert what was attempted.
assert len(fake.mount_calls) == 1
def test_unmount_failure_leaves_set_unchanged(self):
fake = FakeMountExecutor(
initially_mounted=frozenset({"/media/x"}),
fail_for_unmount=frozenset({"/media/x"}),
)
r = fake.unmount("/media/x")
assert r.ok is False
assert "/media/x" in fake.current_mount_points()
# --- SubprocessMountExecutor --------------------------------------------
class _FakeCompletedProcess:
def __init__(self, returncode: int, stderr: str = ""):
self.returncode = returncode
self.stderr = stderr
class TestSubprocessMountExecutor:
"""Cover the subprocess-backed executor without ever invoking mount(8).
We inject a fake subprocess.run into the executor's module namespace via
monkeypatch (pytest's built-in feature, not mock.patch). This is the
closest the suite gets to mocking — it's necessary because subprocess is
the boundary that real I/O lives on.
"""
def test_mount_success(self, monkeypatch):
calls = []
def fake_run(cmd, **kwargs):
calls.append(cmd)
return _FakeCompletedProcess(returncode=0)
monkeypatch.setattr(
"controller.lib.mount_executor.subprocess.run", fake_run,
)
executor = SubprocessMountExecutor()
result = executor.mount("UUID=1", "/media/x", "ext4", "defaults")
assert result == MountResult(ok=True)
assert calls == [["mount", "-t", "ext4", "-o", "defaults",
"UUID=1", "/media/x"]]
def test_mount_propagates_stderr(self, monkeypatch):
def fake_run(cmd, **kwargs):
return _FakeCompletedProcess(returncode=32, stderr="busy")
monkeypatch.setattr(
"controller.lib.mount_executor.subprocess.run", fake_run,
)
result = SubprocessMountExecutor().mount(
"UUID=1", "/media/x", "ext4", "defaults")
assert result.ok is False
assert "busy" in (result.error or "")
def test_mount_timeout(self, monkeypatch):
import subprocess as _sp
def fake_run(cmd, **kwargs):
raise _sp.TimeoutExpired(cmd=cmd, timeout=30)
monkeypatch.setattr(
"controller.lib.mount_executor.subprocess.run", fake_run,
)
result = SubprocessMountExecutor().mount(
"UUID=1", "/media/x", "ext4", "defaults")
assert result.ok is False
assert "timed out" in (result.error or "")
def test_unmount_command_shape(self, monkeypatch):
calls = []
def fake_run(cmd, **kwargs):
calls.append(cmd)
return _FakeCompletedProcess(returncode=0)
monkeypatch.setattr(
"controller.lib.mount_executor.subprocess.run", fake_run,
)
SubprocessMountExecutor().unmount("/media/x")
assert calls == [["umount", "/media/x"]]
def test_current_mount_points_parses_proc_mounts(self, monkeypatch, tmp_path):
# Redirect /proc/mounts to a temp file the executor will read.
proc_mounts = tmp_path / "proc-mounts"
proc_mounts.write_text(
"proc /proc proc rw 0 0\n"
"/dev/sda1 / ext4 rw 0 0\n"
"/dev/sdb1 /media/wrolpi/usb ext4 rw 0 0\n"
"tmpfs /tmp tmpfs rw 0 0\n"
"/dev/sdc1 /media/photos exfat rw 0 0\n"
)
# Patch only the open(...) lookup the executor performs. We do this
# via monkeypatch on builtins.open scoped to this test only.
original_open = open
def open_redirect(path, *args, **kwargs):
if path == "/proc/mounts":
return original_open(proc_mounts, *args, **kwargs)
return original_open(path, *args, **kwargs)
monkeypatch.setattr("builtins.open", open_redirect)
executor = SubprocessMountExecutor()
assert executor.current_mount_points("/media") == {
"/media/wrolpi/usb", "/media/photos",
}