mirror of
https://github.com/lrnselfreliance/wrolpi.git
synced 2026-10-26 19:01:39 -04:00
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>
183 lines
6.9 KiB
Python
183 lines
6.9 KiB
Python
"""
|
|
Reconcile the live mount state under /media/ with fstab.yaml.
|
|
|
|
The Reconciler reads the desired mount table from fstab.yaml and asks an
|
|
injected MountExecutor what is currently mounted. Then::
|
|
|
|
mount each entry in fstab.yaml that is missing from the live set
|
|
unmount each previously-managed path that has been removed from fstab.yaml
|
|
|
|
"Previously-managed" is the load-bearing concept: we never unmount paths
|
|
that WROLPi has never claimed. The set of managed mount points is
|
|
persisted to ``managed_path`` (a small newline-separated file) so the
|
|
reconciler can tell apart its own mounts from the user's manual ones
|
|
across reboots. On Portable this file is on tmpfs (overlay upper layer)
|
|
which is acceptable — each boot the reconciler starts from a clean managed
|
|
set and re-discovers everything from fstab.yaml.
|
|
|
|
The reconciler never raises for I/O failures. Per-entry failures are
|
|
collected into the ReconcileResult and reported, but the run continues so
|
|
a single broken drive does not block the rest of the boot.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import logging
|
|
from dataclasses import dataclass, field
|
|
from pathlib import Path
|
|
from typing import Optional
|
|
|
|
from controller.lib.fstab_yaml import DEFAULT_PATH as DEFAULT_FSTAB_PATH
|
|
from controller.lib.fstab_yaml import FstabEntry, FstabFile, load as load_fstab
|
|
from controller.lib.mount_executor import MountExecutor
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
DEFAULT_MANAGED_PATH = Path("/run/wrolpi-mounts/managed")
|
|
|
|
# Mount points the reconciler will never touch, even if they appear in
|
|
# fstab.yaml: the primary mount belongs to live-boot persistence (Portable)
|
|
# or the host's installer (/etc/fstab on RPi/Debian), and the temp mount
|
|
# belongs to the Controller's onboarding flow.
|
|
RESERVED_MOUNT_POINTS = frozenset({
|
|
"/media/wrolpi",
|
|
"/media/wrolpi_temp_onboarding",
|
|
})
|
|
|
|
|
|
@dataclass
|
|
class ReconcileResult:
|
|
"""Per-run summary suitable for logging or surfacing back to an API."""
|
|
|
|
mounted: list[str] = field(default_factory=list)
|
|
unmounted: list[str] = field(default_factory=list)
|
|
mount_failures: list[tuple[str, str]] = field(default_factory=list)
|
|
unmount_failures: list[tuple[str, str]] = field(default_factory=list)
|
|
skipped: list[tuple[str, str]] = field(default_factory=list)
|
|
|
|
@property
|
|
def all_succeeded(self) -> bool:
|
|
return not self.mount_failures and not self.unmount_failures
|
|
|
|
|
|
class Reconciler:
|
|
"""Apply fstab.yaml to the live mount state via an injected executor."""
|
|
|
|
def __init__(
|
|
self,
|
|
*,
|
|
executor: MountExecutor,
|
|
fstab_path: Path = DEFAULT_FSTAB_PATH,
|
|
managed_path: Path = DEFAULT_MANAGED_PATH,
|
|
wrolpi_uid: Optional[int] = None,
|
|
wrolpi_gid: Optional[int] = None,
|
|
):
|
|
self._executor = executor
|
|
self._fstab_path = fstab_path
|
|
self._managed_path = managed_path
|
|
self._wrolpi_uid = wrolpi_uid
|
|
self._wrolpi_gid = wrolpi_gid
|
|
|
|
# --- Public entry point ------------------------------------------
|
|
|
|
def apply(self) -> ReconcileResult:
|
|
"""Reconcile once. Idempotent."""
|
|
result = ReconcileResult()
|
|
|
|
desired = load_fstab(self._fstab_path)
|
|
live = self._executor.current_mount_points()
|
|
managed = self._read_managed()
|
|
|
|
# 1. Mount each entry in fstab.yaml that isn't already live.
|
|
for entry in desired.mounts:
|
|
skip = self._skip_reason(entry)
|
|
if skip:
|
|
result.skipped.append((entry.mount_point, skip))
|
|
continue
|
|
if entry.mount_point in live:
|
|
# Already mounted — claim ownership so a later removal can
|
|
# safely unmount it.
|
|
managed.add(entry.mount_point)
|
|
continue
|
|
options = self._inject_uid_gid(entry)
|
|
self._ensure_dir(entry.mount_point)
|
|
r = self._executor.mount(
|
|
entry.device, entry.mount_point, entry.fstype, options,
|
|
)
|
|
if r.ok:
|
|
result.mounted.append(entry.mount_point)
|
|
managed.add(entry.mount_point)
|
|
else:
|
|
result.mount_failures.append((entry.mount_point, r.error or "unknown"))
|
|
|
|
# 2. Unmount each previously-managed mount that is no longer in
|
|
# fstab.yaml. We only consider paths in the managed set, which
|
|
# protects user-mounted /media/* paths the reconciler never owned.
|
|
desired_set = desired.mount_points()
|
|
for mp in sorted(managed - desired_set):
|
|
if mp in RESERVED_MOUNT_POINTS:
|
|
continue
|
|
if mp not in live:
|
|
managed.discard(mp)
|
|
continue
|
|
r = self._executor.unmount(mp)
|
|
if r.ok:
|
|
result.unmounted.append(mp)
|
|
managed.discard(mp)
|
|
else:
|
|
result.unmount_failures.append((mp, r.error or "unknown"))
|
|
|
|
self._write_managed(managed)
|
|
return result
|
|
|
|
# --- Internals -----------------------------------------------------
|
|
|
|
def _skip_reason(self, entry: FstabEntry) -> Optional[str]:
|
|
if not entry.device or not entry.mount_point:
|
|
return "missing required field"
|
|
if entry.mount_point in RESERVED_MOUNT_POINTS:
|
|
return "reserved mount point"
|
|
if not entry.mount_point.startswith("/media/"):
|
|
return "mount point outside /media/"
|
|
return None
|
|
|
|
def _inject_uid_gid(self, entry: FstabEntry) -> str:
|
|
"""exfat/vfat have no POSIX permissions; without uid/gid the
|
|
mounted directory belongs to root and the wrolpi user cannot write
|
|
to it."""
|
|
options = entry.options or "defaults"
|
|
if entry.fstype not in ("exfat", "vfat"):
|
|
return options
|
|
if "uid=" in options or "gid=" in options:
|
|
return options
|
|
if self._wrolpi_uid is None or self._wrolpi_gid is None:
|
|
return options
|
|
return f"{options},uid={self._wrolpi_uid},gid={self._wrolpi_gid}"
|
|
|
|
def _ensure_dir(self, mount_point: str) -> None:
|
|
try:
|
|
Path(mount_point).mkdir(parents=True, exist_ok=True)
|
|
except OSError as e:
|
|
logger.warning("Could not create %s: %s", mount_point, e)
|
|
|
|
def _read_managed(self) -> set[str]:
|
|
if not self._managed_path.exists():
|
|
return set()
|
|
try:
|
|
return {
|
|
line.strip()
|
|
for line in self._managed_path.read_text().splitlines()
|
|
if line.strip()
|
|
}
|
|
except OSError as e:
|
|
logger.warning("Could not read %s: %s", self._managed_path, e)
|
|
return set()
|
|
|
|
def _write_managed(self, managed: set[str]) -> None:
|
|
try:
|
|
self._managed_path.parent.mkdir(parents=True, exist_ok=True)
|
|
self._managed_path.write_text(
|
|
"\n".join(sorted(managed)) + ("\n" if managed else "")
|
|
)
|
|
except OSError as e:
|
|
logger.warning("Could not write %s: %s", self._managed_path, e)
|