+ Are you sure you want to unmount {unmountTarget}?
+
+
+ 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 {PRIMARY_MOUNT} and
+ the API service is started again.
+
+
+ ) : `Are you sure you want to unmount ${unmountTarget}?`}
onCancel={() => {
setUnmountConfirmOpen(false);
setUnmountTarget(null);
diff --git a/app/src/components/admin/ControllerPage.test.js b/app/src/components/admin/ControllerPage.test.js
index 0e82c82b..48d59af8 100644
--- a/app/src/components/admin/ControllerPage.test.js
+++ b/app/src/components/admin/ControllerPage.test.js
@@ -1,5 +1,5 @@
import React from 'react';
-import {render, screen, waitFor} from '@testing-library/react';
+import {fireEvent, render, screen, waitFor} from '@testing-library/react';
import {BrowserRouter} from 'react-router';
import {ThemeContext, SettingsContext} from '../../contexts/contexts';
import {ControllerPage} from './ControllerPage';
@@ -56,7 +56,13 @@ jest.mock('../../api/controller', () => ({
getServiceLogs: jest.fn().mockResolvedValue({logs: 'Sample log output'}),
getDisks: jest.fn().mockResolvedValue([]),
getMounts: jest.fn().mockResolvedValue([]),
+ getFstabEntries: jest.fn().mockResolvedValue([]),
getSmartStatus: jest.fn().mockResolvedValue({}),
+ mountDisk: jest.fn().mockResolvedValue({success: true}),
+ unmountDisk: jest.fn().mockResolvedValue(
+ {success: true, stopped_services: ['wrolpi-api']}),
+ addFstabEntry: jest.fn().mockResolvedValue({success: true}),
+ removeFstabEntry: jest.fn().mockResolvedValue({success: true}),
restartServices: jest.fn().mockResolvedValue({success: true}),
getSambaStatus: jest.fn().mockResolvedValue({enabled: false, available: true, shares: []}),
addSambaShare: jest.fn().mockResolvedValue({success: true}),
@@ -177,6 +183,51 @@ describe('ControllerPage', () => {
});
+describe('DiskSection primary unmount', () => {
+ const controllerApi = require('../../api/controller');
+
+ beforeEach(() => {
+ controllerApi.getDisks.mockResolvedValue([
+ {
+ name: 'sda1',
+ path: '/dev/sda1',
+ size: '500G',
+ fstype: 'ext4',
+ label: 'wrolpi',
+ mountpoint: '/media/wrolpi',
+ uuid: 'abc-123',
+ },
+ ]);
+ controllerApi.unmountDisk.mockClear();
+ });
+
+ afterEach(() => {
+ controllerApi.getDisks.mockResolvedValue([]);
+ });
+
+ test('primary mount shows Unmount button; confirming warns and unmounts', async () => {
+ renderControllerPage();
+
+ // The primary mount is no longer labeled "System"; it gets Unmount.
+ const unmountButton = await screen.findByText('Unmount');
+ fireEvent.click(unmountButton);
+
+ // The confirm modal warns that the API service will be stopped.
+ await waitFor(() => {
+ expect(screen.getByText(/API service will/)).toBeInTheDocument();
+ });
+
+ // Semantic's Confirm renders confirmButton='Unmount' as the last match.
+ const buttons = screen.getAllByText('Unmount');
+ fireEvent.click(buttons[buttons.length - 1]);
+
+ await waitFor(() => {
+ expect(controllerApi.unmountDisk).toHaveBeenCalledWith('/media/wrolpi');
+ });
+ });
+});
+
+
describe('groupServices', () => {
const {groupServices} = require('./ControllerPage');
diff --git a/controller/api/disks.py b/controller/api/disks.py
index 6ccaecdc..7a4468f2 100644
--- a/controller/api/disks.py
+++ b/controller/api/disks.py
@@ -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)
diff --git a/controller/templates/index.html b/controller/templates/index.html
index 00027622..336e240b 100644
--- a/controller/templates/index.html
+++ b/controller/templates/index.html
@@ -1695,9 +1695,9 @@
`;
- // 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 += ` `;
// Actions column
- if (mounted && (isSystemMount || isPrimary)) {
+ if (mounted && isSystemMount) {
html += `System `;
} else if (mounted) {
html += `Unmount `;
@@ -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', {
diff --git a/controller/test/test_disks_api.py b/controller/test/test_disks_api.py
index ab63c78b..7c1813bd 100644
--- a/controller/test/test_disks_api.py
+++ b/controller/test/test_disks_api.py
@@ -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 ---------------------------------------