mirror of
https://github.com/lrnselfreliance/wrolpi.git
synced 2026-10-26 10:51:36 -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>
146 lines
4.6 KiB
Python
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)
|