a621f4c5a0
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>
80 lines
3.0 KiB
Go
80 lines
3.0 KiB
Go
package storage
|
|
|
|
import (
|
|
"fmt"
|
|
"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.
|
|
//
|
|
// All interpolated values are pre-validated by the caller (ValidateUUID / ValidateMountPath
|
|
// / validateUnitOpt), so no value here can carry a newline or inject an extra directive.
|
|
func renderMountUnit(spec MountSpec) string {
|
|
what := byUUIDDir + "/" + spec.UUID
|
|
var b strings.Builder
|
|
b.WriteString("# Managed by felhom-agent — do not edit by hand.\n")
|
|
b.WriteString("[Unit]\n")
|
|
fmt.Fprintf(&b, "Description=Felhom storage mount %s\n", sanitizeDesc(spec.Name))
|
|
b.WriteString("After=local-fs-pre.target\n")
|
|
b.WriteString("\n[Mount]\n")
|
|
fmt.Fprintf(&b, "What=%s\n", what)
|
|
fmt.Fprintf(&b, "Where=%s\n", spec.Where)
|
|
if spec.FSType != "" {
|
|
fmt.Fprintf(&b, "Type=%s\n", spec.FSType)
|
|
}
|
|
if spec.Options != "" {
|
|
fmt.Fprintf(&b, "Options=%s\n", spec.Options)
|
|
}
|
|
b.WriteString("\n[Install]\n")
|
|
b.WriteString("WantedBy=multi-user.target\n")
|
|
return b.String()
|
|
}
|
|
|
|
// sanitizeDesc keeps the Description line single-line and harmless (it is cosmetic; the
|
|
// name is already a Proxmox storage id, but be defensive against any newline).
|
|
func sanitizeDesc(name string) string {
|
|
name = strings.ReplaceAll(name, "\n", " ")
|
|
name = strings.ReplaceAll(name, "\r", " ")
|
|
if name == "" {
|
|
return "(unnamed)"
|
|
}
|
|
return name
|
|
}
|