int:
)
result = reconciler.apply()
+ if result.desired_count == 0:
+ logger.info("%s lists no mounts; nothing to reconcile.", DEFAULT_FSTAB_PATH)
logger.info(
- "Reconcile complete: %d mounted, %d unmounted, %d mount failures, %d unmount failures",
+ "Reconcile complete: %d desired, %d already mounted, %d skipped, "
+ "%d mounted, %d unmounted, %d mount failures, %d unmount failures",
+ result.desired_count, len(result.already_mounted), len(result.skipped),
len(result.mounted), len(result.unmounted),
len(result.mount_failures), len(result.unmount_failures),
)
+ for mp in result.already_mounted:
+ logger.info("Already mounted: %s", mp)
+ for mp, reason in result.skipped:
+ logger.info("Skipped %s: %s", mp, reason)
for mp, err in result.mount_failures:
logger.error("Failed to mount %s: %s", mp, err)
for mp, err in result.unmount_failures:
diff --git a/controller/lib/reconciler.py b/controller/lib/reconciler.py
index e70066bf..8b218d3c 100644
--- a/controller/lib/reconciler.py
+++ b/controller/lib/reconciler.py
@@ -54,6 +54,11 @@ class ReconcileResult:
mount_failures: list[tuple[str, str]] = field(default_factory=list)
unmount_failures: list[tuple[str, str]] = field(default_factory=list)
skipped: list[tuple[str, str]] = field(default_factory=list)
+ # Diagnostics: how many entries fstab.yaml listed, and which of them were
+ # satisfied before this run. Together with `skipped` these explain a
+ # "nothing to do" reconcile in the journal.
+ desired_count: int = 0
+ already_mounted: list[str] = field(default_factory=list)
@property
def all_succeeded(self) -> bool:
@@ -87,6 +92,7 @@ class Reconciler:
desired = load_fstab(self._fstab_path)
live = self._executor.current_mount_points()
managed = self._read_managed()
+ result.desired_count = len(desired.mounts)
# 1. Mount each entry in fstab.yaml that isn't already live.
for entry in desired.mounts:
@@ -98,6 +104,7 @@ class Reconciler:
# Already mounted — claim ownership so a later removal can
# safely unmount it.
managed.add(entry.mount_point)
+ result.already_mounted.append(entry.mount_point)
continue
options = self._inject_uid_gid(entry)
self._ensure_dir(entry.mount_point)
diff --git a/controller/templates/index.html b/controller/templates/index.html
index c526d4a7..4dc932dd 100644
--- a/controller/templates/index.html
+++ b/controller/templates/index.html
@@ -1663,8 +1663,11 @@
`;
- const protectedMounts = ['/', '/boot', '/boot/firmware', '/media/wrolpi'];
- const isProtected = protectedMounts.includes(disk.mountpoint);
+ // The primary mount cannot be unmounted, but its persistence
+ // (/etc/fstab entry) can be toggled. Mounts outside /media
+ // (/, /boot/efi, ...) are system mounts: entirely untouchable.
+ const isPrimary = disk.mountpoint === '/media/wrolpi';
+ const isSystemMount = mounted && !isPrimary && !disk.mountpoint.startsWith('/media/');
// Check if this disk has a persistent entry (by mount point or UUID)
let isPersistent = false;
@@ -1679,7 +1682,7 @@
}
// Persist column - show for mounted drives OR unmounted persistent drives
- if ((mounted || isPersistent) && !isProtected) {
+ if ((mounted || isPersistent) && !isSystemMount) {
const mountPoint = mounted ? disk.mountpoint : persistentEntry.mount_point;
html += `
@@ -1697,10 +1700,10 @@
html += ` `;
// Actions column
- if (mounted && !isProtected) {
- html += `Unmount `;
- } else if (mounted && isProtected) {
+ if (mounted && (isSystemMount || isPrimary)) {
html += `System `;
+ } else if (mounted) {
+ html += `Unmount `;
} else {
const defaultMount = disk.label ? `/media/${disk.label}` : `/media/${disk.name}`;
html += `Mount `;
diff --git a/controller/test/test_disks.py b/controller/test/test_disks.py
index 76e271a7..a8c19b8e 100644
--- a/controller/test/test_disks.py
+++ b/controller/test/test_disks.py
@@ -268,6 +268,45 @@ class TestMountDrive:
assert result["success"] is False
assert "mount failed" in result["error"]
+ def test_ntfs_mount_runs_ntfsfix_first(self):
+ """NTFS mounts run ntfsfix -d before mount to clear the Windows dirty flag."""
+ with mock.patch("controller.lib.disks.is_docker_mode", return_value=False), \
+ mock.patch("controller.lib.disks.get_wrolpi_uid_gid", return_value=(1001, 1001)), \
+ mock.patch("pathlib.Path.mkdir"), \
+ mock.patch("subprocess.run") as mock_run:
+ mock_run.return_value = mock.Mock(returncode=0, stdout="", stderr="")
+ result = mount_drive("/dev/sdb2", "/media/test", fstype="ntfs")
+ assert result["success"] is True
+ commands = [c[0][0] for c in mock_run.call_args_list]
+ assert commands[0][:2] == ["ntfsfix", "-d"]
+ assert "/dev/sdb2" in commands[0]
+ assert commands[1][0] == "mount"
+
+ def test_ntfsfix_failure_does_not_block_mount(self):
+ """A failed ntfsfix is logged but the mount is still attempted."""
+ with mock.patch("controller.lib.disks.is_docker_mode", return_value=False), \
+ mock.patch("controller.lib.disks.get_wrolpi_uid_gid", return_value=(1001, 1001)), \
+ mock.patch("pathlib.Path.mkdir"), \
+ mock.patch("subprocess.run") as mock_run:
+ mock_run.side_effect = [
+ mock.Mock(returncode=1, stdout="", stderr="ntfsfix failed"), # ntfsfix
+ mock.Mock(returncode=0, stdout="", stderr=""), # mount
+ ]
+ result = mount_drive("/dev/sdb2", "/media/test", fstype="ntfs")
+ assert result["success"] is True
+
+ def test_ext4_mount_does_not_run_ntfsfix(self):
+ """Non-NTFS mounts must not invoke ntfsfix."""
+ with mock.patch("controller.lib.disks.is_docker_mode", return_value=False), \
+ mock.patch("pathlib.Path.mkdir"), \
+ mock.patch("subprocess.run") as mock_run:
+ mock_run.return_value = mock.Mock(returncode=0, stdout="", stderr="")
+ result = mount_drive("/dev/sda1", "/media/test", fstype="ext4")
+ assert result["success"] is True
+ commands = [c[0][0] for c in mock_run.call_args_list]
+ assert len(commands) == 1
+ assert commands[0][0] == "mount"
+
@pytest.mark.parametrize("fstype,expect_uid_gid", [
("exfat", True),
("vfat", True), # FAT32
diff --git a/controller/test/test_disks_api.py b/controller/test/test_disks_api.py
index ebb53bdf..bc9a6adc 100644
--- a/controller/test/test_disks_api.py
+++ b/controller/test/test_disks_api.py
@@ -345,6 +345,89 @@ class TestFstabMutateEndpoints:
assert response.status_code == 404
+# --- Primary mount persistence (/etc/fstab, not fstab.yaml) ----------------
+
+class TestPrimaryMountPersist:
+ """The primary mount (/media/wrolpi) persists via the host's /etc/fstab,
+ not fstab.yaml: the wrolpi-mounts reconciler refuses to manage it
+ (RESERVED_MOUNT_POINTS), so a fstab.yaml entry would never be applied."""
+
+ @pytest.fixture(autouse=True)
+ def primary_media_dir(self, monkeypatch):
+ """conftest isolates MEDIA_DIRECTORY to a tmp dir; these tests need the
+ real primary mount point so the endpoints route to /etc/fstab."""
+ monkeypatch.setenv("MEDIA_DIRECTORY", "/media/wrolpi")
+
+ def test_post_primary_routes_to_etc_fstab(self, test_client, disks_env):
+ with mock.patch(
+ "controller.api.disks.add_etc_fstab_entry",
+ return_value={"success": True, "device": "UUID=abc-123",
+ "mount_point": "/media/wrolpi"},
+ ) as add_etc:
+ response = test_client.post(
+ "/api/disks/fstab",
+ json={"device": "/dev/sdb2",
+ "mount_point": "/media/wrolpi",
+ "fstype": "ntfs"},
+ )
+ assert response.status_code == 200
+ assert response.json()["device"] == "UUID=abc-123"
+ add_etc.assert_called_once()
+ # fstab.yaml must NOT contain the primary mount.
+ assert fstab_yaml.load(disks_env["fstab_path"]).mount_points() == set()
+
+ def test_post_primary_reports_etc_fstab_failure(self, test_client, disks_env):
+ with mock.patch(
+ "controller.api.disks.add_etc_fstab_entry",
+ return_value={"success": False, "error": "WROL Mode is enabled"},
+ ):
+ response = test_client.post(
+ "/api/disks/fstab",
+ json={"device": "/dev/sdb2",
+ "mount_point": "/media/wrolpi",
+ "fstype": "ntfs"},
+ )
+ assert response.status_code == 500
+ assert "WROL Mode" in response.json()["detail"]
+
+ def test_delete_primary_routes_to_etc_fstab(self, test_client, disks_env):
+ with mock.patch(
+ "controller.api.disks.remove_etc_fstab_entry",
+ return_value={"success": True, "mount_point": "/media/wrolpi"},
+ ) as remove_etc:
+ response = test_client.delete("/api/disks/fstab/media/wrolpi")
+ assert response.status_code == 200
+ remove_etc.assert_called_once_with("/media/wrolpi")
+
+ def test_delete_primary_missing_returns_404(self, test_client, disks_env):
+ with mock.patch(
+ "controller.api.disks.remove_etc_fstab_entry",
+ return_value={"success": False,
+ "error": "No entry found for /media/wrolpi"},
+ ):
+ response = test_client.delete("/api/disks/fstab/media/wrolpi")
+ assert response.status_code == 404
+
+ def test_list_includes_primary_from_etc_fstab(self, test_client, disks_env):
+ etc_entries = [
+ {"type": "comment", "line": "# /etc/fstab\n"},
+ {"type": "mount", "device": "UUID=root", "mount_point": "/",
+ "fstype": "ext4", "options": "defaults", "dump": "0", "pass": "1"},
+ {"type": "mount", "device": "UUID=abc-123",
+ "mount_point": "/media/wrolpi", "fstype": "ntfs",
+ "options": "defaults,nofail", "dump": "0", "pass": "2"},
+ ]
+ with mock.patch("controller.api.disks.parse_etc_fstab",
+ return_value=etc_entries):
+ response = test_client.get("/api/disks/fstab")
+ assert response.status_code == 200
+ body = response.json()
+ mount_points = [e["mount_point"] for e in body]
+ # The primary appears; unrelated system mounts (e.g. /) do not.
+ assert mount_points == ["/media/wrolpi"]
+ assert body[0]["device"] == "UUID=abc-123"
+
+
# --- SMART ----------------------------------------------------------------
class TestSmartEndpoints:
diff --git a/controller/test/test_fstab.py b/controller/test/test_fstab.py
index 88049a66..ab532f16 100644
--- a/controller/test/test_fstab.py
+++ b/controller/test/test_fstab.py
@@ -218,6 +218,30 @@ UUID=5678 /media/64GB exfat defaults,nofail,x-systemd.device-timeout=10s 0 2
assert "uid=" not in written_str
assert "gid=" not in written_str
+ def test_ntfs_fstab_entry_includes_uid_gid(self):
+ """Should add uid/gid options when adding ntfs fstab entry (no POSIX permissions)."""
+ fstab_content = "# empty\n"
+ written_content = []
+
+ def mock_write(content):
+ written_content.append(content)
+
+ mock_file = mock.mock_open(read_data=fstab_content)
+ mock_file().write = mock_write
+
+ with mock.patch("controller.lib.fstab.require_normal_mode"):
+ with mock.patch("controller.lib.fstab.get_uuid", return_value="1234-5678"):
+ with mock.patch("controller.lib.fstab.backup_fstab"):
+ with mock.patch("controller.lib.fstab.get_wrolpi_uid_gid", return_value=(1001, 1001)):
+ with mock.patch("builtins.open", mock_file):
+ with mock.patch("subprocess.run"):
+ result = add_fstab_entry("/dev/sdb2", "/media/wrolpi", "ntfs")
+ assert result["success"] is True
+ written_str = "".join(written_content)
+ # Should have uid/gid options
+ assert "uid=1001" in written_str
+ assert "gid=1001" in written_str
+
def test_vfat_fstab_entry_includes_uid_gid(self):
"""Should add uid/gid options when adding vfat (FAT32) fstab entry."""
fstab_content = "# empty\n"
diff --git a/controller/test/test_reconciler.py b/controller/test/test_reconciler.py
index 65d41af2..909b9ba5 100644
--- a/controller/test/test_reconciler.py
+++ b/controller/test/test_reconciler.py
@@ -84,6 +84,15 @@ class TestMounts:
result = rec.apply()
assert result.mounted == []
assert fake.mount_calls == []
+ # Diagnostics explain the "nothing to do" run in the journal.
+ assert result.desired_count == 1
+ assert result.already_mounted == ["/media/wrolpi/usb"]
+
+ def test_empty_fstab_reports_zero_desired(self, tmp_path):
+ rec, fake = make_reconciler(tmp_path, fstab=FstabFile())
+ result = rec.apply()
+ assert result.desired_count == 0
+ assert result.already_mounted == []
def test_mount_failure_is_recorded(self, tmp_path):
fake = FakeMountExecutor(fail_for_mount=frozenset({"/media/wrolpi/usb"}))