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:" 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//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//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)) }