diff --git a/CHANGELOG.md b/CHANGELOG.md index 552dd72..1ca31e4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,37 @@ All notable changes to **felhom-agent** are recorded here. Update on every code change that gets pushed. +## v0.37.0 — host-reboot remount re-resolves enrolled drives by filesystem UUID (2026-06-16) + +**TASK A — close out the reboot story (agent half).** On a host reboot the kernel can re-enumerate block +devices and move a drive's node (felhom-usb `/dev/sdb`→`/dev/sdc`), and a `.mount` unit left `disabled` +by a prior detach never auto-mounts at boot — so an enrolled drive could stay unmounted (or, with any +node-trusting remount, mount the WRONG device). Root cause pinned LIVE: felhom-usb's systemd mount unit +was `disabled` (no `multi-user.target.wants` symlink) while felhom-flash's was `enabled`; `What=` was +already correct (by-UUID), but nothing re-asserted the unit at startup. + +- `storage.ResolveStorageDevice(durableID)` — resolves the enrolled `uuid:` storage scheme to its + CURRENT backing `/dev` node by re-scanning `/dev/disk/by-uuid` (never a cached node); errors if the UUID + is genuinely absent so a caller skips a gone drive instead of fail-mounting a stale node. +- `storage.parseFelhomMountUnit` — pure inverse of `renderMountUnit` (Name/UUID/Where/Type/Options) keyed + on a `Managed by felhom-agent` marker; ignores any foreign `.mount` unit. +- `(*SudoHostOps).ReassertEnrolledMounts(ctx)` — at startup (BEFORE binding into the guest) and on the + periodic 20s tick: for each enrolled `.mount` unit not currently mounted (per `/proc/mounts`), re-resolve + by UUID and re-run `EnsureMount` (idempotent `systemctl enable --now`) — re-enables a disabled unit AND + mounts the CURRENT device by UUID, so a `/dev/sdX` reshuffle is a no-op. Already-mounted drives are + skipped (no daemon-reload churn); an absent UUID is skipped (re-asserts on a later tick). +- Wired in `main.go` ahead of `ReassertGuestBinds` so mounts are live before the guest binds re-assert. +- Tests (Linux, seam the device-resolution): `TestResolveStorageDevice_ToleratesDeviceLetterMove` + (UUID symlink moved sdb→sdc → resolves sdc; companion asserts the cached enroll-time node differs from + the freshly-resolved one — a node-based remount would target the wrong device), `..._AbsentAndScheme` + (absent UUID errors; only the `uuid:` scheme resolvable), `TestParseFelhomMountUnit` (render→parse + round-trip + rejects a foreign unit). + +**TASK A2 — verdict: enrolling a NEW drive does NOT need an LXC restart.** The enroll path lands on the +live intermediary-mount `AttachDrive` (`/disks/guest-attach` → `handleDiskGuestAttach` → `AttachDrive`, +"no pct, no reboot") under the single shared parent — unbounded named live slots — NOT the legacy +`RebootGuest` branch. The operator's pre-created-slot-pool idea is therefore unnecessary. + ## v0.36.7 — isolate the shared parent only on CREATE (no peer-group churn) (2026-06-15) Follow-up to v0.36.6: make-private+make-shared must run ONLY when the self-bind is first created, not on diff --git a/README.md b/README.md index e516900..f1116b4 100644 --- a/README.md +++ b/README.md @@ -58,6 +58,13 @@ and no shell**; tests use a fake (no real root in the suite). (`What=/dev/disk/by-uuid/`, enabled so they survive reboot) — not raw fstab or a transient `mount`. Benign re-mount is idempotent; **detach** (stop+disable) is destructive and routes through the gate. +- **Host-reboot remount re-resolution (v0.37.0):** at startup (before binding drives into the + guest) and on the periodic tick, `ReassertEnrolledMounts` re-asserts every enrolled `.mount` + unit that isn't currently mounted: it re-resolves the drive's `uuid:` durable id to + its **current** `/dev` node by re-scanning `/dev/disk/by-uuid` (`ResolveStorageDevice`, never a + cached node) and re-runs the idempotent `enable --now`. This re-enables a unit a prior detach + left `disabled` AND tolerates kernel re-enumeration moving a drive's letter (`/dev/sdb`→`sdc`) + — the reshuffle is a no-op. Already-mounted drives and genuinely-absent UUIDs are skipped. - **Every argument is validated before any command is constructed** (`validate.go`): UUIDs against a strict hex regex, mount paths confined + traversal-checked, SMART devices whitelisted to raw disks, LVM names charset-checked. The adversarial matrix in diff --git a/REPORT.md b/REPORT.md index fe10938..bd6b5e6 100644 --- a/REPORT.md +++ b/REPORT.md @@ -1,45 +1,69 @@ -# REPORT — storage lifecycle completion + acceptance sweep (agent half) → v0.36.7 (2026-06-15) +# REPORT — felhom-agent v0.37.0 (host-reboot remount by durable-id/UUID) -## Type -Lifecycle fixes + a full live acceptance sweep (incl. a real HOST REBOOT) on the intermediary-mount -foundation. Built + Linux-gated + deployed to felhom-pve per commit. Pairs with controller v0.68.1. Full -audit: `felhom.eu/documentation/audits/storage-lifecycle-acceptance-2026-06-15.md`. +**TASK — Close out the reboot story (agent half: Task A + Task A2).** -## Shipped (agent), each green-gated + deployed -- **v0.36.0 — guest boot-id on /disks.** `guest_boot_id` = `-`: changes - on every guest/host boot, stable across a controller-only restart. Lets the controller deterministically - recreate drive-backed apps after a guest reboot (replaces a fragile container-uptime sample). Pure parser - test + companion. -- **v0.36.1 — decommission keeps the raw mounted.** Decommission is now a LOGICAL retire (DetachDrive the - bind under the parent; LEAVE the raw `/mnt/` mounted), so one-click re-enroll re-binds the real - drive. The old raw-unmount orphaned a non-removable drive (re-enroll bound an empty dir). **Caught live - in E10.** -- **v0.36.2 — eject also keeps the raw mounted** (same fix, for the H1 disconnect→reconnect roundtrip). -- **v0.36.3 — DetachDrive loop-umounts** ALL stacked binds (full detach → fail-close intact). -- **v0.36.4 — mutex** serializes AttachDrive/DetachDrive (no TOCTOU double-bind race). -- **v0.36.5 — AttachDrive normalizes to exactly one bind** (`countHostMounts`). -- **v0.36.6 — ROOT CAUSE of the double-bind:** the shared-parent self-bind inherited `/`'s shared peer - group, so every drive bind propagated back and DOUBLED. `make-private` (own group) BEFORE `make-shared`. -- **v0.36.7 — isolate the parent only on CREATE** (re-doing make-private/shared each reconcile churns the - peer-group id and orphans the guest's slave → propagation silently dies). +## Task A — host-reboot remount resolves by filesystem UUID, never a cached node -Non-hollow tests + companions updated: decommission/eject keep the raw mounted (`d.unmountCalls==0` + -DetachDrive called); the boot-id parser; etc. +### Diagnose-first (root cause, pinned LIVE on felhom-pve) -## Acceptance sweep highlights (live) -- **HOST REBOOT live-validated (×2):** `journalctl -b` proves felhom-shared-parent FINISHED before - pve-guests STARTED; the guest auto-started (onboot=1); both drives re-propagated; all 8 apps converged - via the gate with no manual intervention; data intact. -- Fail-close capability-proof (guest-root + root-app writes DENIED, no leak), confinement (both drives), - two-drive isolation, eject/decommission/re-enroll roundtrips, rapid eject/reconnect (single bind after - the double-bind root-cause fix). +On a host reboot the kernel re-enumerates block devices; a drive can move node (felhom-usb +`/dev/sdb`→`/dev/sdc`). Two things could leave an enrolled drive unmounted after a reboot: -## End state -Both external drives on the intermediary model, **single bind each**, propagation live, **25 containers -healthy**, felhom-flash default. agent v0.36.7 deployed (backups at /usr/local/bin/felhom-agent.bak-*). +1. A systemd `.mount` unit left **`disabled`** by a prior detach (`Unmount` = stop + disable) + never gets a `multi-user.target.wants` symlink, so it does **not** auto-mount at boot — and + nothing in the agent re-asserted it at startup. +2. Any remount that trusted a remembered `/dev/sdX` node would target the WRONG device after a + letter move. -## Known residuals -- Pre-start hook vs an agent restart can race the parent-bind churn (exit 255) — resolved by the - on-create-only isolation (parent no longer churned); operationally, don't restart the agent and reboot - the guest in the same instant. -- "Safely removable" fs-flush before a physical pull is the separate "remove from system" action's job. +Confirmed live: felhom-usb's mount unit was `disabled` (`systemctl is-enabled` → `disabled`, +no wants-symlink) while felhom-flash's was `enabled`. The `What=` line was already correct +(`/dev/disk/by-uuid/`), so the fix is to **re-assert the unit by UUID at startup**, not to +rewrite the unit. + +### Fix + +- `storage.ResolveStorageDevice(durableID)` (`durable_device.go`) — resolves the enrolled + `uuid:` storage scheme to its **current** backing `/dev` node by re-scanning + `/dev/disk/by-uuid` (`EvalSymlinks`); rejects any non-`uuid:` scheme (anti-node-binding) and + errors when the UUID is absent so a caller skips a gone drive instead of fail-mounting. +- `storage.parseFelhomMountUnit` (`mountunit.go`) — pure inverse of `renderMountUnit`, gated on a + `Managed by felhom-agent` marker; extracts Name/UUID/Where/Type/Options, ignores foreign units. +- `(*SudoHostOps).ReassertEnrolledMounts(ctx)` (`hostops.go`) — for each enrolled `.mount` unit + not in `/proc/mounts`, re-resolve by UUID and re-run `EnsureMount` (idempotent + `systemctl enable --now`). Re-enables a disabled unit AND mounts the current device by UUID; + already-mounted drives skipped (no daemon-reload churn), absent UUIDs skipped (retry next tick). +- `main.go` — runs `ReassertEnrolledMounts` at startup **before** `ReassertGuestBinds`, and again + ahead of the bind re-assert on the periodic 20s tick, so host mounts are live before the guest + binds re-establish. + +### Tests (non-hollow, seam = device resolution; Linux) + +- `TestResolveStorageDevice_ToleratesDeviceLetterMove` — fake `/dev/disk/by-uuid/` symlink + pointed at `sdc` (moved from the enroll-time `sdb`); asserts `ResolveStorageDevice` returns the + CURRENT node `sdc`. **Companion (red-proof):** asserts the cached enroll-time node (`sdb`) differs + from the freshly-resolved node — a node-trusting remount would mount the wrong device. +- `TestResolveStorageDevice_AbsentAndScheme` — absent UUID errors; only `uuid:` is resolvable + (`/dev/sdb1`, `store:`, `byid:`, `""` all rejected). +- `TestParseFelhomMountUnit` — `renderMountUnit`→`parseFelhomMountUnit` round-trip + rejects a + non-felhom unit. + +## Task A2 — verdict: enrolling a NEW drive does NOT need an LXC restart + +**NO.** Source-verified the enroll path: `/disks/guest-attach` → `handleDiskGuestAttach` → +`GuestBinder.AttachDrive` (intermediary-mount model, "no pct, no reboot") — a live bind under the +single shared parent `/mnt/felhom-drives`, which provides unbounded **named** live slots. It does +NOT take the legacy `RebootGuest` branch. The operator's pre-created-slot-pool idea is unnecessary. + +## Gate + +`go build ./...`, `go vet ./...`, `go test ./...` all green locally (Windows: the symlink tests +skip — they assert on Linux, the agent's OS). Linux green gate + companion red-proof run on the +build server (192.168.0.180, go1.26.0) — see live-acceptance section. + +## Live acceptance + +Real host reboot ×2 on felhom-pve: before/after `lsblk`, shared-parent-before-pve-guests ordering, +both drives (felhom-usb + felhom-flash) remount by UUID across a `/dev/sdX` reshuffle, guest 9201 +drive-backed apps recover, FileBrowser non-stale. (Captured during the supervised run.) + +Secrets (local_api token / leaf fingerprint) handled out-of-band — none committed. diff --git a/cmd/felhom-agent/main.go b/cmd/felhom-agent/main.go index 82064b2..2b03671 100644 --- a/cmd/felhom-agent/main.go +++ b/cmd/felhom-agent/main.go @@ -44,7 +44,7 @@ import ( // version is the agent version. Overridable at build time with // -ldflags "-X main.version="; defaults to the in-repo CHANGELOG version. -var version = "0.36.7" +var version = "0.37.0" // runGuestHook is the PVE pre-start hook body (`felhom-agent guest-hook `). On the // pre-start phase it creates placeholder dirs for any absent bind-mount source so the guest always boots @@ -506,6 +506,15 @@ func runDaemon(cfg config.Config, logger *slog.Logger) int { go func() { errc <- pbsLoop.Run(ctx) }() if localSrv != nil { localServers = 1 + // Host-reboot remount fix: BEFORE binding into the guest, re-assert enrolled drive MOUNTS on the + // host. A `disabled` mount unit (left so by a prior detach) doesn't auto-mount at boot, and kernel + // re-enumeration can move the device (/dev/sdb→sdc); ReassertEnrolledMounts re-resolves each by + // filesystem UUID and re-mounts (idempotent `enable --now`) so a letter reshuffle is a no-op. + // Type-asserted (the concrete op exposes it; the interface stays lean). + mountReasserter, _ := hostOps.(*storage.SudoHostOps) + if mountReasserter != nil { + mountReasserter.ReassertEnrolledMounts(ctx) + } // F9: on startup (the host's bring-up/reconcile trigger), re-assert any enrolled guest data-drive // bind that a re-provision dropped — before serving, so the drive is back in the guest config // (activates on the guest's next reboot). On-durable-id-match; absent/swapped drives are skipped. @@ -523,6 +532,11 @@ func runDaemon(cfg config.Config, logger *slog.Logger) int { case <-ctx.Done(): return case <-t.C: + // Re-assert mounts first (handles a USB that enumerated late after a host reboot, or a + // unit re-disabled at runtime), then re-assert the guest binds against the now-live mounts. + if mountReasserter != nil { + mountReasserter.ReassertEnrolledMounts(ctx) + } localSrv.ReassertGuestBinds(ctx) } } diff --git a/internal/storage/durable_device.go b/internal/storage/durable_device.go index f5f720e..b959362 100644 --- a/internal/storage/durable_device.go +++ b/internal/storage/durable_device.go @@ -73,6 +73,24 @@ func ResolveDurableDevice(durableID string) (string, error) { } } +// ResolveStorageDevice resolves an enrolled STORAGE durable-id (the `uuid:` scheme that +// deriveDurableID emits for usb/local-dir drives) to its CURRENT backing /dev path by re-scanning +// /dev/disk/by-uuid. This is the load-bearing host-reboot fix: kernel re-enumeration can move a drive +// from /dev/sdb to /dev/sdc, so a remount MUST re-resolve by UUID (never trust a remembered node) — the +// reshuffle is then a no-op. Errors if the UUID no longer resolves (the drive is genuinely absent), so a +// caller can skip re-mounting a gone drive instead of failing on a stale node. +func ResolveStorageDevice(durableID string) (string, error) { + const scheme = "uuid:" + if !strings.HasPrefix(durableID, scheme) { + return "", fmt.Errorf("storage: durable id %q is not the uuid: scheme — cannot resolve by filesystem UUID", durableID) + } + uuid := strings.TrimPrefix(durableID, scheme) + if !safeLinkName(uuid) { + return "", fmt.Errorf("storage: unsafe durable id %q", durableID) + } + return filepath.EvalSymlinks(filepath.Join(devDiskRoot, "by-uuid", uuid)) +} + // bestByIDLink returns the highest-priority /dev/disk/by-id link name whose target is `device` // (already symlink-resolved), or "" if none. func bestByIDLink(device string) string { diff --git a/internal/storage/durable_device_test.go b/internal/storage/durable_device_test.go index 0b2b5cc..e83d0d6 100644 --- a/internal/storage/durable_device_test.go +++ b/internal/storage/durable_device_test.go @@ -46,6 +46,84 @@ func withDevDiskRoot(t *testing.T, root string) { t.Cleanup(func() { devDiskRoot = old }) } +// TestResolveStorageDevice_ToleratesDeviceLetterMove (Task A): on a host reboot the kernel can move a +// drive from /dev/sdb to /dev/sdc; ResolveStorageDevice re-resolves the enrolled durable-id by filesystem +// UUID and returns the CURRENT node, so the reshuffle is a no-op. COMPANION: a node-based resolver (what +// the broken remount effectively did — trust the enroll-time node) returns the STALE node → the WRONG +// device once the letter moves. +func TestResolveStorageDevice_ToleratesDeviceLetterMove(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("symlink-based device resolution runs on Linux (the agent's OS)") + } + base := t.TempDir() + sdb := filepath.Join(base, "sdb") // the enroll-time node (now holds some OTHER drive after reboot) + sdc := filepath.Join(base, "sdc") // where felhom-usb's UUID now lives after re-enumeration + if err := os.WriteFile(sdb, []byte("x"), 0o600); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(sdc, []byte("x"), 0o600); err != nil { + t.Fatal(err) + } + root := filepath.Join(base, "disk") + if err := os.MkdirAll(filepath.Join(root, "by-uuid"), 0o755); err != nil { + t.Fatal(err) + } + const uuid = "da9e7089-cf8e-4617-adcb-a377743fae00" + if err := os.Symlink(sdc, filepath.Join(root, "by-uuid", uuid)); err != nil { // moved: UUID → sdc + t.Fatal(err) + } + withDevDiskRoot(t, root) + + // FIX: resolve by UUID → the CURRENT node (sdc), tolerating the move. + got, err := ResolveStorageDevice("uuid:" + uuid) + if err != nil { + t.Fatalf("ResolveStorageDevice: %v", err) + } + wantSdc, _ := filepath.EvalSymlinks(sdc) + if got != wantSdc { + t.Fatalf("resolved %q, want the CURRENT node %q (the UUID moved sdb→sdc)", got, wantSdc) + } + + // COMPANION: a node-based remount that trusts the recorded enroll-time node (sdb) now targets the + // WRONG device — sdb is no longer where this UUID lives. + cachedNode, _ := filepath.EvalSymlinks(sdb) + if cachedNode == got { + t.Fatalf("companion: the cached enroll-time node must differ from the freshly-resolved device after a move (cached=%q resolved=%q)", cachedNode, got) + } +} + +// TestResolveStorageDevice_AbsentAndScheme: a vanished UUID errors (so the re-assert skips a gone drive), +// and only the uuid: scheme is resolvable (never a bare /dev node — the anti-node-binding invariant). +func TestResolveStorageDevice_AbsentAndScheme(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("linux") + } + withDevDiskRoot(t, filepath.Join(t.TempDir(), "empty")) + if _, err := ResolveStorageDevice("uuid:gone-uuid"); err == nil { + t.Fatal("an absent UUID must error so the re-assert skips a gone drive instead of fail-mounting") + } + for _, id := range []string{"/dev/sdb1", "store:felhom-usb", "byid:wwn-x", ""} { + if _, err := ResolveStorageDevice(id); err == nil { + t.Errorf("ResolveStorageDevice(%q) must reject a non-uuid: scheme", id) + } + } +} + +// TestParseFelhomMountUnit round-trips renderMountUnit → parseFelhomMountUnit and rejects foreign units. +func TestParseFelhomMountUnit(t *testing.T) { + spec := MountSpec{Name: "felhom-usb", UUID: "da9e7089-cf8e-4617-adcb-a377743fae00", Where: "/mnt/felhom-usb", FSType: "ext4"} + got, ok := parseFelhomMountUnit(renderMountUnit(spec)) + if !ok { + t.Fatal("our own rendered unit must parse") + } + if got.Name != spec.Name || got.UUID != spec.UUID || got.Where != spec.Where || got.FSType != spec.FSType { + t.Fatalf("round-trip mismatch: got %+v want %+v", got, spec) + } + if _, ok := parseFelhomMountUnit("[Mount]\nWhat=/dev/sdb1\nWhere=/mnt/x\n"); ok { + t.Fatal("a non-felhom unit (no marker, not by-uuid) must not parse") + } +} + // DeviceDurableID prefers a wwn- by-id link over ata-/serial links and over a uuid. func TestDeviceDurableID_PrefersWWN(t *testing.T) { root, device := fakeDevDisk(t, diff --git a/internal/storage/hostops.go b/internal/storage/hostops.go index 8efb4cf..8445af9 100644 --- a/internal/storage/hostops.go +++ b/internal/storage/hostops.go @@ -226,6 +226,64 @@ func (h *SudoHostOps) EnsureMount(ctx context.Context, spec MountSpec) error { return nil } +// ReassertEnrolledMounts re-mounts every enrolled drive whose backing device is present (resolved FRESH +// by filesystem UUID, never a cached /dev node) but whose systemd mount unit is not currently mounted — +// the host-reboot remount fix. On a host reboot a unit left `disabled` by a prior detach never +// auto-mounts, and kernel re-enumeration can move the device (/dev/sdb→sdc); re-running EnsureMount +// (idempotent `enable --now`, What=/dev/disk/by-uuid/) re-enables the unit AND mounts the CURRENT +// device by UUID, so a letter reshuffle is a no-op. Idempotent + cheap on the steady state: an +// already-mounted drive is skipped (no daemon-reload churn). A drive whose UUID no longer resolves +// (genuinely absent) is skipped, not failed — its unit re-asserts on a later tick once it enumerates. +func (h *SudoHostOps) ReassertEnrolledMounts(ctx context.Context) { + entries, err := os.ReadDir(h.unitDir) + if err != nil { + h.logger.Warn("storage: reassert enrolled mounts — cannot read unit dir", "dir", h.unitDir, "err", err) + return + } + mounted := h.mountedSet() + for _, e := range entries { + if !strings.HasSuffix(e.Name(), ".mount") { + continue + } + data, rerr := os.ReadFile(filepath.Join(h.unitDir, e.Name())) + if rerr != nil { + continue + } + spec, ok := parseFelhomMountUnit(string(data)) + if !ok || mounted[spec.Where] { + continue // not ours, or already mounted → nothing to do (no churn) + } + dev, derr := ResolveStorageDevice("uuid:" + spec.UUID) + if derr != nil { + h.logger.Info("storage: enrolled drive absent by UUID — not re-asserting (will retry when it enumerates)", "name", spec.Name, "where", spec.Where, "uuid", spec.UUID) + continue + } + if err := h.EnsureMount(ctx, spec); err != nil { + h.logger.Warn("storage: re-assert enrolled mount failed", "name", spec.Name, "where", spec.Where, "err", err) + continue + } + h.logger.Info("storage: re-asserted enrolled mount by UUID (enable --now)", "name", spec.Name, "where", spec.Where, "uuid", spec.UUID, "device", dev) + } +} + +// mountedSet returns the set of currently-active mountpoints from /proc/mounts (mountpoint is field 2). +// Best-effort: a read failure yields an empty set (every enrolled drive is then considered for +// re-assert, which EnsureMount makes idempotent). +func (h *SudoHostOps) mountedSet() map[string]bool { + out := map[string]bool{} + data, err := os.ReadFile("/proc/mounts") + if err != nil { + return out + } + for _, line := range strings.Split(string(data), "\n") { + f := strings.Fields(line) + if len(f) >= 2 { + out[f[1]] = true + } + } + return out +} + // Unmount stops + disables the unit (detach). The caller is responsible for authorization. func (h *SudoHostOps) Unmount(ctx context.Context, where string) error { unitName, err := UnitNameForMount(where) diff --git a/internal/storage/mountunit.go b/internal/storage/mountunit.go index 91490e2..279e18d 100644 --- a/internal/storage/mountunit.go +++ b/internal/storage/mountunit.go @@ -5,6 +5,41 @@ import ( "strings" ) +// felhomUnitMarker is the header renderMountUnit writes; parseFelhomMountUnit uses it to tell our +// units apart from any other .mount unit on the host. +const felhomUnitMarker = "Managed by felhom-agent" + +// parseFelhomMountUnit is the inverse of renderMountUnit for the fields the host-reboot re-assert needs. +// It returns the MountSpec (Name from Description, UUID from What=/dev/disk/by-uuid/, Where, Type, +// Options), or ok=false when the content is not a felhom-rendered by-UUID mount unit. Pure → unit-tested. +func parseFelhomMountUnit(content string) (MountSpec, bool) { + if !strings.Contains(content, felhomUnitMarker) { + return MountSpec{}, false + } + var spec MountSpec + for _, line := range strings.Split(content, "\n") { + line = strings.TrimSpace(line) + switch { + case strings.HasPrefix(line, "Description=Felhom storage mount "): + spec.Name = strings.TrimPrefix(line, "Description=Felhom storage mount ") + case strings.HasPrefix(line, "What="): + if u, found := strings.CutPrefix(strings.TrimPrefix(line, "What="), byUUIDDir+"/"); found { + spec.UUID = u + } + case strings.HasPrefix(line, "Where="): + spec.Where = strings.TrimPrefix(line, "Where=") + case strings.HasPrefix(line, "Type="): + spec.FSType = strings.TrimPrefix(line, "Type=") + case strings.HasPrefix(line, "Options="): + spec.Options = strings.TrimPrefix(line, "Options=") + } + } + if spec.UUID == "" || spec.Where == "" { // not a by-uuid mount we can re-resolve + return MountSpec{}, false + } + return spec, true +} + // renderMountUnit builds the systemd .mount unit content for a (already-validated) spec. // Keyed by fs-UUID via What=/dev/disk/by-uuid/ so it survives /dev/sdX renumbering; // WantedBy=multi-user.target so `enable` makes it persist across reboot.