controller: allow unmounting the primary drive, stopping the API first

Support swapping in a backup drive: /api/disks/unmount now accepts the
primary mount (previously rejected as reserved). It stops wrolpi-api so
open files are released, then umounts directly since the reconciler
never manages the primary mount. The /etc/fstab entry is left in place
so a reboot restores the mount.

Both UIs now offer Unmount for the primary mount and warn that the API
service will be stopped.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Roland 2026-07-23 19:09:23 -06:00 • committed by lrnselfreliance
parent 5a11e23a56
commit 43f6ed2b8e
5 changed files with 209 additions and 31 deletions

View file

@ -13,6 +13,11 @@ not claimed, so foreign mounts (udisks2 desktop automounts, manual
mounts) under /media/ would otherwise be impossible to remove from the
UI. When a reconcile leaves the requested mount point live, /unmount
falls back to a direct umount(8) for non-reserved /media/ paths.
The primary mount (the media directory) is also unmountable, to support
swapping in a backup drive: the wrolpi-api service is stopped first so
it releases open files, then the mount is released with a direct
umount(8). See _unmount_primary().
"""
import logging
@ -49,6 +54,7 @@ from controller.lib.smart import (
get_all_smart_status,
is_smart_available,
)
from controller.lib.systemd import stop_service
from controller.lib.wrol_mode import require_normal_mode
logger = logging.getLogger(__name__)
@ -260,6 +266,35 @@ async def disk_mount(request: MountRequest):
}
def _unmount_primary(normalized: str) -> dict:
"""Unmount the primary WROLPi drive (drive swap).
The API service holds files open on the drive, so it is stopped first;
a failed stop is not fatal — the umount result decides success. The
reconciler never manages the primary mount, so umount directly. The
/etc/fstab entry is left in place so a reboot restores the mount.
"""
stopped = stop_service("wrolpi-api")
if not stopped.get("success"):
logger.warning("Could not stop wrolpi-api before unmounting %s: %s",
normalized, stopped.get("error"))
if normalized in _executor.current_mount_points():
result = _executor.unmount(normalized)
if not result.ok:
raise HTTPException(
status_code=500,
detail=f"{normalized} is still mounted: {result.error}",
)
return {
"success": True,
"mount_point": normalized,
"removed_from_fstab": False,
"stopped_services": ["wrolpi-api"] if stopped.get("success") else [],
}
@router.post("/unmount")
async def disk_unmount(request: UnmountRequest):
"""Remove a mount from fstab.yaml and reconcile.
@ -280,21 +315,22 @@ async def disk_unmount(request: UnmountRequest):
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
# The direct-umount fallback below runs as root, so be strict about
# what we will pass to umount(8): a normalized path strictly under
# /media/ that is not the primary drive.
# The direct-umount paths below run as root, so be strict about what
# we will pass to umount(8): a normalized path strictly under /media/.
normalized = os.path.normpath(request.mount_point)
if normalized in RESERVED_MOUNT_POINTS:
raise HTTPException(
status_code=400,
detail=f"Cannot unmount {request.mount_point}: it is the primary "
f"WROLPi drive.",
)
if not normalized.startswith("/media/"):
raise HTTPException(
status_code=400,
detail=f"Refusing to unmount {normalized}: not under /media/.",
)
if normalized == str(get_media_directory()):
return _unmount_primary(normalized)
if normalized in RESERVED_MOUNT_POINTS:
raise HTTPException(
status_code=400,
detail=f"Cannot unmount {request.mount_point}: it is a reserved "
f"mount point.",
)
data = load_fstab()
removed = data.remove_by_mount_point(request.mount_point)

View file

@ -1695,9 +1695,9 @@
<td>
`;
// 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.
// The primary mount can be unmounted (to swap in a backup
// drive); doing so stops the API service. Mounts outside
// /media (/, /boot/efi, ...) are system mounts: untouchable.
const isPrimary = disk.mountpoint === '/media/wrolpi';
const isSystemMount = mounted && !isPrimary && !disk.mountpoint.startsWith('/media/');
@ -1732,7 +1732,7 @@
html += `</td><td>`;
// Actions column
if (mounted && (isSystemMount || isPrimary)) {
if (mounted && isSystemMount) {
html += `<span style="color: #888;">System</span>`;
} else if (mounted) {
html += `<button class="btn btn-danger" onclick="unmountDisk('${disk.mountpoint}')">Unmount</button>`;
@ -1809,7 +1809,13 @@
}
async function unmountDisk(mountPoint) {
if (!confirm(`Unmount ${mountPoint}?`)) return;
const message = mountPoint === '/media/wrolpi' ?
`Unmount ${mountPoint}?\n\nWARNING: This is the primary WROLPi drive. ` +
`The WROLPi API service will be STOPPED before unmounting, and your media ` +
`will be unavailable until a drive is mounted at /media/wrolpi and the API ` +
`service is started again.` :
`Unmount ${mountPoint}?`;
if (!confirm(message)) return;
try {
await apiCall('api/disks/unmount', 'POST', {

View file

@ -255,7 +255,6 @@ class TestUnmountEndpoint:
@pytest.mark.parametrize("mount_point", [
"/media", # not strictly under /media/
"/media_evil", # prefix-collides with /media
"/media/wrolpi/../wrolpi", # normalizes to the reserved primary
])
def test_unmount_rejects_paths_outside_media(
self, test_client, disks_env, mount_point,
@ -268,18 +267,89 @@ class TestUnmountEndpoint:
assert disks_env["fake"].unmount_calls == []
def test_unmount_reserved_mount_point_rejected(self, test_client, disks_env):
# The primary WROLPi drive must never be unmountable from the API.
disks_env["fake"]._live.add("/media/wrolpi")
# The onboarding temp mount belongs to the Controller itself.
disks_env["fake"]._live.add("/media/wrolpi_temp_onboarding")
response = test_client.post(
"/api/disks/unmount",
json={"mount_point": "/media/wrolpi_temp_onboarding"},
)
assert response.status_code == 400
assert "reserved" in response.json()["detail"].lower()
# No umount was attempted, directly or via the reconciler.
assert disks_env["fake"].unmount_calls == []
assert disks_env["fake"].current_mount_points() == {
"/media/wrolpi_temp_onboarding"}
class TestUnmountPrimaryEndpoint:
"""Unmounting the primary drive stops the API service, then umounts
directly (the reconciler never manages the primary mount). The
/etc/fstab entry is left alone so a reboot restores the mount."""
@pytest.fixture
def primary_env(self, disks_env):
with mock.patch("controller.api.disks.get_media_directory",
return_value=Path("/media/wrolpi")), \
mock.patch("controller.api.disks.stop_service",
return_value={"success": True}) as stop:
yield {**disks_env, "stop_service": stop}
@pytest.mark.parametrize("mount_point", [
"/media/wrolpi",
"/media/wrolpi/../wrolpi", # normalizes to the primary
])
def test_unmount_primary_stops_api_and_unmounts(
self, test_client, primary_env, mount_point,
):
primary_env["fake"]._live.add("/media/wrolpi")
response = test_client.post(
"/api/disks/unmount",
json={"mount_point": mount_point},
)
assert response.status_code == 200
body = response.json()
assert body["success"] is True
assert body["removed_from_fstab"] is False
assert body["stopped_services"] == ["wrolpi-api"]
primary_env["stop_service"].assert_called_once_with("wrolpi-api")
assert primary_env["fake"].current_mount_points() == set()
def test_unmount_primary_returns_500_when_still_mounted(
self, test_client, primary_env,
):
# umount fails (e.g. busy) — surface the failure, but the service
# stop should still have been attempted first.
primary_env["fake"]._live.add("/media/wrolpi")
primary_env["fake"].fail_for_unmount = frozenset({"/media/wrolpi"})
response = test_client.post(
"/api/disks/unmount",
json={"mount_point": "/media/wrolpi"},
)
assert response.status_code == 400
assert "primary" in response.json()["detail"].lower()
# No umount was attempted, directly or via the reconciler.
assert disks_env["fake"].unmount_calls == []
assert disks_env["fake"].current_mount_points() == {"/media/wrolpi"}
assert response.status_code == 500
assert "still mounted" in response.json()["detail"]
primary_env["stop_service"].assert_called_once_with("wrolpi-api")
def test_unmount_primary_stop_failure_still_unmounts(
self, test_client, primary_env,
):
# A failed service stop (e.g. unit not installed) must not block
# the unmount; the umount result decides success.
primary_env["fake"]._live.add("/media/wrolpi")
primary_env["stop_service"].return_value = {
"success": False, "error": "unit not found"}
response = test_client.post(
"/api/disks/unmount",
json={"mount_point": "/media/wrolpi"},
)
assert response.status_code == 200
body = response.json()
assert body["success"] is True
assert body["stopped_services"] == []
assert primary_env["fake"].current_mount_points() == set()
# --- /api/disks/fstab read endpoint ---------------------------------------