mirror of
https://github.com/lrnselfreliance/wrolpi.git
synced 2026-10-26 19:01:39 -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>
343 lines
13 KiB
Python
343 lines
13 KiB
Python
"""
|
|
Tests for controller.lib.fstab_migration — one-time migration of
|
|
WROLPi-managed mount entries into fstab.yaml.
|
|
|
|
Sources:
|
|
- /etc/fstab (older WROLPi versions)
|
|
- /media/wrolpi/config/controller.yaml drives.mounts (intermediate)
|
|
|
|
Migration semantics:
|
|
- Runs at most once per drive (gated by .fstab-migrated marker).
|
|
- All /media/* entries (except /media/wrolpi and the temp onboarding
|
|
mount) are candidates.
|
|
- fstab.yaml gets each missing entry; existing entries are not touched.
|
|
- /etc/fstab candidates are commented in place with a "migrated to
|
|
fstab.yaml: " prefix.
|
|
- controller.yaml has drives.mounts removed after migration.
|
|
- /etc/fstab is backed up before modification.
|
|
"""
|
|
|
|
from pathlib import Path
|
|
|
|
import yaml
|
|
|
|
from controller.lib.fstab_migration import migrate_etc_fstab
|
|
from controller.lib.fstab_yaml import load as load_fstab_yaml
|
|
|
|
|
|
def _write(path: Path, content: str) -> Path:
|
|
path.write_text(content)
|
|
return path
|
|
|
|
|
|
def _write_yaml(path: Path, data: dict) -> Path:
|
|
path.write_text(yaml.safe_dump(data))
|
|
return path
|
|
|
|
|
|
class TestMigrationGating:
|
|
"""Marker file behaviour and basic short-circuits."""
|
|
|
|
def test_marker_present_is_a_no_op(self, tmp_path):
|
|
fstab = _write(tmp_path / "fstab", "UUID=1 /media/x ext4 defaults 0 2\n")
|
|
fstab_yaml = tmp_path / "fstab.yaml"
|
|
marker = tmp_path / ".fstab-migrated"
|
|
marker.touch()
|
|
|
|
result = migrate_etc_fstab(
|
|
fstab_path=fstab,
|
|
controller_yaml_path=tmp_path / "controller.yaml",
|
|
fstab_yaml_path=fstab_yaml,
|
|
marker_path=marker,
|
|
backup_path=tmp_path / "backup",
|
|
)
|
|
assert result == {"migrated": False, "reason": "already-migrated", "count": 0}
|
|
# /etc/fstab untouched, fstab.yaml not written.
|
|
assert "UUID=1 /media/x" in fstab.read_text()
|
|
assert not fstab_yaml.exists()
|
|
|
|
def test_no_sources_writes_marker_only(self, tmp_path):
|
|
marker = tmp_path / ".fstab-migrated"
|
|
result = migrate_etc_fstab(
|
|
fstab_path=tmp_path / "nonexistent-fstab",
|
|
controller_yaml_path=tmp_path / "missing.yaml",
|
|
fstab_yaml_path=tmp_path / "fstab.yaml",
|
|
marker_path=marker,
|
|
backup_path=tmp_path / "backup",
|
|
)
|
|
assert result["migrated"] is True
|
|
assert result["count"] == 0
|
|
assert marker.exists()
|
|
|
|
def test_idempotent_when_called_twice(self, tmp_path):
|
|
_write(tmp_path / "fstab",
|
|
"UUID=1 /media/wrolpi/usb ext4 defaults 0 2\n")
|
|
fstab_yaml_p = tmp_path / "fstab.yaml"
|
|
marker = tmp_path / ".fstab-migrated"
|
|
|
|
first = migrate_etc_fstab(
|
|
fstab_path=tmp_path / "fstab",
|
|
controller_yaml_path=tmp_path / "controller.yaml",
|
|
fstab_yaml_path=fstab_yaml_p,
|
|
marker_path=marker,
|
|
backup_path=tmp_path / "backup",
|
|
)
|
|
assert first["migrated"] is True
|
|
|
|
# Manually re-add an entry to simulate a stray write between runs.
|
|
# The marker should still short-circuit and leave fstab.yaml alone.
|
|
before = fstab_yaml_p.read_text()
|
|
second = migrate_etc_fstab(
|
|
fstab_path=tmp_path / "fstab",
|
|
controller_yaml_path=tmp_path / "controller.yaml",
|
|
fstab_yaml_path=fstab_yaml_p,
|
|
marker_path=marker,
|
|
backup_path=tmp_path / "backup",
|
|
)
|
|
assert second["migrated"] is False
|
|
assert second["reason"] == "already-migrated"
|
|
assert fstab_yaml_p.read_text() == before
|
|
|
|
|
|
class TestEtcFstabMigration:
|
|
|
|
def test_only_primary_entry_is_left_alone(self, tmp_path):
|
|
fstab = _write(tmp_path / "fstab", (
|
|
"UUID=root / ext4 defaults 0 1\n"
|
|
"UUID=primary /media/wrolpi ext4 defaults,nofail 0 2\n"
|
|
))
|
|
fstab_yaml_p = tmp_path / "fstab.yaml"
|
|
result = migrate_etc_fstab(
|
|
fstab_path=fstab,
|
|
controller_yaml_path=tmp_path / "controller.yaml",
|
|
fstab_yaml_path=fstab_yaml_p,
|
|
marker_path=tmp_path / "marker",
|
|
backup_path=tmp_path / "backup",
|
|
)
|
|
assert result == {"migrated": True, "count": 0}
|
|
# Primary still in fstab, intact.
|
|
assert "UUID=primary /media/wrolpi ext4" in fstab.read_text()
|
|
# fstab.yaml not written when no candidates.
|
|
assert not fstab_yaml_p.exists()
|
|
|
|
def test_secondary_entry_is_migrated(self, tmp_path):
|
|
fstab = _write(tmp_path / "fstab", (
|
|
"UUID=root / ext4 defaults 0 1\n"
|
|
"UUID=primary /media/wrolpi ext4 defaults 0 2\n"
|
|
"# WROLPi managed mount - added 2025-01-01T00:00:00\n"
|
|
"UUID=secondary /media/wrolpi/2TB ext4 defaults,nofail 0 2\n"
|
|
))
|
|
fstab_yaml_p = tmp_path / "fstab.yaml"
|
|
result = migrate_etc_fstab(
|
|
fstab_path=fstab,
|
|
controller_yaml_path=tmp_path / "controller.yaml",
|
|
fstab_yaml_path=fstab_yaml_p,
|
|
marker_path=tmp_path / "marker",
|
|
backup_path=tmp_path / "backup",
|
|
)
|
|
assert result["count"] == 1
|
|
data = load_fstab_yaml(fstab_yaml_p)
|
|
assert len(data.mounts) == 1
|
|
e = data.mounts[0]
|
|
assert e.device == "UUID=secondary"
|
|
assert e.mount_point == "/media/wrolpi/2TB"
|
|
assert e.fstype == "ext4"
|
|
|
|
new_fstab = fstab.read_text()
|
|
active_lines = [
|
|
ln.strip() for ln in new_fstab.splitlines()
|
|
if ln.strip() and not ln.strip().startswith("#")
|
|
]
|
|
# Migrated line no longer active.
|
|
assert not any("/media/wrolpi/2TB" in ln for ln in active_lines)
|
|
# Commented form preserved.
|
|
assert "migrated to fstab.yaml" in new_fstab
|
|
|
|
def test_dedup_against_existing_fstab_yaml(self, tmp_path):
|
|
from controller.lib.fstab_yaml import FstabEntry, FstabFile, save
|
|
save(FstabFile(mounts=[
|
|
FstabEntry("UUID=secondary", "/media/wrolpi/2TB", "ext4", "defaults"),
|
|
]), tmp_path / "fstab.yaml")
|
|
|
|
fstab = _write(tmp_path / "fstab", (
|
|
"UUID=primary /media/wrolpi ext4 defaults 0 2\n"
|
|
"UUID=secondary /media/wrolpi/2TB ext4 defaults 0 2\n"
|
|
))
|
|
result = migrate_etc_fstab(
|
|
fstab_path=fstab,
|
|
controller_yaml_path=tmp_path / "controller.yaml",
|
|
fstab_yaml_path=tmp_path / "fstab.yaml",
|
|
marker_path=tmp_path / "marker",
|
|
backup_path=tmp_path / "backup",
|
|
)
|
|
# Same mount point already in fstab.yaml; no new entry but /etc/fstab
|
|
# line is still commented out (cleanup).
|
|
assert result["count"] == 0
|
|
data = load_fstab_yaml(tmp_path / "fstab.yaml")
|
|
assert len(data.mounts) == 1
|
|
assert "migrated to fstab.yaml" in fstab.read_text()
|
|
|
|
def test_non_media_entries_untouched(self, tmp_path):
|
|
fstab = _write(tmp_path / "fstab", (
|
|
"UUID=root / ext4 defaults 0 1\n"
|
|
"UUID=home /home ext4 defaults 0 2\n"
|
|
"tmpfs /tmp tmpfs defaults 0 0\n"
|
|
))
|
|
migrate_etc_fstab(
|
|
fstab_path=fstab,
|
|
controller_yaml_path=tmp_path / "controller.yaml",
|
|
fstab_yaml_path=tmp_path / "fstab.yaml",
|
|
marker_path=tmp_path / "marker",
|
|
backup_path=tmp_path / "backup",
|
|
)
|
|
new_fstab = fstab.read_text()
|
|
for ln in ("UUID=root / ext4", "UUID=home /home ext4", "tmpfs /tmp"):
|
|
assert ln in new_fstab
|
|
assert f"migrated to fstab.yaml: {ln}" not in new_fstab
|
|
|
|
def test_excluded_mount_points_skipped(self, tmp_path):
|
|
fstab = _write(
|
|
tmp_path / "fstab",
|
|
"UUID=temp /media/wrolpi_temp_onboarding ext4 defaults 0 2\n")
|
|
result = migrate_etc_fstab(
|
|
fstab_path=fstab,
|
|
controller_yaml_path=tmp_path / "controller.yaml",
|
|
fstab_yaml_path=tmp_path / "fstab.yaml",
|
|
marker_path=tmp_path / "marker",
|
|
backup_path=tmp_path / "backup",
|
|
)
|
|
assert result["count"] == 0
|
|
# /etc/fstab not commented out — never claimed.
|
|
assert "/media/wrolpi_temp_onboarding" in fstab.read_text()
|
|
|
|
def test_backup_written_when_changes_made(self, tmp_path):
|
|
fstab_content = "UUID=secondary /media/wrolpi/2TB ext4 defaults 0 2\n"
|
|
fstab = _write(tmp_path / "fstab", fstab_content)
|
|
backup = tmp_path / "backup"
|
|
|
|
migrate_etc_fstab(
|
|
fstab_path=fstab,
|
|
controller_yaml_path=tmp_path / "controller.yaml",
|
|
fstab_yaml_path=tmp_path / "fstab.yaml",
|
|
marker_path=tmp_path / "marker",
|
|
backup_path=backup,
|
|
)
|
|
assert backup.exists()
|
|
assert backup.read_text() == fstab_content
|
|
|
|
|
|
class TestControllerYamlMigration:
|
|
"""drives.mounts in controller.yaml gets lifted into fstab.yaml and
|
|
the key is stripped from the controller.yaml file."""
|
|
|
|
def test_drives_mounts_migrated_to_fstab_yaml(self, tmp_path):
|
|
controller_yaml = _write_yaml(tmp_path / "controller.yaml", {
|
|
"port": 80,
|
|
"drives": {
|
|
"mounts": [{
|
|
"device": "UUID=secondary",
|
|
"mount_point": "/media/wrolpi/2TB",
|
|
"fstype": "ext4",
|
|
"options": "defaults",
|
|
}],
|
|
},
|
|
})
|
|
fstab_yaml_p = tmp_path / "fstab.yaml"
|
|
result = migrate_etc_fstab(
|
|
fstab_path=tmp_path / "nonexistent-fstab",
|
|
controller_yaml_path=controller_yaml,
|
|
fstab_yaml_path=fstab_yaml_p,
|
|
marker_path=tmp_path / "marker",
|
|
backup_path=tmp_path / "backup",
|
|
)
|
|
assert result["count"] == 1
|
|
data = load_fstab_yaml(fstab_yaml_p)
|
|
assert data.mount_points() == {"/media/wrolpi/2TB"}
|
|
|
|
# drives.mounts stripped, other keys preserved.
|
|
post = yaml.safe_load(controller_yaml.read_text())
|
|
assert post.get("port") == 80
|
|
assert "mounts" not in (post.get("drives") or {})
|
|
|
|
def test_drives_mounts_with_excluded_paths(self, tmp_path):
|
|
# The reserved mount points should not be migrated; they should
|
|
# remain in drives.mounts? — actually no, drives.mounts is being
|
|
# retired entirely. We migrate all valid candidates and drop the
|
|
# key regardless. Reserved entries are silently filtered out.
|
|
controller_yaml = _write_yaml(tmp_path / "controller.yaml", {
|
|
"drives": {
|
|
"mounts": [
|
|
{"device": "UUID=primary",
|
|
"mount_point": "/media/wrolpi",
|
|
"fstype": "ext4",
|
|
"options": "defaults"},
|
|
{"device": "UUID=secondary",
|
|
"mount_point": "/media/wrolpi/2TB",
|
|
"fstype": "ext4",
|
|
"options": "defaults"},
|
|
],
|
|
},
|
|
})
|
|
fstab_yaml_p = tmp_path / "fstab.yaml"
|
|
result = migrate_etc_fstab(
|
|
fstab_path=tmp_path / "nonexistent-fstab",
|
|
controller_yaml_path=controller_yaml,
|
|
fstab_yaml_path=fstab_yaml_p,
|
|
marker_path=tmp_path / "marker",
|
|
backup_path=tmp_path / "backup",
|
|
)
|
|
# Only the secondary made it across.
|
|
assert result["count"] == 1
|
|
data = load_fstab_yaml(fstab_yaml_p)
|
|
assert data.mount_points() == {"/media/wrolpi/2TB"}
|
|
|
|
def test_no_drives_key_is_a_noop(self, tmp_path):
|
|
controller_yaml = _write_yaml(tmp_path / "controller.yaml", {"port": 80})
|
|
fstab_yaml_p = tmp_path / "fstab.yaml"
|
|
result = migrate_etc_fstab(
|
|
fstab_path=tmp_path / "nonexistent-fstab",
|
|
controller_yaml_path=controller_yaml,
|
|
fstab_yaml_path=fstab_yaml_p,
|
|
marker_path=tmp_path / "marker",
|
|
backup_path=tmp_path / "backup",
|
|
)
|
|
assert result["count"] == 0
|
|
# controller.yaml untouched.
|
|
assert yaml.safe_load(controller_yaml.read_text()) == {"port": 80}
|
|
|
|
|
|
class TestCombinedSources:
|
|
"""Both /etc/fstab AND drives.mounts contain entries — merge dedup."""
|
|
|
|
def test_merges_both_sources_dedup_by_mount_point(self, tmp_path):
|
|
fstab = _write(tmp_path / "fstab",
|
|
"UUID=a /media/wrolpi/a ext4 defaults 0 2\n"
|
|
"UUID=both /media/wrolpi/both ext4 defaults 0 2\n")
|
|
controller_yaml = _write_yaml(tmp_path / "controller.yaml", {
|
|
"drives": {
|
|
"mounts": [
|
|
{"device": "UUID=both",
|
|
"mount_point": "/media/wrolpi/both",
|
|
"fstype": "ext4",
|
|
"options": "defaults"},
|
|
{"device": "UUID=b",
|
|
"mount_point": "/media/wrolpi/b",
|
|
"fstype": "ext4",
|
|
"options": "defaults"},
|
|
],
|
|
},
|
|
})
|
|
fstab_yaml_p = tmp_path / "fstab.yaml"
|
|
result = migrate_etc_fstab(
|
|
fstab_path=fstab,
|
|
controller_yaml_path=controller_yaml,
|
|
fstab_yaml_path=fstab_yaml_p,
|
|
marker_path=tmp_path / "marker",
|
|
backup_path=tmp_path / "backup",
|
|
)
|
|
# a (fstab only), both (both sources, dedup), b (controller only)
|
|
assert result["count"] == 3
|
|
data = load_fstab_yaml(fstab_yaml_p)
|
|
assert data.mount_points() == {
|
|
"/media/wrolpi/a", "/media/wrolpi/both", "/media/wrolpi/b",
|
|
}
|