mirror of
https://github.com/lrnselfreliance/wrolpi.git
synced 2026-10-25 18:40:06 -04:00
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:
parent
5a11e23a56
commit
43f6ed2b8e
5 changed files with 209 additions and 31 deletions
|
|
@ -688,9 +688,10 @@ function SmartDetailsModal({drive, open, onClose}) {
|
|||
}
|
||||
|
||||
|
||||
// The primary mount cannot be unmounted from the UI, but its persistence
|
||||
// (an /etc/fstab entry) can be toggled. Any mount outside /media belongs to
|
||||
// the system (/, /boot/efi, /boot/firmware, …) and is entirely untouchable.
|
||||
// The primary mount can be unmounted (to swap in a backup drive), but doing
|
||||
// so stops the WROLPi API service — the unmount modal warns about this. Any
|
||||
// mount outside /media belongs to the system (/, /boot/efi, /boot/firmware, …)
|
||||
// and is entirely untouchable.
|
||||
const PRIMARY_MOUNT = '/media/wrolpi';
|
||||
|
||||
// Helper to format disk size
|
||||
|
|
@ -942,12 +943,15 @@ function DiskSection() {
|
|||
if (!unmountTarget) return;
|
||||
|
||||
try {
|
||||
await unmountDisk(unmountTarget);
|
||||
const result = await unmountDisk(unmountTarget);
|
||||
const stoppedServices = (result && result.stopped_services) || [];
|
||||
toast({
|
||||
type: 'success',
|
||||
title: 'Disk Unmounted',
|
||||
description: `Unmounted ${unmountTarget}`,
|
||||
time: 3000,
|
||||
description: stoppedServices.length > 0 ?
|
||||
`Unmounted ${unmountTarget}; stopped ${stoppedServices.join(', ')}` :
|
||||
`Unmounted ${unmountTarget}`,
|
||||
time: 5000,
|
||||
});
|
||||
fetchDiskInfo();
|
||||
} catch (e) {
|
||||
|
|
@ -1077,7 +1081,7 @@ function DiskSection() {
|
|||
)}
|
||||
</Table.Cell>
|
||||
<Table.Cell>
|
||||
{isMounted && (isSystemMount || isPrimary) ? (
|
||||
{isMounted && isSystemMount ? (
|
||||
<span style={{color: '#888'}}>System</span>
|
||||
) : isMounted ? (
|
||||
<Button
|
||||
|
|
@ -1168,7 +1172,18 @@ function DiskSection() {
|
|||
<Confirm
|
||||
open={unmountConfirmOpen}
|
||||
header='Unmount Disk'
|
||||
content={`Are you sure you want to unmount ${unmountTarget}?`}
|
||||
content={unmountTarget === PRIMARY_MOUNT ? (
|
||||
<div className='content'>
|
||||
<p>Are you sure you want to unmount <code>{unmountTarget}</code>?</p>
|
||||
<p>
|
||||
<Icon name='warning sign' color='yellow'/>
|
||||
This is the primary WROLPi drive. The WROLPi API service will
|
||||
be <strong>stopped</strong> before unmounting, and your media will be
|
||||
unavailable until a drive is mounted at <code>{PRIMARY_MOUNT}</code> and
|
||||
the API service is started again.
|
||||
</p>
|
||||
</div>
|
||||
) : `Are you sure you want to unmount ${unmountTarget}?`}
|
||||
onCancel={() => {
|
||||
setUnmountConfirmOpen(false);
|
||||
setUnmountTarget(null);
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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', {
|
||||
|
|
|
|||
|
|
@ -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 ---------------------------------------
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue