mirror of
https://github.com/lrnselfreliance/wrolpi.git
synced 2026-10-26 19:01:39 -04:00
147 lines
4.6 KiB
Python
147 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)
|