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

67 lines
2.3 KiB
Python

"""
Entry point for wrolpi-mounts.service.
This module is intentionally tiny: it wires up production dependencies
(real subprocess executor, real wrolpi uid/gid lookup, real fstab.yaml on
the drive) and asks the Reconciler to apply. All real work lives in
controller.lib.reconciler; tests for the orchestration live in
test_reconciler.py using FakeMountExecutor.
"""
from __future__ import annotations
import logging
import sys
from controller.lib.disks import get_wrolpi_uid_gid
from controller.lib.fstab_migration import migrate_etc_fstab
from controller.lib.mount_executor import SubprocessMountExecutor
from controller.lib.reconciler import Reconciler
logger = logging.getLogger(__name__)
def main(argv=None) -> int:
logging.basicConfig(
level=logging.INFO,
format="%(levelname)s wrolpi-mounts: %(message)s",
)
# Migration is idempotent (marker-gated). Doing it here means a freshly
# upgraded WROLPi picks up legacy /etc/fstab entries on the first boot
# of the new mount system, before we try to apply them.
try:
migrate_etc_fstab()
except Exception as e: # noqa: BLE001 - never fail boot on migration error
logger.error("fstab migration raised, continuing without it: %s", e)
try:
wrolpi_uid, wrolpi_gid = get_wrolpi_uid_gid()
except Exception as e: # noqa: BLE001 - best-effort enrichment
logger.warning("Could not resolve wrolpi uid/gid (%s)", e)
wrolpi_uid, wrolpi_gid = None, None
reconciler = Reconciler(
executor=SubprocessMountExecutor(),
wrolpi_uid=wrolpi_uid,
wrolpi_gid=wrolpi_gid,
)
result = reconciler.apply()
logger.info(
"Reconcile complete: %d mounted, %d unmounted, %d mount failures, %d unmount failures",
len(result.mounted), len(result.unmounted),
len(result.mount_failures), len(result.unmount_failures),
)
for mp, err in result.mount_failures:
logger.error("Failed to mount %s: %s", mp, err)
for mp, err in result.unmount_failures:
logger.error("Failed to unmount %s: %s", mp, err)
# Always return 0: the service must not block boot if a single mount
# is broken. Failures are logged and surfaced via the journal.
return 0
if __name__ == "__main__":
sys.exit(main())