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>
67 lines
2.3 KiB
Python
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())
|