Moving WROLPi-managed mounts off /etc/fstab onto a drive-resident fstab.yaml.

A WROLPi drive can move between hosts and across deployment styles
(Portable, RPi, Debian).  /etc/fstab is host-specific and on the Portable
image it's part of a read-only squashfs, so persisting secondary mounts
there was both wrong-host and lost-on-reboot.  This change makes
/media/wrolpi/config/fstab.yaml the single source of truth for WROLPi-
managed mounts and introduces a new wrolpi-mounts.service that reconciles
live state to match.

Architecture:

- The Controller writes intent (fstab.yaml) but never calls mount(8) or
  umount(8) itself.  Every mutation triggers `systemctl restart
  wrolpi-mounts.service`; the Controller then verifies the outcome via
  the same MountExecutor abstraction the service uses.

- wrolpi-mounts.service runs a thin entry point that constructs a real
  Reconciler with a SubprocessMountExecutor and calls apply().  The
  reconciler mounts what's in fstab.yaml but missing from the live state,
  and unmounts paths it previously claimed that are no longer in
  fstab.yaml.  A managed-set file in /run/wrolpi-mounts/managed prevents
  the reconciler from ever touching user-mounted paths it didn't own.

- One-time migration (marker-gated) lifts both /etc/fstab WROLPi entries
  and any controller.yaml drives.mounts entries into fstab.yaml.
  /etc/fstab is backed up to /etc/fstab.wrolpi-migration.backup;
  migrated lines are commented in place with a "migrated to fstab.yaml:"
  prefix so a host admin can hand-recover.

Test approach: dependency injection in place of patching.  The
MountExecutor protocol has SubprocessMountExecutor (production) and
FakeMountExecutor (in-memory, records calls).  The Reconciler accepts an
executor in its constructor, so the 20 reconciler tests + 13 migration
tests + 18 fstab_yaml tests use real files in tmp_path with zero
mock.patch calls.  test_disks_api.py drops to mocks only for the
host-touching read helpers; the mount/unmount paths now run the real
Reconciler against the fake executor.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Roland 2026-05-22 22:02:30 -06:00 • committed by lrnselfreliance
parent 94d56f313a
commit 719905cb77
15 changed files with 2257 additions and 320 deletions

View file

@ -1,45 +1,67 @@
"""
Disk management API endpoints for WROLPi Controller.
The Controller defines what should be mounted (writes fstab.yaml on the
WROLPi drive) but never calls mount(8) or umount(8) itself. After every
write to fstab.yaml the Controller triggers wrolpi-mounts.service via
``systemctl restart``; the service reconciles live state to match. The
Controller verifies the outcome by reading /proc/mounts through the same
MountExecutor abstraction the service uses.
"""
import logging
import subprocess
from typing import Optional
from fastapi import APIRouter, HTTPException
from pydantic import BaseModel, Field
from controller.lib.config import (
get_config_value,
is_docker_mode,
save_config,
update_config,
)
from controller.lib.config import is_docker_mode
from controller.lib.disks import (
check_shadowed_data,
get_block_devices,
get_mounts,
mount_drive,
unmount_drive,
get_uuid,
validate_mount_point,
)
from controller.lib.fstab import (
add_fstab_entry,
get_wrolpi_fstab_entries,
remove_fstab_entry,
from controller.lib.fstab_yaml import (
FstabEntry,
load as load_fstab,
save as save_fstab,
)
from controller.lib.mount_executor import (
MountExecutor,
SubprocessMountExecutor,
)
from controller.lib.smart import (
get_all_smart_status,
is_smart_available,
)
from controller.lib.wrol_mode import require_normal_mode
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/disks", tags=["disks"])
# Module-level executor — production uses the real subprocess-backed one.
# Tests rebind this attribute (and ``_trigger_reconcile``) via monkeypatch
# to inject a FakeMountExecutor without touching subprocess or /proc.
_executor: MountExecutor = SubprocessMountExecutor()
RECONCILE_TIMEOUT_SECONDS = 60
class MountRequest(BaseModel):
"""Request model for mounting a drive."""
device: str = Field(description="Device path (e.g., /dev/sda1)")
mount_point: str = Field(description="Where to mount (must be under /media)")
fstype: Optional[str] = Field(default=None, description="Filesystem type (auto-detected if not specified)")
fstype: Optional[str] = Field(default=None,
description="Filesystem type (auto-detected if not specified)")
options: str = Field(default="defaults", description="Mount options")
persist: bool = Field(default=False, description="Add to fstab for persistent mounting")
# `persist` is accepted for API compatibility but no longer
# meaningful: every Controller-initiated mount goes through fstab.yaml
# and the Reconciler. A future UI cleanup will remove the field.
persist: bool = Field(default=True, description="(Deprecated; always persistent.)")
force_shadowed: bool = Field(
default=False,
description="Proceed even if existing data at the mount target would be hidden by the new mount",
@ -47,17 +69,17 @@ class MountRequest(BaseModel):
class UnmountRequest(BaseModel):
"""Request model for unmounting a drive."""
mount_point: str = Field(description="Mount point to unmount")
lazy: bool = Field(default=False, description="Use lazy unmount if busy")
lazy: bool = Field(default=False,
description="(Deprecated; reconciler chooses the strategy.)")
class FstabRequest(BaseModel):
"""Request model for adding an fstab entry."""
device: str = Field(description="Device path")
mount_point: str = Field(description="Where to mount")
fstype: str = Field(description="Filesystem type")
options: str = Field(default="defaults,nofail,x-systemd.device-timeout=10s", description="Mount options")
options: str = Field(default="defaults",
description="Mount options")
def _check_native_mode():
@ -70,11 +92,43 @@ def _check_native_mode():
)
def _to_uuid_spec(device: str) -> str:
"""Convert a /dev/sdX path to UUID=... if possible; passthrough otherwise.
Persistent mount entries must be UUID-keyed so they travel with the
drive between hosts. Already-formatted UUID=/PARTUUID=/LABEL= specs
pass through unchanged.
"""
if not device.startswith("/dev/"):
return device
uuid = get_uuid(device)
return f"UUID={uuid}" if uuid else device
def _trigger_reconcile() -> tuple[bool, Optional[str]]:
"""Ask systemd to re-run wrolpi-mounts.service. Blocks until the
oneshot completes; returns (ok, error_or_none)."""
cmd = ["systemctl", "restart", "wrolpi-mounts.service"]
try:
result = subprocess.run(
cmd, capture_output=True, text=True,
timeout=RECONCILE_TIMEOUT_SECONDS,
)
except subprocess.TimeoutExpired:
return False, f"systemctl restart wrolpi-mounts.service timed out " \
f"after {RECONCILE_TIMEOUT_SECONDS}s"
if result.returncode != 0:
return False, (result.stderr.strip()
or f"systemctl exit {result.returncode}")
return True, None
# --- Read endpoints (no fstab.yaml mutation) ------------------------------
@router.get("")
async def list_disks():
"""List all detected disks/partitions."""
_check_native_mode()
devices = get_block_devices()
return [
{
@ -98,13 +152,15 @@ async def list_mounts():
return get_mounts()
# --- Mount / unmount ------------------------------------------------------
@router.post("/mount")
async def disk_mount(request: MountRequest):
"""Mount a partition."""
"""Add a mount to fstab.yaml and reconcile."""
_check_native_mode()
# Soft-block if the mount target already contains user data that would
# be hidden by the new mount. The UI re-submits with force_shadowed=True
# be hidden by the new mount. UI re-submits with force_shadowed=True
# to override.
if not request.force_shadowed:
shadowed = check_shadowed_data(request.mount_point)
@ -121,116 +177,165 @@ async def disk_mount(request: MountRequest):
),
}
result = mount_drive(
device=request.device,
try:
require_normal_mode("mount")
except PermissionError as e:
raise HTTPException(status_code=403, detail=str(e))
try:
validate_mount_point(request.mount_point)
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
device_spec = _to_uuid_spec(request.device)
entry = FstabEntry(
device=device_spec,
mount_point=request.mount_point,
fstype=request.fstype,
options=request.options,
fstype=request.fstype or "auto",
options=request.options or "defaults",
)
if not result.get("success"):
raise HTTPException(status_code=500, detail=result.get("error", "Mount failed"))
data = load_fstab()
data.add_or_replace(entry)
save_fstab(data)
# Add to fstab if requested
if request.persist:
fstab_result = add_fstab_entry(
device=request.device,
mount_point=request.mount_point,
fstype=request.fstype or "auto",
ok, err = _trigger_reconcile()
if not ok:
raise HTTPException(status_code=500,
detail=f"Failed to reconcile mounts: {err}")
if request.mount_point not in _executor.current_mount_points():
# Entry is in fstab.yaml and will be retried on next reconcile, but
# this attempt did not produce a live mount — surface the failure.
raise HTTPException(
status_code=500,
detail=f"Reconciler ran but {request.mount_point} did not mount. "
f"Check journalctl -u wrolpi-mounts.service.",
)
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
return {
"success": True,
"device": device_spec,
"mount_point": request.mount_point,
}
@router.post("/unmount")
async def disk_unmount(request: UnmountRequest):
"""Unmount a partition."""
"""Remove a mount from fstab.yaml and reconcile."""
_check_native_mode()
result = unmount_drive(
mount_point=request.mount_point,
lazy=request.lazy,
)
try:
require_normal_mode("unmount")
except PermissionError as e:
raise HTTPException(status_code=403, detail=str(e))
if not result.get("success"):
raise HTTPException(status_code=500, detail=result.get("error", "Unmount failed"))
try:
validate_mount_point(request.mount_point)
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
return result
data = load_fstab()
removed = data.remove_by_mount_point(request.mount_point)
save_fstab(data)
ok, err = _trigger_reconcile()
if not ok:
raise HTTPException(status_code=500,
detail=f"Failed to reconcile mounts: {err}")
if request.mount_point in _executor.current_mount_points():
raise HTTPException(
status_code=500,
detail=f"Reconciler ran but {request.mount_point} is still mounted. "
f"Check journalctl -u wrolpi-mounts.service.",
)
return {
"success": True,
"mount_point": request.mount_point,
"removed_from_fstab": removed,
}
# --- fstab.yaml read/write endpoints --------------------------------------
@router.get("/fstab")
async def list_fstab():
"""Get WROLPi-related fstab entries."""
"""Return the WROLPi-managed mount table (fstab.yaml)."""
_check_native_mode()
return get_wrolpi_fstab_entries()
data = load_fstab()
return [
{
"type": "mount",
"device": e.device,
"mount_point": e.mount_point,
"fstype": e.fstype,
"options": e.options,
"dump": "0",
"pass": "2",
}
for e in data.mounts
]
@router.post("/fstab")
async def add_fstab(request: FstabRequest):
"""Add an fstab entry for persistent mounting."""
"""Add a row to fstab.yaml without triggering a reconcile.
Use disk_mount() to add AND apply. This endpoint is for callers that
want to stage changes without immediately mounting.
"""
_check_native_mode()
result = add_fstab_entry(
device=request.device,
try:
require_normal_mode("add fstab entry")
except PermissionError as e:
raise HTTPException(status_code=403, detail=str(e))
try:
validate_mount_point(request.mount_point)
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
device_spec = _to_uuid_spec(request.device)
data = load_fstab()
data.add_or_replace(FstabEntry(
device=device_spec,
mount_point=request.mount_point,
fstype=request.fstype,
options=request.options,
)
options=request.options or "defaults",
))
save_fstab(data)
if not result.get("success"):
raise HTTPException(status_code=500, detail=result.get("error", "Failed"))
return result
return {"success": True, "device": device_spec,
"mount_point": request.mount_point}
@router.delete("/fstab/{mount_point:path}")
async def delete_fstab(mount_point: str):
"""Remove an fstab entry."""
"""Remove a row from fstab.yaml without triggering a reconcile."""
_check_native_mode()
# URL decode the mount point
mount_point = f"/{mount_point}"
result = remove_fstab_entry(mount_point)
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
require_normal_mode("remove fstab entry")
except PermissionError as e:
raise HTTPException(status_code=403, detail=str(e))
return result
try:
validate_mount_point(mount_point)
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
data = load_fstab()
if not data.remove_by_mount_point(mount_point):
raise HTTPException(status_code=404,
detail=f"No entry found for {mount_point}")
save_fstab(data)
return {"success": True, "mount_point": mount_point}
# --- SMART ---
@ -241,7 +346,8 @@ async def list_smart():
_check_native_mode()
if not is_smart_available():
return {"available": False, "reason": "pySMART not installed or not supported"}
return {"available": False,
"reason": "pySMART not installed or not supported"}
return {
"available": True,