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>
This commit is contained in:
@@ -0,0 +1,111 @@
|
||||
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))
|
||||
}
|
||||
Reference in New Issue
Block a user