agent v0.37.0: host-reboot remount re-resolves enrolled drives by fs-UUID

TASK A — close out the reboot story (agent half). Root cause (pinned live on
felhom-pve): an enrolled .mount unit left `disabled` by a prior detach never
auto-mounts at boot, and kernel re-enumeration can move a drive's node
(/dev/sdb->sdc). Fix re-asserts every enrolled mount by filesystem UUID at
startup + on the periodic tick.

- ResolveStorageDevice: resolve uuid:<fs-uuid> -> current /dev node via
  /dev/disk/by-uuid (never a cached node); errors if absent.
- parseFelhomMountUnit: pure inverse of renderMountUnit (marker-gated).
- (*SudoHostOps).ReassertEnrolledMounts: re-run EnsureMount (enable --now) for
  any enrolled unit not in /proc/mounts; idempotent, skips mounted/absent.
- main.go: runs before ReassertGuestBinds at startup + on the 20s tick.
- tests (Linux, seam=device resolution): letter-move tolerated (sdb->sdc) +
  red-proof companion, absent/scheme rejection, render->parse round-trip.

TASK A2 verdict: enrolling a NEW drive does NOT need an LXC restart — the path
lands on the live AttachDrive (shared parent, named live slots), not RebootGuest.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-16 17:49:00 +02:00
parent 437f096d9d
commit a621f4c5a0
8 changed files with 305 additions and 40 deletions
+35
View File
@@ -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/<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/<UUID> so it survives /dev/sdX renumbering;
// WantedBy=multi-user.target so `enable` makes it persist across reboot.