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>
317 lines
14 KiB
Python
317 lines
14 KiB
Python
"""
|
|
Integration tests for Controller disks API endpoints.
|
|
|
|
The API writes fstab.yaml and asks wrolpi-mounts.service to reconcile.
|
|
Tests inject:
|
|
|
|
- a FakeMountExecutor in place of the production SubprocessMountExecutor,
|
|
- a fake ``_trigger_reconcile`` that simulates the service by running the
|
|
real Reconciler against the fake executor + a tmp_path fstab.yaml.
|
|
|
|
The only mocks are ``mock.patch`` for the read helpers (get_block_devices,
|
|
get_mounts, get_uuid, SMART) that touch the host's real disks. The
|
|
mount/unmount/fstab paths use real I/O against tmp_path and the
|
|
FakeMountExecutor.
|
|
"""
|
|
|
|
from pathlib import Path
|
|
from unittest import mock
|
|
|
|
import pytest
|
|
|
|
from controller.lib import fstab_yaml
|
|
from controller.lib.mount_executor import FakeMountExecutor
|
|
from controller.lib.reconciler import Reconciler
|
|
|
|
|
|
@pytest.fixture
|
|
def disks_env(monkeypatch, tmp_path):
|
|
"""Replace production wiring with a real Reconciler driving a fake
|
|
executor against a tmp_path fstab.yaml. Returns ``(fake, fstab_path)``.
|
|
|
|
Calling the API mutates the real fstab.yaml on disk and then runs the
|
|
real Reconciler.apply() — verifying the same code path that boot uses.
|
|
"""
|
|
fstab_path = tmp_path / "fstab.yaml"
|
|
managed_path = tmp_path / "managed"
|
|
monkeypatch.setattr(fstab_yaml, "DEFAULT_PATH", fstab_path)
|
|
|
|
fake = FakeMountExecutor()
|
|
|
|
def fake_trigger():
|
|
# Run the real reconciler. This is exactly the work the service
|
|
# would do when systemctl restarts it.
|
|
Reconciler(
|
|
executor=fake,
|
|
fstab_path=fstab_path,
|
|
managed_path=managed_path,
|
|
wrolpi_uid=1001,
|
|
wrolpi_gid=1001,
|
|
).apply()
|
|
return True, None
|
|
|
|
from controller.api import disks as disks_module
|
|
monkeypatch.setattr(disks_module, "_executor", fake)
|
|
monkeypatch.setattr(disks_module, "_trigger_reconcile", fake_trigger)
|
|
|
|
return {"fake": fake, "fstab_path": fstab_path}
|
|
|
|
|
|
# --- Docker mode ----------------------------------------------------------
|
|
|
|
class TestDockerModeRejectsDisksEndpoints:
|
|
"""All /api/disks/* endpoints should return 501 in Docker mode."""
|
|
|
|
@pytest.mark.parametrize("method,endpoint,payload", [
|
|
("get", "/api/disks", None),
|
|
("get", "/api/disks/mounts", None),
|
|
("post", "/api/disks/mount", {"device": "/dev/sda1", "mount_point": "/media/test"}),
|
|
("post", "/api/disks/unmount", {"mount_point": "/media/test"}),
|
|
("get", "/api/disks/fstab", None),
|
|
("post", "/api/disks/fstab", {"device": "/dev/sda1", "mount_point": "/media/test", "fstype": "ext4"}),
|
|
("delete", "/api/disks/fstab/media/test", None),
|
|
("get", "/api/disks/smart", None),
|
|
])
|
|
def test_disks_endpoint_rejected_in_docker(
|
|
self, test_client_docker_mode, method, endpoint, payload,
|
|
):
|
|
client_call = getattr(test_client_docker_mode, method)
|
|
response = client_call(endpoint, json=payload) if payload else client_call(endpoint)
|
|
assert response.status_code == 501
|
|
|
|
|
|
# --- Read-only endpoints --------------------------------------------------
|
|
|
|
class TestDisksListEndpoint:
|
|
|
|
def test_returns_list_in_native_mode(self, test_client):
|
|
with mock.patch("controller.api.disks.get_block_devices", return_value=[]):
|
|
response = test_client.get("/api/disks")
|
|
assert response.status_code == 200
|
|
assert isinstance(response.json(), list)
|
|
|
|
|
|
class TestMountsEndpoint:
|
|
|
|
def test_returns_mounts(self, test_client):
|
|
mock_mounts = [{"mount_point": "/media/wrolpi", "device": "/dev/sda1"}]
|
|
with mock.patch("controller.api.disks.get_mounts", return_value=mock_mounts):
|
|
response = test_client.get("/api/disks/mounts")
|
|
assert response.status_code == 200
|
|
assert response.json() == mock_mounts
|
|
|
|
|
|
# --- /api/disks/mount -----------------------------------------------------
|
|
|
|
class TestMountEndpoint:
|
|
|
|
def test_mounts_drive_successfully(self, test_client, disks_env):
|
|
with mock.patch("controller.api.disks.check_shadowed_data", return_value=None), \
|
|
mock.patch("controller.api.disks.get_uuid", return_value="abc-123"):
|
|
response = test_client.post(
|
|
"/api/disks/mount",
|
|
json={"device": "/dev/sda1", "mount_point": "/media/wrolpi/usb",
|
|
"fstype": "ext4"},
|
|
)
|
|
assert response.status_code == 200
|
|
body = response.json()
|
|
assert body["success"] is True
|
|
assert body["device"] == "UUID=abc-123"
|
|
assert body["mount_point"] == "/media/wrolpi/usb"
|
|
# Drive is now in fstab.yaml AND live in the fake executor.
|
|
assert disks_env["fake"].current_mount_points() == {"/media/wrolpi/usb"}
|
|
data = fstab_yaml.load(disks_env["fstab_path"])
|
|
assert data.mount_points() == {"/media/wrolpi/usb"}
|
|
|
|
def test_returns_500_when_reconciler_fails(self, test_client, disks_env, monkeypatch):
|
|
# Force the fake executor to refuse the mount; reconciler runs but
|
|
# the live state will not include the mount point.
|
|
disks_env["fake"].fail_for_mount = frozenset({"/media/wrolpi/usb"})
|
|
with mock.patch("controller.api.disks.check_shadowed_data", return_value=None), \
|
|
mock.patch("controller.api.disks.get_uuid", return_value="abc-123"):
|
|
response = test_client.post(
|
|
"/api/disks/mount",
|
|
json={"device": "/dev/sda1", "mount_point": "/media/wrolpi/usb",
|
|
"fstype": "ext4"},
|
|
)
|
|
assert response.status_code == 500
|
|
assert "did not mount" in response.json()["detail"]
|
|
# The entry is still in fstab.yaml — it'll be retried next reconcile.
|
|
assert fstab_yaml.load(disks_env["fstab_path"]).mount_points() == {"/media/wrolpi/usb"}
|
|
|
|
def test_replaces_existing_entry_for_same_device(self, test_client, disks_env):
|
|
# Seed fstab.yaml with an existing entry, then mount the same device
|
|
# to a different path. The old row should drop, not accumulate.
|
|
from controller.lib.fstab_yaml import FstabEntry, FstabFile, save
|
|
save(FstabFile(mounts=[FstabEntry(
|
|
"UUID=abc-123", "/media/wrolpi/old", "ext4", "defaults")]),
|
|
disks_env["fstab_path"])
|
|
|
|
with mock.patch("controller.api.disks.check_shadowed_data", return_value=None), \
|
|
mock.patch("controller.api.disks.get_uuid", return_value="abc-123"):
|
|
response = test_client.post(
|
|
"/api/disks/mount",
|
|
json={"device": "/dev/sda1", "mount_point": "/media/wrolpi/new",
|
|
"fstype": "ext4"},
|
|
)
|
|
assert response.status_code == 200
|
|
data = fstab_yaml.load(disks_env["fstab_path"])
|
|
assert data.mount_points() == {"/media/wrolpi/new"}
|
|
|
|
def test_shadowed_data_soft_blocks_mount(self, test_client, disks_env):
|
|
shadowed = {"size_bytes": 1024, "entries": ["videos"]}
|
|
with mock.patch("controller.api.disks.check_shadowed_data", return_value=shadowed):
|
|
response = test_client.post(
|
|
"/api/disks/mount",
|
|
json={"device": "/dev/sda1", "mount_point": "/media/wrolpi/usb"},
|
|
)
|
|
assert response.status_code == 200
|
|
body = response.json()
|
|
assert body["success"] is False
|
|
assert body["needs_force"] == "shadowed"
|
|
# No fstab.yaml mutation.
|
|
assert disks_env["fstab_path"].exists() is False
|
|
assert disks_env["fake"].mount_calls == []
|
|
|
|
def test_force_shadowed_overrides_soft_block(self, test_client, disks_env):
|
|
shadowed = {"size_bytes": 1, "entries": ["a"]}
|
|
with mock.patch("controller.api.disks.check_shadowed_data", return_value=shadowed) as chk, \
|
|
mock.patch("controller.api.disks.get_uuid", return_value="abc-123"):
|
|
response = test_client.post(
|
|
"/api/disks/mount",
|
|
json={
|
|
"device": "/dev/sda1",
|
|
"mount_point": "/media/wrolpi/usb",
|
|
"fstype": "ext4",
|
|
"force_shadowed": True,
|
|
},
|
|
)
|
|
assert response.status_code == 200
|
|
chk.assert_not_called()
|
|
assert "/media/wrolpi/usb" in disks_env["fake"].current_mount_points()
|
|
|
|
|
|
# --- /api/disks/unmount ---------------------------------------------------
|
|
|
|
class TestUnmountEndpoint:
|
|
|
|
def test_unmounts_and_removes_from_fstab(self, test_client, disks_env):
|
|
# Seed: drive is live AND in fstab.yaml AND managed by reconciler.
|
|
from controller.lib.fstab_yaml import FstabEntry, FstabFile, save
|
|
save(FstabFile(mounts=[FstabEntry(
|
|
"UUID=abc-123", "/media/wrolpi/usb", "ext4", "defaults")]),
|
|
disks_env["fstab_path"])
|
|
disks_env["fake"]._live.add("/media/wrolpi/usb")
|
|
# Tell reconciler we own it (so a removal triggers an unmount).
|
|
(disks_env["fstab_path"].parent / "managed").write_text(
|
|
"/media/wrolpi/usb\n")
|
|
|
|
response = test_client.post(
|
|
"/api/disks/unmount",
|
|
json={"mount_point": "/media/wrolpi/usb"},
|
|
)
|
|
assert response.status_code == 200
|
|
body = response.json()
|
|
assert body["success"] is True
|
|
assert body["removed_from_fstab"] is True
|
|
# Live unmounted, fstab.yaml empty.
|
|
assert disks_env["fake"].current_mount_points() == set()
|
|
assert fstab_yaml.load(disks_env["fstab_path"]).mounts == []
|
|
|
|
def test_unmount_returns_500_when_still_mounted(self, test_client, disks_env):
|
|
# Reconciler refuses the unmount — surface the failure.
|
|
disks_env["fake"]._live.add("/media/wrolpi/usb")
|
|
disks_env["fake"].fail_for_unmount = frozenset({"/media/wrolpi/usb"})
|
|
(disks_env["fstab_path"].parent / "managed").write_text(
|
|
"/media/wrolpi/usb\n")
|
|
|
|
response = test_client.post(
|
|
"/api/disks/unmount",
|
|
json={"mount_point": "/media/wrolpi/usb"},
|
|
)
|
|
assert response.status_code == 500
|
|
assert "still mounted" in response.json()["detail"]
|
|
|
|
|
|
# --- /api/disks/fstab read endpoint ---------------------------------------
|
|
|
|
class TestFstabReadEndpoint:
|
|
|
|
def test_returns_empty_when_no_fstab_yaml(self, test_client, disks_env):
|
|
response = test_client.get("/api/disks/fstab")
|
|
assert response.status_code == 200
|
|
assert response.json() == []
|
|
|
|
def test_returns_entries_in_legacy_shape(self, test_client, disks_env):
|
|
from controller.lib.fstab_yaml import FstabEntry, FstabFile, save
|
|
save(FstabFile(mounts=[
|
|
FstabEntry("UUID=abc", "/media/wrolpi/usb", "ext4", "defaults"),
|
|
]), disks_env["fstab_path"])
|
|
response = test_client.get("/api/disks/fstab")
|
|
assert response.status_code == 200
|
|
body = response.json()
|
|
assert len(body) == 1
|
|
assert body[0]["type"] == "mount"
|
|
assert body[0]["device"] == "UUID=abc"
|
|
assert body[0]["mount_point"] == "/media/wrolpi/usb"
|
|
|
|
|
|
# --- /api/disks/fstab POST / DELETE (no reconcile) ------------------------
|
|
|
|
class TestFstabMutateEndpoints:
|
|
"""POST and DELETE on /fstab modify fstab.yaml without triggering a
|
|
reconcile — used to stage changes without immediately applying."""
|
|
|
|
def test_post_adds_entry_to_fstab_only(self, test_client, disks_env):
|
|
with mock.patch("controller.api.disks.get_uuid", return_value="abc-123"):
|
|
response = test_client.post(
|
|
"/api/disks/fstab",
|
|
json={"device": "/dev/sda1",
|
|
"mount_point": "/media/wrolpi/usb",
|
|
"fstype": "ext4"},
|
|
)
|
|
assert response.status_code == 200
|
|
assert response.json()["device"] == "UUID=abc-123"
|
|
# fstab.yaml updated, but the executor was NOT asked to mount.
|
|
assert fstab_yaml.load(disks_env["fstab_path"]).mount_points() == {"/media/wrolpi/usb"}
|
|
assert disks_env["fake"].mount_calls == []
|
|
|
|
def test_delete_removes_entry_from_fstab_only(self, test_client, disks_env):
|
|
from controller.lib.fstab_yaml import FstabEntry, FstabFile, save
|
|
save(FstabFile(mounts=[
|
|
FstabEntry("UUID=abc", "/media/wrolpi/usb", "ext4", "defaults"),
|
|
FstabEntry("UUID=def", "/media/wrolpi/other", "ext4", "defaults"),
|
|
]), disks_env["fstab_path"])
|
|
|
|
response = test_client.delete("/api/disks/fstab/media/wrolpi/usb")
|
|
assert response.status_code == 200
|
|
# Other entry preserved.
|
|
remaining = fstab_yaml.load(disks_env["fstab_path"]).mount_points()
|
|
assert remaining == {"/media/wrolpi/other"}
|
|
# No unmount issued — DELETE is config-only.
|
|
assert disks_env["fake"].unmount_calls == []
|
|
|
|
def test_delete_returns_404_for_missing_entry(self, test_client, disks_env):
|
|
response = test_client.delete("/api/disks/fstab/media/missing")
|
|
assert response.status_code == 404
|
|
|
|
|
|
# --- SMART ----------------------------------------------------------------
|
|
|
|
class TestSmartEndpoints:
|
|
|
|
def test_list_smart_when_not_available(self, test_client):
|
|
with mock.patch("controller.api.disks.is_smart_available", return_value=False):
|
|
response = test_client.get("/api/disks/smart")
|
|
assert response.status_code == 200
|
|
assert response.json()["available"] is False
|
|
|
|
def test_list_smart_returns_drives(self, test_client):
|
|
mock_drives = [{"device": "sda", "assessment": "PASS"}]
|
|
with mock.patch("controller.api.disks.is_smart_available", return_value=True), \
|
|
mock.patch("controller.api.disks.get_all_smart_status", return_value=mock_drives):
|
|
response = test_client.get("/api/disks/smart")
|
|
assert response.status_code == 200
|
|
body = response.json()
|
|
assert body["available"] is True
|
|
assert len(body["drives"]) == 1
|