Adding disk mount handling to React UI.
Some checks are pending
CodeQL / Analyze (push) Waiting to run
Controller Tests / controller-tests (push) Waiting to run
Jest Tests / jest-tests (push) Waiting to run

This commit is contained in:
Roland 2025-12-16 15:06:41 -07:00
parent 1a02a4eb5b
commit 628b9f3096
No known key found for this signature in database
GPG key ID: 1BA620CE6C9A4680
10 changed files with 600 additions and 51 deletions

View file

@ -151,10 +151,10 @@ export async function getMounts() {
return controllerFetch('/disks/mounts');
}
export async function mountDisk(device, mountPoint) {
export async function mountDisk(device, mountPoint, fstype = null, options = 'defaults', persist = false) {
return controllerFetch('/disks/mount', {
method: 'POST',
body: JSON.stringify({device, mount_point: mountPoint}),
body: JSON.stringify({device, mount_point: mountPoint, fstype, options, persist}),
});
}
@ -173,6 +173,24 @@ export async function getSmartStatus() {
return controllerFetch('/disks/smart');
}
// --- Fstab Endpoints ---
export async function getFstabEntries() {
return controllerFetch('/disks/fstab');
}
export async function addFstabEntry(device, mountPoint, fstype, options = 'defaults,nofail,x-systemd.device-timeout=10s') {
return controllerFetch('/disks/fstab', {
method: 'POST',
body: JSON.stringify({device, mount_point: mountPoint, fstype, options}),
});
}
export async function removeFstabEntry(mountPoint) {
const encodedPath = encodeURIComponent(mountPoint.slice(1));
return controllerFetch(`/disks/fstab/${encodedPath}`, {method: 'DELETE'});
}
// --- Health Check ---
export async function healthCheck() {

View file

@ -1,5 +1,5 @@
import React from 'react';
import {Container, Dropdown, Icon} from "semantic-ui-react";
import {Container, Dropdown, Icon, Confirm, Input, Checkbox, Form} from "semantic-ui-react";
import {Button, Header, Loader, Modal, Segment, Table} from "../Theme";
import {
APIButton,
@ -17,9 +17,13 @@ import {
disableService,
getServiceLogs,
getDisks,
getMounts,
getSmartStatus,
restartServices,
getFstabEntries,
addFstabEntry,
removeFstabEntry,
mountDisk,
unmountDisk,
} from "../../api/controller";
import {toast} from "react-semantic-toasts-2";
import {RestartButton, ShutdownButton} from "./Settings";
@ -481,32 +485,150 @@ function SmartDetailsModal({drive, open, onClose}) {
}
// Protected mount points that should not be unmounted
const PROTECTED_MOUNTS = ['/', '/boot', '/boot/firmware'];
// Helper to format disk size
const formatSize = (size) => {
if (!size) return '-';
// If already a string (e.g., "59.5G" from lsblk), return as-is
if (typeof size === 'string') return size;
const units = ['B', 'KB', 'MB', 'GB', 'TB'];
let i = 0;
let bytes = size;
while (bytes >= 1024 && i < units.length - 1) {
bytes /= 1024;
i++;
}
return `${bytes.toFixed(1)} ${units[i]}`;
};
function MountModal({disk, open, onClose, onMount}) {
const [mountPoint, setMountPoint] = React.useState('');
const [persist, setPersist] = React.useState(false);
const [loading, setLoading] = React.useState(false);
// Set default mount point when disk changes
React.useEffect(() => {
if (disk) {
const defaultMount = disk.label ? `/media/${disk.label}` : `/media/${disk.name}`;
setMountPoint(defaultMount);
setPersist(false);
}
}, [disk]);
const handleMount = async () => {
if (!mountPoint.trim()) {
toast({
type: 'error',
title: 'Mount point required',
description: 'Please enter a mount point.',
time: 3000,
});
return;
}
setLoading(true);
try {
await mountDisk(disk.path, mountPoint.trim(), disk.fstype, 'defaults', persist);
toast({
type: 'success',
title: 'Disk Mounted',
description: `${disk.name} mounted at ${mountPoint}`,
time: 3000,
});
onClose();
if (onMount) onMount();
} catch (e) {
toast({
type: 'error',
title: 'Mount Failed',
description: e.message,
time: 5000,
});
} finally {
setLoading(false);
}
};
if (!disk) return null;
return (
<Modal open={open} onClose={onClose} size='small'>
<Modal.Header>
<Icon name='disk'/>
Mount Disk: {disk.name}
</Modal.Header>
<Modal.Content>
<Form>
<Form.Field>
<label>Mount Point</label>
<Input
value={mountPoint}
onChange={(e) => setMountPoint(e.target.value)}
placeholder="/media/..."
fluid
/>
</Form.Field>
<Form.Field>
<Checkbox
label="Persistent (survive reboots)"
checked={persist}
onChange={(e, {checked}) => setPersist(checked)}
/>
</Form.Field>
{disk.fstype && (
<p style={{color: '#888', fontSize: '0.9em'}}>
Filesystem: {disk.fstype}
{disk.label && ` | Label: ${disk.label}`}
{disk.size && ` | Size: ${formatSize(disk.size)}`}
</p>
)}
</Form>
</Modal.Content>
<Modal.Actions>
<Button onClick={onClose} disabled={loading}>Cancel</Button>
<Button color='green' onClick={handleMount} loading={loading} disabled={loading}>
<Icon name='check'/>
Mount
</Button>
</Modal.Actions>
</Modal>
);
}
function DiskSection() {
const [disks, setDisks] = React.useState([]);
const [mounts, setMounts] = React.useState([]);
const [fstabEntries, setFstabEntries] = React.useState([]);
const [smartDrives, setSmartDrives] = React.useState([]);
const [smartAvailable, setSmartAvailable] = React.useState(true);
const [loading, setLoading] = React.useState(true);
const [error, setError] = React.useState(null);
const [selectedDrive, setSelectedDrive] = React.useState(null);
const [detailsOpen, setDetailsOpen] = React.useState(false);
const [mountModalOpen, setMountModalOpen] = React.useState(false);
const [selectedDisk, setSelectedDisk] = React.useState(null);
const [unmountConfirmOpen, setUnmountConfirmOpen] = React.useState(false);
const [unmountTarget, setUnmountTarget] = React.useState(null);
const dockerized = useDockerized();
const fetchDiskInfo = async () => {
setLoading(true);
setError(null);
try {
const [disksResult, mountsResult, smartResult] = await Promise.allSettled([
const [disksResult, fstabResult, smartResult] = await Promise.allSettled([
getDisks(),
getMounts(),
getFstabEntries(),
getSmartStatus(),
]);
if (disksResult.status === 'fulfilled') {
setDisks(Array.isArray(disksResult.value) ? disksResult.value : []);
}
if (mountsResult.status === 'fulfilled') {
setMounts(Array.isArray(mountsResult.value) ? mountsResult.value : []);
if (fstabResult.status === 'fulfilled') {
setFstabEntries(Array.isArray(fstabResult.value) ? fstabResult.value : []);
}
if (smartResult.status === 'fulfilled' && smartResult.value) {
// Handle new API format: {available: bool, drives: [...]}
@ -529,6 +651,81 @@ function DiskSection() {
setDetailsOpen(true);
};
const handleOpenMountModal = (disk) => {
setSelectedDisk(disk);
setMountModalOpen(true);
};
const handleCloseMountModal = () => {
setMountModalOpen(false);
setSelectedDisk(null);
};
const handleUnmountClick = (mountPoint) => {
setUnmountTarget(mountPoint);
setUnmountConfirmOpen(true);
};
const handleUnmountConfirm = async () => {
if (!unmountTarget) return;
try {
await unmountDisk(unmountTarget);
toast({
type: 'success',
title: 'Disk Unmounted',
description: `Unmounted ${unmountTarget}`,
time: 3000,
});
fetchDiskInfo();
} catch (e) {
toast({
type: 'error',
title: 'Unmount Failed',
description: e.message,
time: 5000,
});
} finally {
setUnmountConfirmOpen(false);
setUnmountTarget(null);
}
};
const handleTogglePersist = async (disk, enable) => {
try {
if (enable) {
await addFstabEntry(disk.path, disk.mountpoint, disk.fstype || 'auto');
toast({
type: 'success',
title: 'Persistence Enabled',
description: `${disk.mountpoint} will persist across reboots`,
time: 3000,
});
} else {
await removeFstabEntry(disk.mountpoint);
toast({
type: 'success',
title: 'Persistence Disabled',
description: `${disk.mountpoint} will not persist across reboots`,
time: 3000,
});
}
fetchDiskInfo();
} catch (e) {
toast({
type: 'error',
title: 'Failed to update persistence',
description: e.message,
time: 5000,
});
}
};
// Check if a mount point is in fstab (persistent)
const isPersistent = (mountpoint) => {
return fstabEntries.some(entry => entry.mount_point === mountpoint);
};
React.useEffect(() => {
fetchDiskInfo();
}, []);
@ -561,28 +758,78 @@ function DiskSection() {
{error && <p style={{color: 'orange'}}>Some disk information unavailable: {error}</p>}
<Header as='h4'>Current Mounts</Header>
{mounts.length > 0 ? (
<Header as='h4'>Disks</Header>
{disks.length > 0 ? (
<Table unstackable striped compact>
<Table.Header>
<Table.Row>
<Table.HeaderCell>Device</Table.HeaderCell>
<Table.HeaderCell>Name</Table.HeaderCell>
<Table.HeaderCell>Size</Table.HeaderCell>
<Table.HeaderCell>Type</Table.HeaderCell>
<Table.HeaderCell>Label</Table.HeaderCell>
<Table.HeaderCell>Mount Point</Table.HeaderCell>
<Table.HeaderCell>Filesystem</Table.HeaderCell>
<Table.HeaderCell>Persist</Table.HeaderCell>
<Table.HeaderCell>Actions</Table.HeaderCell>
</Table.Row>
</Table.Header>
<Table.Body>
{mounts.map((mount, idx) => (
<Table.Row key={idx}>
<Table.Cell>{mount.device}</Table.Cell>
<Table.Cell>{mount.mount_point}</Table.Cell>
<Table.Cell>{mount.fstype}</Table.Cell>
</Table.Row>
))}
{disks.map((disk) => {
const isMounted = disk.mountpoint && disk.mountpoint !== '';
const isProtected = PROTECTED_MOUNTS.includes(disk.mountpoint);
const persistent = isMounted && isPersistent(disk.mountpoint);
return (
<Table.Row key={disk.path || disk.name}>
<Table.Cell>{disk.name}</Table.Cell>
<Table.Cell>{formatSize(disk.size)}</Table.Cell>
<Table.Cell>{disk.fstype || '-'}</Table.Cell>
<Table.Cell>{disk.label || '-'}</Table.Cell>
<Table.Cell>
{isMounted ? (
<span>{disk.mountpoint}</span>
) : (
<span style={{color: '#888'}}>-</span>
)}
</Table.Cell>
<Table.Cell>
{isMounted && !isProtected ? (
<Toggle
checked={persistent}
onChange={(checked) => handleTogglePersist(disk, checked)}
label={persistent ? 'Enabled' : 'Disabled'}
/>
) : (
<span style={{color: '#888'}}>-</span>
)}
</Table.Cell>
<Table.Cell>
{isMounted && isProtected ? (
<span style={{color: '#888'}}>System</span>
) : isMounted ? (
<Button
size='small'
color='red'
icon='eject'
content='Unmount'
onClick={() => handleUnmountClick(disk.mountpoint)}
/>
) : (
<Button
size='small'
color='green'
icon='plug'
content='Mount'
onClick={() => handleOpenMountModal(disk)}
/>
)}
</Table.Cell>
</Table.Row>
);
})}
</Table.Body>
</Table>
) : (
<p>No mounts found.</p>
<p>No disks detected.</p>
)}
<Header as='h4'>SMART Health</Header>
@ -636,6 +883,25 @@ function DiskSection() {
onClose={() => setDetailsOpen(false)}
/>
<MountModal
disk={selectedDisk}
open={mountModalOpen}
onClose={handleCloseMountModal}
onMount={fetchDiskInfo}
/>
<Confirm
open={unmountConfirmOpen}
header='Unmount Disk'
content={`Are you sure you want to unmount ${unmountTarget}?`}
onCancel={() => {
setUnmountConfirmOpen(false);
setUnmountTarget(null);
}}
onConfirm={handleUnmountConfirm}
confirmButton='Unmount'
/>
<Button onClick={fetchDiskInfo} style={{marginTop: '1em'}}>
<Icon name='refresh'/>
Refresh

View file

@ -7,7 +7,12 @@ from typing import Optional
from fastapi import APIRouter, HTTPException
from pydantic import BaseModel, Field
from controller.lib.config import is_docker_mode
from controller.lib.config import (
get_config_value,
is_docker_mode,
save_config,
update_config,
)
from controller.lib.disks import (
get_block_devices,
get_mounts,
@ -112,6 +117,30 @@ async def disk_mount(request: MountRequest):
)
result["fstab"] = fstab_result
# Save to controller.yaml to track WROLPi-managed mounts
if fstab_result.get("success"):
mount_entry = {
"device": fstab_result.get("device", request.device),
"mount_point": request.mount_point,
"fstype": request.fstype or "auto",
"options": request.options,
}
# Get current mounts, filter out any existing entry for same mount_point or device
current_mounts = get_config_value("drives.mounts", [])
current_mounts = [
m for m in current_mounts
if m.get("mount_point") != request.mount_point
and m.get("device") != mount_entry["device"]
]
current_mounts.append(mount_entry)
update_config("drives.mounts", current_mounts)
try:
save_config()
except RuntimeError:
pass # Primary drive not mounted - can't save yet
return result
@ -169,6 +198,15 @@ async def delete_fstab(mount_point: str):
if not result.get("success"):
raise HTTPException(status_code=500, detail=result.get("error", "Failed"))
# Remove from controller.yaml - mount is no longer persistent
current_mounts = get_config_value("drives.mounts", [])
current_mounts = [m for m in current_mounts if m.get("mount_point") != mount_point]
update_config("drives.mounts", current_mounts)
try:
save_config()
except RuntimeError:
pass # Primary drive not mounted - can't save
return result

View file

@ -103,6 +103,7 @@ def save_config() -> None:
"""
Save current runtime config to the WROLPi drive.
Only saves the diff from defaults to keep the file clean.
If config matches defaults, removes the config file.
"""
if not is_primary_drive_mounted():
raise RuntimeError("Cannot save config: primary drive not mounted")
@ -111,7 +112,9 @@ def save_config() -> None:
diff = _get_config_diff(_runtime_config, DEFAULT_CONFIG)
if not diff:
# Nothing to save - config matches defaults
# Config matches defaults - remove config file if it exists
if CONFIG_PATH_ON_DRIVE.exists():
CONFIG_PATH_ON_DRIVE.unlink()
return
CONFIG_PATH_ON_DRIVE.parent.mkdir(parents=True, exist_ok=True)

View file

@ -60,23 +60,38 @@ def parse_fstab() -> list[dict]:
return entries
def _filter_entry_with_comment(entries: list[dict], mount_point: str) -> list[dict]:
def _filter_entry_with_comment(
entries: list[dict],
mount_point: str = None,
device: str = None,
) -> list[dict]:
"""
Remove a mount entry and its preceding WROLPi comment if present.
Remove mount entries and their preceding WROLPi comments if present.
Args:
entries: List of parsed fstab entries
mount_point: Mount point to remove
mount_point: Mount point to remove (optional)
device: Device spec to remove (e.g., "UUID=1234" or "/dev/sda1") (optional)
Returns:
Filtered list without the mount entry and its associated WROLPi comment
Filtered list without matching mount entries and their associated WROLPi comments
"""
result = []
skip_indices = set()
# First pass: identify which entries to skip
for i, entry in enumerate(entries):
if entry.get("mount_point") == mount_point:
should_skip = False
# Check mount_point match
if mount_point and entry.get("mount_point") == mount_point:
should_skip = True
# Check device match
if device and entry.get("device") == device:
should_skip = True
if should_skip:
skip_indices.add(i)
# Check if previous entry is a WROLPi comment
if i > 0:
@ -141,8 +156,9 @@ def add_fstab_entry(
# Read current fstab
entries = parse_fstab()
# Remove any existing entry for this mount point (and its WROLPi comment)
entries = _filter_entry_with_comment(entries, mount_point)
# Remove any existing entry for this mount point or device (and its WROLPi comment)
# This ensures we don't get duplicates when remounting the same device to a different location
entries = _filter_entry_with_comment(entries, mount_point=mount_point, device=device_spec)
# Build new entry line
new_entry = f"{device_spec} {mount_point} {fstype} {options} 0 2\n"

View file

@ -1055,6 +1055,12 @@
// Build set of persistent mount points for quick lookup
const persistentMounts = new Set(fstabEntries.map(e => e.mount_point));
// Build map of device (UUID) to fstab entry for unmounted drive lookup
const persistentByDevice = new Map();
fstabEntries.forEach(e => {
// e.device is like "UUID=1234-5678" or "/dev/sda1"
persistentByDevice.set(e.device, e);
});
let html = `
<table>
@ -1090,14 +1096,26 @@
const protectedMounts = ['/', '/boot', '/boot/firmware'];
const isProtected = protectedMounts.includes(disk.mountpoint);
// Persist column - only show for mounted, non-protected drives
if (mounted && !isProtected) {
const isPersistent = persistentMounts.has(disk.mountpoint);
// Check if this disk has a persistent entry (by mount point or UUID)
let isPersistent = false;
let persistentEntry = null;
if (mounted) {
isPersistent = persistentMounts.has(disk.mountpoint);
} else if (disk.uuid) {
// Check if unmounted drive has fstab entry by UUID
persistentEntry = persistentByDevice.get(`UUID=${disk.uuid}`);
isPersistent = !!persistentEntry;
}
// Persist column - show for mounted drives OR unmounted persistent drives
if ((mounted || isPersistent) && !isProtected) {
const mountPoint = mounted ? disk.mountpoint : persistentEntry.mount_point;
html += `
<label class="toggle">
<input type="checkbox"
${isPersistent ? 'checked' : ''}
onchange="togglePersist('${disk.path}', '${disk.mountpoint}', '${disk.fstype}', this.checked)">
onchange="togglePersist('${disk.path}', '${mountPoint}', '${disk.fstype}', this.checked)">
<span class="toggle-slider"></span>
</label>
<span class="toggle-label">${isPersistent ? 'Enabled' : 'Disabled'}</span>

View file

@ -205,6 +205,36 @@ class TestSaveConfig:
# Only port should be in the file
assert saved == {"port": 9999}
def test_save_config_removes_file_when_matches_defaults(
self, reset_runtime_config, mock_config_path, mock_drive_mounted
):
"""Should remove config file when config matches defaults."""
# First create a config file with non-default value
update_config("port", 9999)
save_config()
assert mock_config_path.exists()
# Reset to default value
update_config("port", 8087) # Default port
save_config()
# File should be removed since config matches defaults
assert not mock_config_path.exists()
def test_save_config_removes_stale_file(
self, reset_runtime_config, mock_config_path, mock_drive_mounted
):
"""Should remove existing config file when config is reset to defaults."""
# Simulate an existing stale config file
mock_config_path.write_text("drives:\n mounts:\n - device: UUID=1234\n")
assert mock_config_path.exists()
# Runtime config matches defaults (no changes made)
save_config()
# File should be removed
assert not mock_config_path.exists()
class TestUpdateConfig:
"""Tests for update_config function."""

View file

@ -169,6 +169,143 @@ class TestSmartEndpoints:
assert len(data["drives"]) == 1
class TestMountPersistSavesConfig:
"""Tests for mount with persist=true saving to controller.yaml."""
def test_mount_with_persist_saves_config(self, test_client):
"""Mount with persist=true should save to controller.yaml."""
with mock.patch(
"controller.api.disks.mount_drive",
return_value={"success": True, "mount_point": "/media/test"}
):
with mock.patch(
"controller.api.disks.add_fstab_entry",
return_value={"success": True, "device": "UUID=1234", "mount_point": "/media/test"}
):
with mock.patch("controller.api.disks.save_config") as mock_save:
with mock.patch("controller.api.disks.get_config_value", return_value=[]):
with mock.patch("controller.api.disks.update_config") as mock_update:
response = test_client.post(
"/api/disks/mount",
json={
"device": "/dev/sda1",
"mount_point": "/media/test",
"fstype": "ext4",
"options": "defaults",
"persist": True
}
)
assert response.status_code == 200
# Check that update_config was called with correct mount entry
mock_update.assert_called_once()
call_args = mock_update.call_args
assert call_args[0][0] == "drives.mounts"
mounts = call_args[0][1]
assert len(mounts) == 1
assert mounts[0]["device"] == "UUID=1234"
assert mounts[0]["mount_point"] == "/media/test"
# Check that save_config was called
mock_save.assert_called_once()
def test_mount_without_persist_does_not_save_config(self, test_client):
"""Mount without persist=true should not save to controller.yaml."""
with mock.patch(
"controller.api.disks.mount_drive",
return_value={"success": True, "mount_point": "/media/test"}
):
with mock.patch("controller.api.disks.save_config") as mock_save:
response = test_client.post(
"/api/disks/mount",
json={
"device": "/dev/sda1",
"mount_point": "/media/test",
"persist": False
}
)
assert response.status_code == 200
mock_save.assert_not_called()
def test_mount_replaces_existing_entry_for_same_device(self, test_client):
"""Mount should replace existing entry for same device UUID."""
existing_mounts = [
{"device": "UUID=1234", "mount_point": "/media/old", "fstype": "ext4", "options": "defaults"}
]
with mock.patch(
"controller.api.disks.mount_drive",
return_value={"success": True}
):
with mock.patch(
"controller.api.disks.add_fstab_entry",
return_value={"success": True, "device": "UUID=1234", "mount_point": "/media/new"}
):
with mock.patch("controller.api.disks.save_config"):
with mock.patch("controller.api.disks.get_config_value", return_value=existing_mounts):
with mock.patch("controller.api.disks.update_config") as mock_update:
response = test_client.post(
"/api/disks/mount",
json={
"device": "/dev/sda1",
"mount_point": "/media/new",
"fstype": "ext4",
"persist": True
}
)
assert response.status_code == 200
# Should have replaced the old entry
call_args = mock_update.call_args
mounts = call_args[0][1]
assert len(mounts) == 1
assert mounts[0]["mount_point"] == "/media/new"
class TestDeleteFstabRemovesConfig:
"""Tests for delete_fstab removing from controller.yaml."""
def test_delete_fstab_removes_from_config(self, test_client):
"""Delete fstab entry should remove from controller.yaml."""
existing_mounts = [
{"device": "UUID=1234", "mount_point": "/media/test", "fstype": "ext4", "options": "defaults"}
]
with mock.patch(
"controller.api.disks.remove_fstab_entry",
return_value={"success": True, "mount_point": "/media/test"}
):
with mock.patch("controller.api.disks.save_config") as mock_save:
with mock.patch("controller.api.disks.get_config_value", return_value=existing_mounts):
with mock.patch("controller.api.disks.update_config") as mock_update:
response = test_client.delete("/api/disks/fstab/media/test")
assert response.status_code == 200
# Check that update_config was called with empty list
mock_update.assert_called_once()
call_args = mock_update.call_args
assert call_args[0][0] == "drives.mounts"
mounts = call_args[0][1]
assert len(mounts) == 0
# Check that save_config was called
mock_save.assert_called_once()
def test_delete_fstab_preserves_other_mounts(self, test_client):
"""Delete fstab should only remove the specified mount."""
existing_mounts = [
{"device": "UUID=1234", "mount_point": "/media/test", "fstype": "ext4", "options": "defaults"},
{"device": "UUID=5678", "mount_point": "/media/other", "fstype": "ext4", "options": "defaults"},
]
with mock.patch(
"controller.api.disks.remove_fstab_entry",
return_value={"success": True, "mount_point": "/media/test"}
):
with mock.patch("controller.api.disks.save_config"):
with mock.patch("controller.api.disks.get_config_value", return_value=existing_mounts):
with mock.patch("controller.api.disks.update_config") as mock_update:
response = test_client.delete("/api/disks/fstab/media/test")
assert response.status_code == 200
# Should preserve the other mount
call_args = mock_update.call_args
mounts = call_args[0][1]
assert len(mounts) == 1
assert mounts[0]["mount_point"] == "/media/other"
class TestOpenAPIIncludesDisksEndpoints:
"""Tests that OpenAPI documentation includes disks endpoints."""

View file

@ -131,6 +131,45 @@ UUID=5678 /media/test ext4 defaults,nofail 0 2
# The old timestamp should NOT be there
assert "2025-12-15T15:57:37.675757" not in written_str
def test_replaces_entry_for_same_uuid_different_mount_point(self):
"""Should remove old entry when adding same UUID to different mount point.
Scenario: User mounts /dev/sda1 (UUID=5678) to /media/64GB with persist,
then unmounts, then mounts same device to /media/64GB-2 with persist.
The old /media/64GB entry should be removed.
"""
fstab_content = """\
# /etc/fstab
UUID=1234 / ext4 defaults 0 1
# WROLPi managed mount - added 2025-12-15T15:57:37.675757
UUID=5678 /media/64GB exfat defaults,nofail,x-systemd.device-timeout=10s 0 2
"""
written_content = []
def mock_write(content):
written_content.append(content)
mock_file = mock.mock_open(read_data=fstab_content)
mock_file().write = mock_write
with mock.patch("controller.lib.fstab.require_normal_mode"):
with mock.patch("controller.lib.fstab.get_uuid", return_value="5678"):
with mock.patch("controller.lib.fstab.backup_fstab"):
with mock.patch("builtins.open", mock_file):
with mock.patch("subprocess.run"):
# Mount same device to different mount point
result = add_fstab_entry("/dev/sda1", "/media/64GB-2", "exfat")
assert result["success"] is True
written_str = "".join(written_content)
# Should only have ONE entry for UUID=5678 (the new one)
assert written_str.count("UUID=5678") == 1
# Should have the new mount point
assert "/media/64GB-2" in written_str
# Should NOT have the old mount point
assert "/media/64GB " not in written_str
# Should have exactly ONE WROLPi comment
assert written_str.count("WROLPi managed mount") == 1
class TestRemoveFstabEntry:
"""Tests for remove_fstab_entry function."""

View file

@ -39,25 +39,9 @@ upgrade_controller() {
/opt/wrolpi/controller/venv/bin/pip install --upgrade pip
/opt/wrolpi/controller/venv/bin/pip install --upgrade -r /opt/wrolpi/controller/requirements.txt
# Reload systemd in case service file changed
# Start the Controller.
systemctl daemon-reload
# Start Controller
systemctl start wrolpi-controller
# Wait for health check (up to 30 seconds)
echo "Waiting for Controller health check..."
for i in {1..30}; do
if curl -s http://localhost:8087/api/health > /dev/null 2>&1; then
echo "Controller upgraded successfully"
return 0
fi
sleep 1
done
echo "WARNING: Controller health check failed after upgrade"
echo "Check logs: journalctl -u wrolpi-controller -n 50"
return 1
}
upgrade_controller || echo "Controller upgrade failed, continuing..."