wrolpi/controller/lib/onboarding.py
Roland 719905cb77 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>
2026-05-24 12:44:05 -06:00

330 lines
11 KiB
Python

"""
Onboarding orchestration for fresh WROLPi setup.
Guides a user through selecting a primary drive, reading its config,
mounting all drives per the config, and running repair.
"""
import logging
from pathlib import Path
import yaml
from controller.lib.config import (
get_config_value,
get_media_directory,
is_docker_mode,
is_primary_drive_mounted,
reload_config_from_drive,
save_config,
update_config,
)
from controller.lib.disks import check_shadowed_data, get_block_devices, mount_drive, unmount_drive
from controller.lib.fstab import add_fstab_entry
from controller.lib.scripts import start_script
logger = logging.getLogger(__name__)
TEMP_MOUNT_PATH = "/media/wrolpi_temp_onboarding"
# System mountpoints that should never be candidates
EXCLUDED_MOUNTPOINTS = {"/", "/boot", "/boot/firmware"}
# Partition labels that should never be candidates (case-insensitive)
EXCLUDED_LABELS = {"efi", "efi system partition"}
def get_onboarding_candidates() -> list[dict]:
"""
Get partitions that could be the primary WROLPi drive.
Includes both unmounted drives and drives auto-mounted under /media/
(e.g. /media/pi/ on Raspberry Pi). Excludes system mounts and drives
already mounted at the WROLPi primary location.
Returns:
List of dicts with drive info (path, name, size, fstype, label, uuid, model, mountpoint).
"""
devices = get_block_devices()
candidates = []
for d in devices:
# Skip system mounts
if d.mountpoint in EXCLUDED_MOUNTPOINTS:
continue
# Skip the WROLPi primary mount and temp onboarding mount
if d.mountpoint and d.mountpoint.startswith("/media/wrolpi"):
continue
# Skip EFI system partitions
if d.label and d.label.lower() in EXCLUDED_LABELS:
continue
candidates.append({
"path": d.path,
"name": d.name,
"size": d.size,
"fstype": d.fstype,
"label": d.label,
"uuid": d.uuid,
"model": d.model,
"mountpoint": d.mountpoint,
})
return candidates
def _get_current_mountpoint(device_path: str):
"""Get the current mountpoint of a device, or None if not mounted."""
for d in get_block_devices():
if d.path == device_path and d.mountpoint:
return d.mountpoint
return None
def _cleanup_temp_mount() -> None:
"""Clean up any stale temp mount at TEMP_MOUNT_PATH."""
temp_path = Path(TEMP_MOUNT_PATH)
if temp_path.is_mount():
logger.info("Cleaning up stale temp mount at %s", TEMP_MOUNT_PATH)
unmount_drive(TEMP_MOUNT_PATH, lazy=True)
if temp_path.exists():
try:
temp_path.rmdir()
except OSError:
pass
def probe_drive(device_path: str, fstype: str) -> dict:
"""
Temp-mount a drive and check for WROLPi configuration.
Leaves the drive temp-mounted so commit_onboarding can proceed quickly.
Args:
device_path: Device path (e.g. /dev/sda1)
fstype: Filesystem type
Returns:
dict with config_found, mounts (from config), device_path, fstype, error
"""
_cleanup_temp_mount()
# If the drive is already mounted somewhere (e.g. auto-mounted on RPi),
# unmount it first so we can temp-mount it ourselves.
original_mountpoint = _get_current_mountpoint(device_path)
if original_mountpoint:
logger.info("Unmounting auto-mounted drive %s from %s", device_path, original_mountpoint)
unmount_result = unmount_drive(original_mountpoint, lazy=True)
if not unmount_result.get("success"):
return {
"success": False,
"error": f"Failed to unmount {original_mountpoint}: {unmount_result.get('error')}",
"config_found": False,
"mounts": [],
"device_path": device_path,
"fstype": fstype,
}
result = mount_drive(device_path, TEMP_MOUNT_PATH, fstype=fstype)
if not result.get("success"):
return {
"success": False,
"error": result.get("error", "Failed to temp-mount drive"),
"config_found": False,
"mounts": [],
"device_path": device_path,
"fstype": fstype,
}
config_dir = Path(TEMP_MOUNT_PATH) / "config"
controller_config = config_dir / "controller.yaml"
wrolpi_config = config_dir / "wrolpi.yaml"
# A WROLPi drive may have controller.yaml (new) or wrolpi.yaml (legacy)
config_found = controller_config.exists() or wrolpi_config.exists()
mounts = []
if controller_config.exists():
try:
with open(controller_config) as f:
config_data = yaml.safe_load(f) or {}
mounts = config_data.get("drives", {}).get("mounts", [])
except (IOError, yaml.YAMLError) as e:
logger.warning("Failed to read config from temp mount: %s", e)
return {
"success": True,
"config_found": config_found,
"mounts": mounts,
"device_path": device_path,
"fstype": fstype,
}
def cancel_probe() -> dict:
"""Unmount temp mount so user can pick a different drive."""
_cleanup_temp_mount()
return {"success": True}
def commit_onboarding(
device_path: str,
fstype: str,
force_shadowed: bool = False,
) -> dict:
"""
Execute the full onboarding setup.
1. Unmount temp mount
2. Check for shadowed data at the mount target
3. Mount primary drive at /media/wrolpi
4. Add fstab entry for primary
5. Reload config
6. Mount secondary drives per config
7. Add fstab entries for secondary drives
8. Start repair
Args:
device_path: Device path
fstype: Filesystem type
force_shadowed: If True, proceed even when existing data at the
mount target would be hidden by the new mount
Returns:
dict with success, error, mounts, repair_started, and on a shadowed
block, `needs_force` and `shadowed_data`
"""
media_dir = str(get_media_directory())
mounted_mounts = []
try:
# Step 1: Unmount temp mount if present
_cleanup_temp_mount()
# Step 1b: Clean up any transient config directory created on the root
# filesystem (e.g. by generate_certificates.sh running before the drive
# was mounted). This prevents stale root CAs from lingering.
transient_config = Path(media_dir) / "config"
if transient_config.exists() and not Path(media_dir).is_mount():
import shutil
shutil.rmtree(transient_config)
logger.info("Removed transient config directory at %s", transient_config)
# Step 1c: Check for shadowed data. If the user has been downloading
# to /media/wrolpi without a drive mounted, mounting now would hide
# those files on the SD card / root filesystem.
if not force_shadowed:
shadowed = check_shadowed_data(media_dir)
if shadowed:
return {
"success": False,
"needs_force": "shadowed",
"shadowed_data": shadowed,
"error": (
f"{media_dir} 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."
),
"mounts": [],
"repair_started": False,
}
# Step 2: Mount primary drive
result = mount_drive(device_path, media_dir, fstype=fstype)
if not result.get("success"):
return {
"success": False,
"error": f"Failed to mount primary drive: {result.get('error')}",
"mounts": [],
"repair_started": False,
}
mounted_mounts.append(media_dir)
# Step 3: Ensure config directory exists
config_dir = Path(media_dir) / "config"
config_dir.mkdir(parents=True, exist_ok=True)
# Step 4: Add fstab entry for primary drive
fstab_result = add_fstab_entry(device_path, media_dir, fstype)
if not fstab_result.get("success"):
logger.warning("Failed to add primary fstab entry: %s", fstab_result.get("error"))
# Step 5: Reload config from drive and ensure controller.yaml exists
reload_config_from_drive()
try:
save_config()
except RuntimeError:
pass
# Step 5b: If config has no mounts, save the primary drive to config
current_mounts = get_config_value("drives.mounts", [])
if not current_mounts and fstab_result.get("success"):
mount_entry = {
"device": fstab_result.get("device", device_path),
"mount_point": media_dir,
"fstype": fstype,
"options": "defaults",
}
update_config("drives.mounts", [mount_entry])
try:
save_config()
except RuntimeError:
pass
# Step 6: Mount secondary drives per config
config_path = Path(media_dir) / "config" / "controller.yaml"
secondary_mounts = []
if config_path.exists():
try:
with open(config_path) as f:
config_data = yaml.safe_load(f) or {}
secondary_mounts = config_data.get("drives", {}).get("mounts", [])
except (IOError, yaml.YAMLError) as e:
logger.warning("Failed to read config for secondary mounts: %s", e)
for mount_entry in secondary_mounts:
mount_point = mount_entry.get("mount_point")
mount_device = mount_entry.get("device")
mount_fstype = mount_entry.get("fstype", "auto")
mount_options = mount_entry.get("options", "defaults")
# Skip the primary mount (already mounted)
if mount_point == media_dir:
continue
if not mount_device or not mount_point:
continue
mount_result = mount_drive(mount_device, mount_point, fstype=mount_fstype, options=mount_options)
if mount_result.get("success"):
mounted_mounts.append(mount_point)
# No /etc/fstab write — entry already lives in drives.mounts
# (it's the source we just read), so wrolpi-mounts.service
# will re-apply it on every subsequent boot.
else:
logger.warning(
"Failed to mount secondary drive %s at %s: %s",
mount_device, mount_point, mount_result.get("error"),
)
# Step 7: Start repair
repair_started = False
repair_result = start_script("repair")
if repair_result.get("success"):
repair_started = True
else:
logger.warning("Failed to start repair: %s", repair_result.get("error"))
return {
"success": True,
"mounts": mounted_mounts,
"repair_started": repair_started,
}
except Exception as e:
logger.exception("Onboarding failed")
return {
"success": False,
"error": str(e),
"mounts": mounted_mounts,
"repair_started": False,
}