Disks: mount the primary drive directly; clean phantom fstab.yaml entries.

POST /disks/mount wrote every mount into fstab.yaml and waited for the
reconciler — but the reconciler skips /media/wrolpi as a reserved mount
point, so mounting the primary drive from the Disks page could never
succeed and left a phantom fstab.yaml entry behind (visible as
"1 desired, 1 skipped: reserved mount point" in the journal).  Mount the
primary drive directly via mount_drive() and persist it to /etc/fstab,
as onboarding does.

Phantom entries are now also invisible to the fstab list endpoint (they
were reported as active persistence) and are removed whenever the
primary mount is mounted, persisted, or un-persisted.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Roland 2026-07-05 15:05:20 -06:00
parent c25902b92a
commit 2e660cdbb9
No known key found for this signature in database
GPG key ID: 1BA620CE6C9A4680
2 changed files with 115 additions and 1 deletions

View file

@ -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)

View file

@ -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"},