Files
admin 91f6a26490 v0.55.0: raw-device discovery + registry-sourced drive tracking (Impl-2a)
GET /disks/candidates enumerates host disks the Impl-1 unclaimed filter proves
free (init/attach split). RegistryKnownTargets sources the watchdog's known-drive
set from the intent registry + Felhom .mount units (not Observe/PVE storages) —
decouples drive health from PVE storage (closes the registry-only false-detach
class); Observe kept for real PVE storages + a deduped /disks union. Idempotent
existing-drive migration at start. Tests + red-proof (Observe misses a
registry-only drive; registry provider tracks it). go build/vet/test clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-01 17:33:49 +02:00

112 lines
4.2 KiB
Go

package storage
import (
"context"
"os"
"path/filepath"
"strconv"
"strings"
)
// CandidateDisk is one host block device that the Impl-1 unclaimed filter proved is FREE for Felhom to
// enroll (Impl-2a discovery). The controller wizard (Impl-2b) renders these; enrollment then formats
// (guarded, Impl-1) and/or mounts + binds. Blank disks are "initialize"-only; disks already carrying a
// mountable FS are ALSO "attach" candidates.
type CandidateDisk struct {
Device string `json:"device"` // whole disk, e.g. /dev/sdd
SizeBytes int64 `json:"size_bytes"` // 0 when unreadable
Model string `json:"model,omitempty"`
FSType string `json:"fstype,omitempty"` // first filesystem found ("" = blank)
DataBearing bool `json:"data_bearing"` // has any FS / partition (→ wipe-confirm downstream)
Mountable bool `json:"mountable"` // carries an ext4/xfs FS the agent can mount as-is
MountSource string `json:"mount_source,omitempty"` // the node to mount for attach (e.g. /dev/sdd1)
DurableID string `json:"durable_id,omitempty"` // "uuid:<fs-uuid>" if it has an FS; "" for a blank disk
}
// mountableFSTypes are the filesystems the agent can mount as-is (EnsureMount) → an "attach" candidate.
// Others (ntfs, exfat, …) are data-bearing but not attach-mountable → "initialize" only (with wipe).
var mountableFSTypes = map[string]bool{"ext4": true, "xfs": true}
// ListCandidateDisks enumerates the host's whole disks (from /sys/block — non-privileged) and returns
// the subset the Impl-1 unclaimed filter proves is free for Felhom. Fail-safe carries through: a device
// the filter cannot prove unclaimed (incl. any read error) is simply omitted (never offered).
func (h *SudoHostOps) ListCandidateDisks(ctx context.Context) ([]CandidateDisk, error) {
entries, err := os.ReadDir("/sys/block")
if err != nil {
return nil, err
}
var out []CandidateDisk
for _, e := range entries {
name := e.Name()
dev := "/dev/" + name
// Only real whole disks (sd*/nvme*n*/vd*/hd*). wholeDiskOf rejects loop/ram/dm-/zram/md/sr etc.
if wd, ok := wholeDiskOf(dev); !ok || wd != dev {
continue
}
unclaimed, _ := classifyClaim(h.gatherClaimFacts(ctx, dev))
if !unclaimed {
continue
}
out = append(out, h.buildCandidate(dev, name))
}
return out, nil
}
// buildCandidate assembles the probe info for an already-unclaimed disk. It re-reads the lsblk tree for
// per-node FS (via the same allowlisted command the claim gather uses) and /sys for size/model.
func (h *SudoHostOps) buildCandidate(dev, name string) CandidateDisk {
c := CandidateDisk{Device: dev}
c.SizeBytes = readSysBlockSize(name)
c.Model = readSysBlockModel(name)
// Per-node FS from lsblk (reuses the FELHOM_FORMAT-allowlisted command).
lout, _, lerr := h.runner.Run(context.Background(), h.bins.Lsblk, "-J", "-o", "NAME,FSTYPE,PTTYPE,MOUNTPOINT", dev)
if lerr == nil {
if nodes, perr := parseLsblkNodes(lout); perr == nil {
for _, n := range nodes {
if n.fstype == "" {
continue
}
c.DataBearing = true
if c.FSType == "" {
c.FSType = n.fstype // first filesystem found (partition or whole-disk)
}
if mountableFSTypes[n.fstype] && c.MountSource == "" {
c.Mountable = true
c.MountSource = "/dev/" + n.name
if uuid, ok := h.host.ResolveUUID("/dev/" + n.name); ok {
c.DurableID = "uuid:" + uuid
}
}
}
// A partition table with no FS is still data-bearing (must be wiped before init).
if len(nodes) > 1 {
c.DataBearing = true
}
}
}
return c
}
// readSysBlockSize returns the device size in bytes from /sys/block/<name>/size (512-byte sectors).
func readSysBlockSize(name string) int64 {
b, err := os.ReadFile(filepath.Join("/sys/block", name, "size"))
if err != nil {
return 0
}
sectors, err := strconv.ParseInt(strings.TrimSpace(string(b)), 10, 64)
if err != nil {
return 0
}
return sectors * 512
}
// readSysBlockModel returns the device model from /sys/block/<name>/device/model ("" if unreadable).
func readSysBlockModel(name string) string {
b, err := os.ReadFile(filepath.Join("/sys/block", name, "device", "model"))
if err != nil {
return ""
}
return strings.TrimSpace(string(b))
}