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

167 lines
5.9 KiB
Python

"""
Tests for controller.lib.fstab_yaml — the on-drive fstab.yaml reader/writer.
All tests use real files in tmp_path; no mocks anywhere. The file format is
a stable on-disk contract so the round-trip tests are the load-bearing ones.
"""
import yaml
from controller.lib.fstab_yaml import (
FstabEntry,
FstabFile,
SCHEMA_VERSION,
load,
save,
)
class TestFstabEntry:
def test_to_dict_round_trip(self):
entry = FstabEntry("UUID=abc", "/media/x", "ext4", "defaults,noatime")
d = entry.to_dict()
assert d == {
"device": "UUID=abc",
"mount_point": "/media/x",
"fstype": "ext4",
"options": "defaults,noatime",
}
assert FstabEntry.from_dict(d) == entry
def test_from_dict_supplies_defaults(self):
entry = FstabEntry.from_dict({"device": "UUID=abc", "mount_point": "/media/x"})
assert entry.fstype == "auto"
assert entry.options == "defaults"
def test_empty_options_falls_back_to_defaults(self):
# Migration data sometimes has options=None; treat as "defaults".
entry = FstabEntry.from_dict({
"device": "UUID=abc",
"mount_point": "/media/x",
"fstype": "ext4",
"options": None,
})
assert entry.options == "defaults"
class TestFstabFile:
def test_mount_points(self):
f = FstabFile(mounts=[
FstabEntry("UUID=1", "/media/a", "ext4"),
FstabEntry("UUID=2", "/media/b", "ext4"),
])
assert f.mount_points() == {"/media/a", "/media/b"}
def test_find_by_mount_point(self):
e = FstabEntry("UUID=1", "/media/a", "ext4")
f = FstabFile(mounts=[e])
assert f.find_by_mount_point("/media/a") is e
assert f.find_by_mount_point("/media/missing") is None
def test_add_or_replace_same_mount_point(self):
f = FstabFile(mounts=[FstabEntry("UUID=1", "/media/a", "ext4", "defaults")])
f.add_or_replace(FstabEntry("UUID=1", "/media/a", "ext4", "noatime"))
assert len(f.mounts) == 1
assert f.mounts[0].options == "noatime"
def test_add_or_replace_same_device_different_mount_point(self):
# User remounts the same drive to a new directory; old row should
# drop, not accumulate.
f = FstabFile(mounts=[FstabEntry("UUID=1", "/media/old", "ext4")])
f.add_or_replace(FstabEntry("UUID=1", "/media/new", "ext4"))
assert len(f.mounts) == 1
assert f.mounts[0].mount_point == "/media/new"
def test_add_or_replace_preserves_unrelated(self):
f = FstabFile(mounts=[
FstabEntry("UUID=1", "/media/a", "ext4"),
FstabEntry("UUID=2", "/media/b", "ext4"),
])
f.add_or_replace(FstabEntry("UUID=3", "/media/c", "ext4"))
assert {m.mount_point for m in f.mounts} == {"/media/a", "/media/b", "/media/c"}
def test_remove_by_mount_point_returns_true_when_removed(self):
f = FstabFile(mounts=[FstabEntry("UUID=1", "/media/a", "ext4")])
assert f.remove_by_mount_point("/media/a") is True
assert f.mounts == []
def test_remove_by_mount_point_returns_false_when_absent(self):
f = FstabFile()
assert f.remove_by_mount_point("/media/a") is False
class TestLoadSave:
def test_load_missing_file_is_empty(self, tmp_path):
f = load(tmp_path / "missing.yaml")
assert f.version == SCHEMA_VERSION
assert f.mounts == []
def test_load_empty_file_is_empty(self, tmp_path):
p = tmp_path / "fstab.yaml"
p.write_text("")
f = load(p)
assert f.mounts == []
def test_load_malformed_yaml_is_empty(self, tmp_path):
p = tmp_path / "fstab.yaml"
p.write_text("this: is: not: valid: yaml: [")
f = load(p)
assert f.mounts == []
def test_load_skips_unrecognised_fields(self, tmp_path):
# Forward-compat: future entries may carry extra fields; load should
# silently ignore them.
p = tmp_path / "fstab.yaml"
p.write_text(yaml.safe_dump({
"version": 1,
"mounts": [{
"device": "UUID=1",
"mount_point": "/media/a",
"fstype": "ext4",
"options": "defaults",
"future_field": "ignored",
}],
}))
f = load(p)
assert len(f.mounts) == 1
def test_save_creates_parent_directory(self, tmp_path):
nested = tmp_path / "config" / "fstab.yaml"
save(FstabFile(mounts=[FstabEntry("UUID=1", "/media/a", "ext4")]), nested)
assert nested.exists()
def test_save_is_atomic(self, tmp_path):
# We can't directly observe atomicity in a unit test, but we can
# confirm the temp file is gone after a successful write.
p = tmp_path / "fstab.yaml"
save(FstabFile(mounts=[FstabEntry("UUID=1", "/media/a", "ext4")]), p)
assert p.exists()
assert not (tmp_path / "fstab.yaml.tmp").exists()
def test_round_trip(self, tmp_path):
original = FstabFile(mounts=[
FstabEntry("UUID=1", "/media/b", "ext4", "defaults"),
FstabEntry("UUID=2", "/media/a", "exfat", "defaults,uid=1001"),
])
p = tmp_path / "fstab.yaml"
save(original, p)
loaded = load(p)
# Mount points sorted on save:
assert [m.mount_point for m in loaded.mounts] == ["/media/a", "/media/b"]
# Other fields preserved:
a = loaded.find_by_mount_point("/media/a")
assert a.device == "UUID=2"
assert a.fstype == "exfat"
assert a.options == "defaults,uid=1001"
def test_save_sorts_mounts(self, tmp_path):
p = tmp_path / "fstab.yaml"
save(FstabFile(mounts=[
FstabEntry("UUID=2", "/media/zeta", "ext4"),
FstabEntry("UUID=1", "/media/alpha", "ext4"),
]), p)
raw = yaml.safe_load(p.read_text())
assert [m["mount_point"] for m in raw["mounts"]] == ["/media/alpha", "/media/zeta"]