2025-12-11 18:32:55 -07:00
|
|
|
"""
|
|
|
|
|
Disk management API endpoints for WROLPi Controller.
|
2026-05-22 22:02:30 -06:00
|
|
|
|
|
|
|
|
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.
|
2025-12-11 18:32:55 -07:00
|
|
|
"""
|
|
|
|
|
|
2026-05-22 22:02:30 -06:00
|
|
|
import logging
|
|
|
|
|
import subprocess
|
2025-12-11 18:32:55 -07:00
|
|
|
from typing import Optional
|
|
|
|
|
|
|
|
|
|
from fastapi import APIRouter, HTTPException
|
|
|
|
|
from pydantic import BaseModel, Field
|
|
|
|
|
|
2026-05-22 22:02:30 -06:00
|
|
|
from controller.lib.config import is_docker_mode
|
2025-12-11 18:32:55 -07:00
|
|
|
from controller.lib.disks import (
|
2026-05-14 16:38:02 -06:00
|
|
|
check_shadowed_data,
|
2025-12-11 18:32:55 -07:00
|
|
|
get_block_devices,
|
|
|
|
|
get_mounts,
|
2026-05-22 22:02:30 -06:00
|
|
|
get_uuid,
|
|
|
|
|
validate_mount_point,
|
2025-12-11 18:32:55 -07:00
|
|
|
)
|
2026-05-22 22:02:30 -06:00
|
|
|
from controller.lib.fstab_yaml import (
|
|
|
|
|
FstabEntry,
|
|
|
|
|
load as load_fstab,
|
|
|
|
|
save as save_fstab,
|
|
|
|
|
)
|
|
|
|
|
from controller.lib.mount_executor import (
|
|
|
|
|
MountExecutor,
|
|
|
|
|
SubprocessMountExecutor,
|
2025-12-11 18:32:55 -07:00
|
|
|
)
|
|
|
|
|
from controller.lib.smart import (
|
|
|
|
|
get_all_smart_status,
|
|
|
|
|
is_smart_available,
|
|
|
|
|
)
|
2026-05-22 22:02:30 -06:00
|
|
|
from controller.lib.wrol_mode import require_normal_mode
|
|
|
|
|
|
|
|
|
|
logger = logging.getLogger(__name__)
|
2025-12-11 18:32:55 -07:00
|
|
|
|
|
|
|
|
router = APIRouter(prefix="/api/disks", tags=["disks"])
|
|
|
|
|
|
2026-05-22 22:02:30 -06:00
|
|
|
# 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
|
|
|
|
|
|
2025-12-11 18:32:55 -07:00
|
|
|
|
|
|
|
|
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)")
|
2026-05-22 22:02:30 -06:00
|
|
|
fstype: Optional[str] = Field(default=None,
|
|
|
|
|
description="Filesystem type (auto-detected if not specified)")
|
2025-12-11 18:32:55 -07:00
|
|
|
options: str = Field(default="defaults", description="Mount options")
|
2026-05-22 22:02:30 -06:00
|
|
|
# `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.)")
|
2026-05-14 16:38:02 -06:00
|
|
|
force_shadowed: bool = Field(
|
|
|
|
|
default=False,
|
|
|
|
|
description="Proceed even if existing data at the mount target would be hidden by the new mount",
|
|
|
|
|
)
|
2025-12-11 18:32:55 -07:00
|
|
|
|
|
|
|
|
|
|
|
|
|
class UnmountRequest(BaseModel):
|
|
|
|
|
mount_point: str = Field(description="Mount point to unmount")
|
2026-05-22 22:02:30 -06:00
|
|
|
lazy: bool = Field(default=False,
|
|
|
|
|
description="(Deprecated; reconciler chooses the strategy.)")
|
2025-12-11 18:32:55 -07:00
|
|
|
|
|
|
|
|
|
|
|
|
|
class FstabRequest(BaseModel):
|
|
|
|
|
device: str = Field(description="Device path")
|
|
|
|
|
mount_point: str = Field(description="Where to mount")
|
|
|
|
|
fstype: str = Field(description="Filesystem type")
|
2026-05-22 22:02:30 -06:00
|
|
|
options: str = Field(default="defaults",
|
|
|
|
|
description="Mount options")
|
2025-12-11 18:32:55 -07:00
|
|
|
|
|
|
|
|
|
|
|
|
|
def _check_native_mode():
|
|
|
|
|
"""Raise 501 if in Docker mode."""
|
|
|
|
|
if is_docker_mode():
|
|
|
|
|
raise HTTPException(
|
|
|
|
|
status_code=501,
|
|
|
|
|
detail="Disk management is not available in Docker mode. "
|
|
|
|
|
"Configure mounts on the host system."
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
2026-05-22 22:02:30 -06:00
|
|
|
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) ------------------------------
|
|
|
|
|
|
2025-12-11 18:32:55 -07:00
|
|
|
@router.get("")
|
|
|
|
|
async def list_disks():
|
|
|
|
|
"""List all detected disks/partitions."""
|
|
|
|
|
_check_native_mode()
|
|
|
|
|
devices = get_block_devices()
|
|
|
|
|
return [
|
|
|
|
|
{
|
|
|
|
|
"name": d.name,
|
|
|
|
|
"path": d.path,
|
|
|
|
|
"size": d.size,
|
|
|
|
|
"fstype": d.fstype,
|
|
|
|
|
"mountpoint": d.mountpoint,
|
|
|
|
|
"label": d.label,
|
|
|
|
|
"uuid": d.uuid,
|
|
|
|
|
"model": d.model,
|
|
|
|
|
}
|
|
|
|
|
for d in devices
|
|
|
|
|
]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@router.get("/mounts")
|
|
|
|
|
async def list_mounts():
|
|
|
|
|
"""List current mounts under /media."""
|
|
|
|
|
_check_native_mode()
|
|
|
|
|
return get_mounts()
|
|
|
|
|
|
|
|
|
|
|
2026-05-22 22:02:30 -06:00
|
|
|
# --- Mount / unmount ------------------------------------------------------
|
|
|
|
|
|
2025-12-11 18:32:55 -07:00
|
|
|
@router.post("/mount")
|
|
|
|
|
async def disk_mount(request: MountRequest):
|
2026-05-22 22:02:30 -06:00
|
|
|
"""Add a mount to fstab.yaml and reconcile."""
|
2025-12-11 18:32:55 -07:00
|
|
|
_check_native_mode()
|
|
|
|
|
|
2026-05-14 16:38:02 -06:00
|
|
|
# Soft-block if the mount target already contains user data that would
|
2026-05-22 22:02:30 -06:00
|
|
|
# be hidden by the new mount. UI re-submits with force_shadowed=True
|
2026-05-14 16:38:02 -06:00
|
|
|
# to override.
|
|
|
|
|
if not request.force_shadowed:
|
|
|
|
|
shadowed = check_shadowed_data(request.mount_point)
|
|
|
|
|
if shadowed:
|
|
|
|
|
return {
|
|
|
|
|
"success": False,
|
|
|
|
|
"needs_force": "shadowed",
|
|
|
|
|
"shadowed_data": shadowed,
|
|
|
|
|
"error": (
|
|
|
|
|
f"{request.mount_point} contains {len(shadowed['entries'])} "
|
|
|
|
|
f"existing data "
|
|
|
|
|
f"{'entry' if len(shadowed['entries']) == 1 else 'entries'}. "
|
|
|
|
|
f"Mounting now would hide them on the underlying filesystem."
|
|
|
|
|
),
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-22 22:02:30 -06:00
|
|
|
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,
|
2025-12-11 18:32:55 -07:00
|
|
|
mount_point=request.mount_point,
|
2026-05-22 22:02:30 -06:00
|
|
|
fstype=request.fstype or "auto",
|
|
|
|
|
options=request.options or "defaults",
|
2025-12-11 18:32:55 -07:00
|
|
|
)
|
|
|
|
|
|
2026-05-22 22:02:30 -06:00
|
|
|
data = load_fstab()
|
|
|
|
|
data.add_or_replace(entry)
|
|
|
|
|
save_fstab(data)
|
2025-12-16 15:06:41 -07:00
|
|
|
|
2026-05-22 22:02:30 -06:00
|
|
|
ok, err = _trigger_reconcile()
|
|
|
|
|
if not ok:
|
|
|
|
|
raise HTTPException(status_code=500,
|
|
|
|
|
detail=f"Failed to reconcile mounts: {err}")
|
2025-12-16 15:06:41 -07:00
|
|
|
|
2026-05-22 22:02:30 -06:00
|
|
|
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.",
|
|
|
|
|
)
|
2025-12-16 15:06:41 -07:00
|
|
|
|
2026-05-22 22:02:30 -06:00
|
|
|
return {
|
|
|
|
|
"success": True,
|
|
|
|
|
"device": device_spec,
|
|
|
|
|
"mount_point": request.mount_point,
|
|
|
|
|
}
|
2025-12-11 18:32:55 -07:00
|
|
|
|
|
|
|
|
|
|
|
|
|
@router.post("/unmount")
|
|
|
|
|
async def disk_unmount(request: UnmountRequest):
|
2026-05-22 22:02:30 -06:00
|
|
|
"""Remove a mount from fstab.yaml and reconcile."""
|
2025-12-11 18:32:55 -07:00
|
|
|
_check_native_mode()
|
|
|
|
|
|
2026-05-22 22:02:30 -06:00
|
|
|
try:
|
|
|
|
|
require_normal_mode("unmount")
|
|
|
|
|
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))
|
2025-12-11 18:32:55 -07:00
|
|
|
|
2026-05-22 22:02:30 -06:00
|
|
|
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,
|
|
|
|
|
}
|
2025-12-11 18:32:55 -07:00
|
|
|
|
|
|
|
|
|
2026-05-22 22:02:30 -06:00
|
|
|
# --- fstab.yaml read/write endpoints --------------------------------------
|
2025-12-11 18:32:55 -07:00
|
|
|
|
|
|
|
|
@router.get("/fstab")
|
|
|
|
|
async def list_fstab():
|
2026-05-22 22:02:30 -06:00
|
|
|
"""Return the WROLPi-managed mount table (fstab.yaml)."""
|
2025-12-11 18:32:55 -07:00
|
|
|
_check_native_mode()
|
2026-05-22 22:02:30 -06:00
|
|
|
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
|
|
|
|
|
]
|
2025-12-11 18:32:55 -07:00
|
|
|
|
|
|
|
|
|
|
|
|
|
@router.post("/fstab")
|
|
|
|
|
async def add_fstab(request: FstabRequest):
|
2026-05-22 22:02:30 -06:00
|
|
|
"""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.
|
|
|
|
|
"""
|
2025-12-11 18:32:55 -07:00
|
|
|
_check_native_mode()
|
|
|
|
|
|
2026-05-22 22:02:30 -06:00
|
|
|
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,
|
2025-12-11 18:32:55 -07:00
|
|
|
mount_point=request.mount_point,
|
|
|
|
|
fstype=request.fstype,
|
2026-05-22 22:02:30 -06:00
|
|
|
options=request.options or "defaults",
|
|
|
|
|
))
|
|
|
|
|
save_fstab(data)
|
2025-12-11 18:32:55 -07:00
|
|
|
|
2026-05-22 22:02:30 -06:00
|
|
|
return {"success": True, "device": device_spec,
|
|
|
|
|
"mount_point": request.mount_point}
|
2025-12-11 18:32:55 -07:00
|
|
|
|
|
|
|
|
|
|
|
|
|
@router.delete("/fstab/{mount_point:path}")
|
|
|
|
|
async def delete_fstab(mount_point: str):
|
2026-05-22 22:02:30 -06:00
|
|
|
"""Remove a row from fstab.yaml without triggering a reconcile."""
|
2025-12-11 18:32:55 -07:00
|
|
|
_check_native_mode()
|
|
|
|
|
|
|
|
|
|
# URL decode the mount point
|
|
|
|
|
mount_point = f"/{mount_point}"
|
|
|
|
|
|
2026-05-22 22:02:30 -06:00
|
|
|
try:
|
|
|
|
|
require_normal_mode("remove fstab entry")
|
|
|
|
|
except PermissionError as e:
|
|
|
|
|
raise HTTPException(status_code=403, detail=str(e))
|
2025-12-11 18:32:55 -07:00
|
|
|
|
2025-12-16 15:06:41 -07:00
|
|
|
try:
|
2026-05-22 22:02:30 -06:00
|
|
|
validate_mount_point(mount_point)
|
|
|
|
|
except ValueError as e:
|
|
|
|
|
raise HTTPException(status_code=400, detail=str(e))
|
2025-12-16 15:06:41 -07:00
|
|
|
|
2026-05-22 22:02:30 -06:00
|
|
|
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}
|
2025-12-11 18:32:55 -07:00
|
|
|
|
|
|
|
|
|
|
|
|
|
# --- SMART ---
|
|
|
|
|
|
|
|
|
|
@router.get("/smart")
|
|
|
|
|
async def list_smart():
|
|
|
|
|
"""Get SMART status for all drives."""
|
|
|
|
|
_check_native_mode()
|
|
|
|
|
|
|
|
|
|
if not is_smart_available():
|
2026-05-22 22:02:30 -06:00
|
|
|
return {"available": False,
|
|
|
|
|
"reason": "pySMART not installed or not supported"}
|
2025-12-11 18:32:55 -07:00
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
"available": True,
|
|
|
|
|
"drives": get_all_smart_status(),
|
|
|
|
|
}
|