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:
@@ -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/<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)
|
||||
|
||||
Reference in New Issue
Block a user