Files
admin a621f4c5a0 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>
2026-06-16 17:49:00 +02:00

147 lines
5.9 KiB
Go

package storage
import (
"fmt"
"os"
"path/filepath"
"sort"
"strings"
)
// Durable BLOCK-DEVICE identity for the slice-10B operator-signed wipe (anti-retarget). The
// signed op binds a DURABLE id (a WWN / hardware serial, falling back to a filesystem UUID), and
// execution re-resolves it to the CURRENT /dev path — so "wipe device X" wipes that exact physical
// device, never whatever happens to be at /dev/sdb now. These reads are world-readable udev
// symlinks under /dev/disk/by-id and /dev/disk/by-uuid — NO privilege and NO subprocess (the
// root-CLI fence is untouched).
//
// devDiskRoot is overridable for tests (a fake /dev/disk layout).
var devDiskRoot = "/dev/disk"
// durable-id scheme prefixes. byid: a /dev/disk/by-id/<name> entry (preferred — wwn/serial,
// survives reformat + re-cabling); byuuid: a filesystem UUID (fallback — survives re-cabling but
// not a reformat, acceptable for a one-shot wipe resolved immediately before wiping).
const (
durableByID = "byid:"
durableByUUID = "byuuid:"
)
// byIDPriority ranks /dev/disk/by-id link prefixes most-stable-first: wwn (hardware world-wide
// name) > nvme-eui (NVMe EUI) > nvme-/ata-/scsi-/usb- (model+serial). dm-/lvm-/md- names are
// excluded (they are mapper constructs, not the physical disk we want to bind a wipe to).
var byIDPriority = []string{"wwn-", "nvme-eui.", "nvme-", "ata-", "scsi-", "usb-"}
// DeviceDurableID derives a stable durable identifier for a block device by scanning the
// world-readable udev symlinks. Prefers a by-id (wwn/serial) link, then a filesystem UUID. Errors
// when neither is resolvable (a device with no durable identity cannot be safely wipe-bound).
func DeviceDurableID(device string) (string, error) {
target, err := filepath.EvalSymlinks(device)
if err != nil {
return "", fmt.Errorf("storage: resolve %s: %w", device, err)
}
// 1. best by-id link pointing at this device.
if name := bestByIDLink(target); name != "" {
return durableByID + name, nil
}
// 2. fallback: a filesystem UUID symlink.
if uuid := byUUIDFor(target); uuid != "" {
return durableByUUID + uuid, nil
}
return "", fmt.Errorf("storage: no durable id (wwn/serial/uuid) for device %s", device)
}
// ResolveDurableDevice resolves a durable id back to the CURRENT canonical /dev path, or errors if
// it no longer resolves (the device was physically removed/replaced — the wipe must then refuse).
func ResolveDurableDevice(durableID string) (string, error) {
switch {
case strings.HasPrefix(durableID, durableByID):
name := strings.TrimPrefix(durableID, durableByID)
if !safeLinkName(name) {
return "", fmt.Errorf("storage: unsafe durable id %q", durableID)
}
return filepath.EvalSymlinks(filepath.Join(devDiskRoot, "by-id", name))
case strings.HasPrefix(durableID, durableByUUID):
uuid := strings.TrimPrefix(durableID, durableByUUID)
if !safeLinkName(uuid) {
return "", fmt.Errorf("storage: unsafe durable id %q", durableID)
}
return filepath.EvalSymlinks(filepath.Join(devDiskRoot, "by-uuid", uuid))
default:
// A bare path or unknown scheme is REFUSED — a wipe must bind to a durable id, never a
// mutable /dev path (the anti-retarget invariant).
return "", fmt.Errorf("storage: durable id %q is not a durable scheme (byid:/byuuid:) — refusing path-only binding", durableID)
}
}
// ResolveStorageDevice resolves an enrolled STORAGE durable-id (the `uuid:<fs-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 {
dir := filepath.Join(devDiskRoot, "by-id")
entries, err := os.ReadDir(dir)
if err != nil {
return ""
}
// collect matching names, then pick by priority (then lexically for determinism).
var matches []string
for _, e := range entries {
name := e.Name()
if tgt, err := filepath.EvalSymlinks(filepath.Join(dir, name)); err == nil && tgt == device {
matches = append(matches, name)
}
}
if len(matches) == 0 {
return ""
}
sort.Strings(matches)
for _, pfx := range byIDPriority {
for _, name := range matches {
if strings.HasPrefix(name, pfx) {
return name
}
}
}
return matches[0] // some by-id link exists but not a recognized prefix — still durable enough
}
// byUUIDFor returns the filesystem UUID whose by-uuid link targets `device`, or "".
func byUUIDFor(device string) string {
dir := filepath.Join(devDiskRoot, "by-uuid")
entries, err := os.ReadDir(dir)
if err != nil {
return ""
}
for _, e := range entries {
if tgt, err := filepath.EvalSymlinks(filepath.Join(dir, e.Name())); err == nil && tgt == device {
return e.Name()
}
}
return ""
}
// safeLinkName rejects a durable-id component that could escape the by-id/by-uuid dir (path
// traversal / separators) — defense even though the value is operator-signed.
func safeLinkName(s string) bool {
if s == "" || strings.ContainsAny(s, "/\x00") || s == "." || s == ".." {
return false
}
return true
}