diff --git a/controller/api/disks.py b/controller/api/disks.py index 05a220fc..6ccaecdc 100644 --- a/controller/api/disks.py +++ b/controller/api/disks.py @@ -32,6 +32,7 @@ from controller.lib.disks import ( get_block_devices, get_mounts, get_uuid, + mount_drive, validate_mount_point, ) from controller.lib.fstab_yaml import ( @@ -198,6 +199,34 @@ async def disk_mount(request: MountRequest): except ValueError as e: raise HTTPException(status_code=400, detail=str(e)) + if request.mount_point == str(get_media_directory()): + # The primary mount is reserved: the reconciler never applies a + # fstab.yaml entry for it. Mount it directly and persist via the + # host's /etc/fstab, as onboarding does. + result = mount_drive(request.device, request.mount_point, + fstype=request.fstype, + options=request.options or "defaults") + if not result.get("success"): + raise HTTPException( + status_code=500, + detail=f"Failed to mount {request.mount_point}: {result.get('error')}") + persisted = add_etc_fstab_entry(request.device, request.mount_point, + request.fstype or "auto") + if not persisted.get("success"): + logger.warning("Mounted %s but could not persist to /etc/fstab: %s", + request.mount_point, persisted.get("error")) + # Self-heal: drop any stale fstab.yaml entry for the primary mount + # (written by older Controllers); the reconciler only ever skips it. + data = load_fstab() + if data.remove_by_mount_point(request.mount_point): + save_fstab(data) + return { + "success": True, + "device": persisted.get("device") or request.device, + "mount_point": request.mount_point, + "persisted": persisted.get("success", False), + } + device_spec = _to_uuid_spec(request.device) entry = FstabEntry( device=device_spec, @@ -311,6 +340,8 @@ async def list_fstab(): "pass": "2", } for e in data.mounts + # Reserved mount points in fstab.yaml are never applied; hide them. + if e.mount_point not in RESERVED_MOUNT_POINTS ] # The primary mount persists via the host's /etc/fstab (the reconciler # refuses to manage it) — include it so UIs can show its Persist state. @@ -358,6 +389,10 @@ async def add_fstab(request: FstabRequest): if not result.get("success"): raise HTTPException(status_code=500, detail=result.get("error") or "Failed to update /etc/fstab") + # Self-heal: drop any stale fstab.yaml entry for the primary mount. + data = load_fstab() + if data.remove_by_mount_point(request.mount_point): + save_fstab(data) return {"success": True, "device": result.get("device"), "mount_point": request.mount_point} @@ -395,7 +430,12 @@ async def delete_fstab(mount_point: str): if mount_point == str(get_media_directory()): result = remove_etc_fstab_entry(mount_point) - if not result.get("success"): + # Also drop any stale fstab.yaml entry for the primary mount. + data = load_fstab() + removed_phantom = data.remove_by_mount_point(mount_point) + if removed_phantom: + save_fstab(data) + if not result.get("success") and not removed_phantom: error = result.get("error") or f"No entry found for {mount_point}" status = 404 if "No entry found" in error else 500 raise HTTPException(status_code=status, detail=error) diff --git a/controller/test/test_disks_api.py b/controller/test/test_disks_api.py index bc9a6adc..ab63c78b 100644 --- a/controller/test/test_disks_api.py +++ b/controller/test/test_disks_api.py @@ -408,6 +408,80 @@ class TestPrimaryMountPersist: response = test_client.delete("/api/disks/fstab/media/wrolpi") assert response.status_code == 404 + def test_mount_primary_mounts_directly_and_persists_to_etc_fstab( + self, test_client, disks_env): + """POST /disks/mount for the primary mount bypasses fstab.yaml/the + reconciler entirely: mount_drive + /etc/fstab, and any stale + fstab.yaml phantom entry is removed.""" + from controller.lib.fstab_yaml import FstabEntry, FstabFile, save + # Phantom left by an older Controller: reconciler only ever skips it. + save(FstabFile(mounts=[ + FstabEntry("UUID=abc", "/media/wrolpi", "ntfs", "defaults"), + ]), disks_env["fstab_path"]) + + with mock.patch("controller.api.disks.mount_drive", + return_value={"success": True, + "mount_point": "/media/wrolpi"}) as m_mount, \ + mock.patch("controller.api.disks.add_etc_fstab_entry", + return_value={"success": True, "device": "UUID=abc", + "mount_point": "/media/wrolpi"}) as m_persist: + response = test_client.post( + "/api/disks/mount", + json={"device": "/dev/sdb2", + "mount_point": "/media/wrolpi", + "fstype": "ntfs", + "force_shadowed": True}, + ) + assert response.status_code == 200 + assert response.json()["persisted"] is True + m_mount.assert_called_once() + m_persist.assert_called_once() + # The reconciler's executor was never asked to mount anything. + assert disks_env["fake"].mount_calls == [] + # The phantom fstab.yaml entry was cleaned up. + assert fstab_yaml.load(disks_env["fstab_path"]).mount_points() == set() + + def test_mount_primary_failure_returns_500(self, test_client, disks_env): + with mock.patch("controller.api.disks.mount_drive", + return_value={"success": False, "error": "unclean NTFS"}): + response = test_client.post( + "/api/disks/mount", + json={"device": "/dev/sdb2", + "mount_point": "/media/wrolpi", + "fstype": "ntfs", + "force_shadowed": True}, + ) + assert response.status_code == 500 + assert "unclean NTFS" in response.json()["detail"] + + def test_list_hides_phantom_primary_yaml_entry(self, test_client, disks_env): + """A reserved mount point in fstab.yaml is never applied, so it must + not be reported as persistence.""" + from controller.lib.fstab_yaml import FstabEntry, FstabFile, save + save(FstabFile(mounts=[ + FstabEntry("UUID=abc", "/media/wrolpi", "ntfs", "defaults"), + FstabEntry("UUID=def", "/media/wrolpi/usb", "ext4", "defaults"), + ]), disks_env["fstab_path"]) + with mock.patch("controller.api.disks.parse_etc_fstab", return_value=[]): + response = test_client.get("/api/disks/fstab") + assert response.status_code == 200 + assert [e["mount_point"] for e in response.json()] == ["/media/wrolpi/usb"] + + def test_delete_primary_cleans_phantom_yaml_entry(self, test_client, disks_env): + """DELETE succeeds when only the phantom fstab.yaml entry exists.""" + from controller.lib.fstab_yaml import FstabEntry, FstabFile, save + save(FstabFile(mounts=[ + FstabEntry("UUID=abc", "/media/wrolpi", "ntfs", "defaults"), + ]), disks_env["fstab_path"]) + 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 == 200 + assert fstab_yaml.load(disks_env["fstab_path"]).mount_points() == set() + def test_list_includes_primary_from_etc_fstab(self, test_client, disks_env): etc_entries = [ {"type": "comment", "line": "# /etc/fstab\n"},