mirror of
https://github.com/lrnselfreliance/wrolpi.git
synced 2026-10-26 10:51:36 -04:00
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>
167 lines
5.9 KiB
Python
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"]
|