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>
229 lines
8.1 KiB
Python
229 lines
8.1 KiB
Python
"""
|
|
One-time migration of WROLPi-managed mount entries into fstab.yaml.
|
|
|
|
Two legacy sources exist:
|
|
|
|
1. ``/etc/fstab`` — older WROLPi versions wrote WROLPi-managed mounts as
|
|
ordinary fstab lines so systemd-fstab-generator would mount them at
|
|
boot. On Portable this is read-only squashfs (writes go to RAM and
|
|
evaporate), and on any host an /etc/fstab entry is tied to *that* host,
|
|
not the drive.
|
|
|
|
2. ``controller.yaml drives.mounts`` — a brief intermediate iteration
|
|
stored the mount table alongside Controller settings in
|
|
``/media/wrolpi/config/controller.yaml``. Lives on the drive but
|
|
conflates "definitions" with "applier"; we split them out into a
|
|
dedicated file.
|
|
|
|
Both sources feed into ``/media/wrolpi/config/fstab.yaml``. Each source
|
|
is migrated at most once (gated by a marker file). /etc/fstab is backed
|
|
up; migrated lines are commented in place rather than deleted so the host
|
|
admin can hand-recover if something goes wrong.
|
|
"""
|
|
|
|
import logging
|
|
import shutil
|
|
from pathlib import Path
|
|
|
|
import yaml
|
|
|
|
from controller.lib.fstab_yaml import (
|
|
DEFAULT_PATH as DEFAULT_FSTAB_YAML_PATH,
|
|
FstabEntry,
|
|
load as load_fstab_yaml,
|
|
save as save_fstab_yaml,
|
|
)
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
FSTAB_PATH = Path("/etc/fstab")
|
|
CONTROLLER_YAML_PATH = Path("/media/wrolpi/config/controller.yaml")
|
|
MARKER_PATH = Path("/media/wrolpi/config/.fstab-migrated")
|
|
BACKUP_PATH = Path("/etc/fstab.wrolpi-migration.backup")
|
|
|
|
# Mount points that legitimately appear in /etc/fstab or drives.mounts but
|
|
# must not be lifted into fstab.yaml: the primary's mount is host-specific
|
|
# (live-boot, /etc/fstab installer entry, or repair.sh sets it up directly)
|
|
# and the temp onboarding mount belongs to the Controller's onboarding flow.
|
|
EXCLUDED_MIGRATION_MOUNT_POINTS = frozenset({
|
|
"/media/wrolpi",
|
|
"/media/wrolpi_temp_onboarding",
|
|
})
|
|
|
|
MIGRATED_PREFIX = "# migrated to fstab.yaml:"
|
|
|
|
|
|
# --- /etc/fstab parser ----------------------------------------------------
|
|
|
|
def _parse_fstab_text(text: str) -> list[dict]:
|
|
"""Parse the contents of an fstab file into structured entries."""
|
|
entries: list[dict] = []
|
|
for raw in text.splitlines(keepends=False):
|
|
stripped = raw.strip()
|
|
if not stripped or stripped.startswith("#"):
|
|
entries.append({"type": "comment", "line": raw})
|
|
continue
|
|
parts = stripped.split()
|
|
if len(parts) >= 4:
|
|
entries.append({
|
|
"type": "mount",
|
|
"device": parts[0],
|
|
"mount_point": parts[1],
|
|
"fstype": parts[2],
|
|
"options": parts[3],
|
|
"line": raw,
|
|
})
|
|
else:
|
|
entries.append({"type": "comment", "line": raw})
|
|
return entries
|
|
|
|
|
|
def _is_migration_candidate(entry: dict) -> bool:
|
|
if entry.get("type") != "mount":
|
|
return False
|
|
mp = entry.get("mount_point", "")
|
|
if not mp.startswith("/media"):
|
|
return False
|
|
if mp in EXCLUDED_MIGRATION_MOUNT_POINTS:
|
|
return False
|
|
return True
|
|
|
|
|
|
# --- controller.yaml helpers ----------------------------------------------
|
|
|
|
def _read_controller_yaml(path: Path) -> dict:
|
|
if not path.exists():
|
|
return {}
|
|
try:
|
|
return yaml.safe_load(path.read_text()) or {}
|
|
except (OSError, yaml.YAMLError) as e:
|
|
logger.warning("Could not parse %s, treating as empty: %s", path, e)
|
|
return {}
|
|
|
|
|
|
def _save_controller_yaml(path: Path, data: dict) -> None:
|
|
path.parent.mkdir(parents=True, exist_ok=True)
|
|
path.write_text(yaml.safe_dump(data, default_flow_style=False, sort_keys=True))
|
|
|
|
|
|
# --- Main entry point -----------------------------------------------------
|
|
|
|
def migrate_etc_fstab(
|
|
*,
|
|
fstab_path: Path = FSTAB_PATH,
|
|
controller_yaml_path: Path = CONTROLLER_YAML_PATH,
|
|
fstab_yaml_path: Path = None,
|
|
marker_path: Path = MARKER_PATH,
|
|
backup_path: Path = BACKUP_PATH,
|
|
) -> dict:
|
|
"""Lift WROLPi-managed mounts from /etc/fstab and
|
|
controller.yaml drives.mounts into fstab.yaml.
|
|
|
|
Returns ``{migrated, reason?, count}`` where ``count`` is the number of
|
|
new entries added to fstab.yaml.
|
|
"""
|
|
if marker_path.exists():
|
|
return {"migrated": False, "reason": "already-migrated", "count": 0}
|
|
|
|
fstab_yaml_path = fstab_yaml_path or DEFAULT_FSTAB_YAML_PATH
|
|
|
|
# --- Gather candidates from both sources ----------------------------
|
|
etc_fstab_candidates: list[dict] = []
|
|
if fstab_path.exists():
|
|
try:
|
|
etc_fstab_text = fstab_path.read_text()
|
|
except OSError as e:
|
|
logger.warning("Could not read %s: %s", fstab_path, e)
|
|
etc_fstab_text = ""
|
|
etc_fstab_entries = _parse_fstab_text(etc_fstab_text)
|
|
etc_fstab_candidates = [
|
|
e for e in etc_fstab_entries if _is_migration_candidate(e)
|
|
]
|
|
else:
|
|
etc_fstab_text = ""
|
|
etc_fstab_entries = []
|
|
|
|
controller_data = _read_controller_yaml(controller_yaml_path)
|
|
drives_mounts_candidates = []
|
|
for m in (controller_data.get("drives") or {}).get("mounts") or []:
|
|
mp = m.get("mount_point", "")
|
|
if not mp.startswith("/media") or mp in EXCLUDED_MIGRATION_MOUNT_POINTS:
|
|
continue
|
|
drives_mounts_candidates.append(m)
|
|
|
|
# --- Backup /etc/fstab before any modification ----------------------
|
|
if etc_fstab_candidates:
|
|
try:
|
|
shutil.copy2(fstab_path, backup_path)
|
|
except OSError as e:
|
|
logger.warning("Could not back up %s: %s", fstab_path, e)
|
|
|
|
# --- Merge into fstab.yaml (dedup by mount_point) -------------------
|
|
fstab_data = load_fstab_yaml(fstab_yaml_path)
|
|
existing = fstab_data.mount_points()
|
|
added = 0
|
|
|
|
for cand in etc_fstab_candidates:
|
|
if cand["mount_point"] in existing:
|
|
continue
|
|
fstab_data.add_or_replace(FstabEntry(
|
|
device=cand["device"],
|
|
mount_point=cand["mount_point"],
|
|
fstype=cand["fstype"],
|
|
options=cand.get("options") or "defaults",
|
|
))
|
|
existing.add(cand["mount_point"])
|
|
added += 1
|
|
|
|
for cand in drives_mounts_candidates:
|
|
mp = cand["mount_point"]
|
|
if mp in existing:
|
|
continue
|
|
fstab_data.add_or_replace(FstabEntry(
|
|
device=cand.get("device", ""),
|
|
mount_point=mp,
|
|
fstype=cand.get("fstype") or "auto",
|
|
options=cand.get("options") or "defaults",
|
|
))
|
|
existing.add(mp)
|
|
added += 1
|
|
|
|
if etc_fstab_candidates or drives_mounts_candidates:
|
|
save_fstab_yaml(fstab_data, fstab_yaml_path)
|
|
|
|
# --- Comment out migrated entries in /etc/fstab ---------------------
|
|
if etc_fstab_candidates:
|
|
candidate_mps = {e["mount_point"] for e in etc_fstab_candidates}
|
|
new_lines = []
|
|
for entry in etc_fstab_entries:
|
|
if (entry["type"] == "mount"
|
|
and entry["mount_point"] in candidate_mps):
|
|
new_lines.append(f"{MIGRATED_PREFIX} {entry['line']}")
|
|
else:
|
|
new_lines.append(entry["line"])
|
|
suffix = "\n" if etc_fstab_text.endswith("\n") else ""
|
|
try:
|
|
fstab_path.write_text("\n".join(new_lines) + suffix)
|
|
except OSError as e:
|
|
logger.warning("Could not rewrite %s: %s", fstab_path, e)
|
|
|
|
# --- Strip drives.mounts from controller.yaml -----------------------
|
|
if drives_mounts_candidates and (controller_data.get("drives") or {}).get("mounts"):
|
|
drives = controller_data.get("drives") or {}
|
|
drives.pop("mounts", None)
|
|
if not drives:
|
|
controller_data.pop("drives", None)
|
|
else:
|
|
controller_data["drives"] = drives
|
|
_save_controller_yaml(controller_yaml_path, controller_data)
|
|
|
|
# --- Mark done ------------------------------------------------------
|
|
marker_path.parent.mkdir(parents=True, exist_ok=True)
|
|
marker_path.touch()
|
|
|
|
logger.info(
|
|
"Migration complete: %d /etc/fstab candidate(s), %d drives.mounts "
|
|
"candidate(s), %d new entry/entries written to fstab.yaml",
|
|
len(etc_fstab_candidates), len(drives_mounts_candidates), added,
|
|
)
|
|
return {"migrated": True, "count": added}
|