wrolpi/controller/lib/fstab_yaml.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

146 lines
4.6 KiB
Python

"""
Reader/writer for /media/wrolpi/config/fstab.yaml.
This file is the WROLPi-managed mount table. It lives on the drive itself
(so it travels between hosts) and is the sole input to the Reconciler.
Schema::
version: 1
mounts:
- device: UUID=... # filesystem UUID; the entry is portable
mount_point: /media/... # must be under /media
fstype: ext4
options: defaults # mount(8) -o options
The Controller defines what should be mounted by editing this file; the
Reconciler (invoked by wrolpi-mounts.service) applies the diff between this
file and the live mount state.
"""
from __future__ import annotations
import logging
from dataclasses import dataclass, field
from pathlib import Path
from typing import Optional
import yaml
logger = logging.getLogger(__name__)
DEFAULT_PATH = Path("/media/wrolpi/config/fstab.yaml")
SCHEMA_VERSION = 1
@dataclass
class FstabEntry:
"""A single row in fstab.yaml."""
device: str
mount_point: str
fstype: str
options: str = "defaults"
def to_dict(self) -> dict:
return {
"device": self.device,
"mount_point": self.mount_point,
"fstype": self.fstype,
"options": self.options,
}
@classmethod
def from_dict(cls, data: dict) -> "FstabEntry":
return cls(
device=data.get("device", ""),
mount_point=data.get("mount_point", ""),
fstype=data.get("fstype", "auto"),
options=data.get("options") or "defaults",
)
@dataclass
class FstabFile:
"""In-memory representation of fstab.yaml."""
version: int = SCHEMA_VERSION
mounts: list[FstabEntry] = field(default_factory=list)
# --- Lookup helpers ------------------------------------------------
def find_by_mount_point(self, mount_point: str) -> Optional[FstabEntry]:
for m in self.mounts:
if m.mount_point == mount_point:
return m
return None
def mount_points(self) -> set[str]:
return {m.mount_point for m in self.mounts}
# --- Mutation -------------------------------------------------------
def add_or_replace(self, entry: FstabEntry) -> None:
"""Insert ``entry``, replacing any prior row that shares its
mount_point OR its device. The device clause handles the "remount
the same drive to a different path" case so we don't accumulate
stale rows."""
self.mounts = [
m for m in self.mounts
if m.mount_point != entry.mount_point and m.device != entry.device
]
self.mounts.append(entry)
def remove_by_mount_point(self, mount_point: str) -> bool:
"""Remove the entry at ``mount_point``. Returns True if a row was
removed, False if none matched."""
before = len(self.mounts)
self.mounts = [m for m in self.mounts if m.mount_point != mount_point]
return len(self.mounts) < before
# --- File I/O -------------------------------------------------------------
def load(path: Optional[Path] = None) -> FstabFile:
"""Read fstab.yaml. Returns an empty FstabFile if the file is absent
or malformed; both are normal startup conditions on a fresh drive.
``path`` defaults to the module-level ``DEFAULT_PATH`` at call time
(resolved on each invocation, so tests can redirect via monkeypatch).
"""
if path is None:
path = DEFAULT_PATH
if not path.exists():
return FstabFile()
try:
raw = yaml.safe_load(path.read_text()) or {}
except yaml.YAMLError as e:
logger.warning("fstab.yaml at %s is malformed (%s); treating as empty", path, e)
return FstabFile()
version = raw.get("version", SCHEMA_VERSION)
mounts = [FstabEntry.from_dict(m) for m in (raw.get("mounts") or [])]
return FstabFile(version=version, mounts=mounts)
def save(data: FstabFile, path: Optional[Path] = None) -> None:
"""Write fstab.yaml atomically (write-then-rename). Creates the parent
directory if needed. Sorted by mount_point so diffs stay tidy.
``path`` defaults to the module-level ``DEFAULT_PATH`` at call time so
tests can redirect via monkeypatch.
"""
if path is None:
path = DEFAULT_PATH
path.parent.mkdir(parents=True, exist_ok=True)
sorted_mounts = sorted(data.mounts, key=lambda m: m.mount_point)
serialised = yaml.safe_dump(
{
"version": data.version,
"mounts": [m.to_dict() for m in sorted_mounts],
},
default_flow_style=False,
sort_keys=True,
)
tmp = path.with_suffix(path.suffix + ".tmp")
tmp.write_text(serialised)
tmp.replace(path)