91f6a26490
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>
112 lines
4.2 KiB
Go
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))
|
|
}
|