703db166e7
gates / gates (push) Successful in 8s
After a rebuild the customer's own drives could not be re-attached: candidates returned initialize:[] attach:[] while both drives sat there, and the deploy refused with 'choose an attached drive from the list' — a list that was empty. Measured live three times. Mechanism: enrolment mounts a drive TWICE, at /mnt/felhom-drives/<name> and at the raw /mnt/<name> it creates on the host. The host survives a guest rebuild; the controller's registry does not. So classifyClaim saw a mount outside the managed prefix and concluded 'claimed by something else' — about our own mount. The fix is CORROBORATED, not a widened prefix: a non-managed mountpoint is forgiven only when the SAME device is also mounted under the managed path, a pairing only our enrolment produces. A disk another system uses — /srv/data, /media/x, even /mnt/someone-elses-disk — has no counterpart and is STILL refused, with its own test and a red-proof showing an over-wide fix offering it for formatting. Read from /proc/mounts deliberately: the lsblk invocation is pinned verbatim in configs/felhom-agent.sudoers, so using the plural MOUNTPOINTS would have coupled this to a sudoers rollout. /proc/mounts is world-readable — no sudo, no new allowlisted command, no config change. Fail-safe: an unreadable mount table corroborates NOTHING, so the device classifies exactly as before. 'Could not corroborate' must never read as 'ours'. 29 packages ok, vet clean, agent gates OK.
375 lines
14 KiB
Go
375 lines
14 KiB
Go
package storage
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"os"
|
|
"path"
|
|
"path/filepath"
|
|
"strings"
|
|
)
|
|
|
|
// The "unclaimed-disk" filter (Impl-1, SPIKE-drive-enrollment-2026-07-01 §SQ2). It answers a single
|
|
// safety-critical question: is a block device provably FREE for Felhom to format, i.e. NOT claimed by
|
|
// the OS or by anything but Felhom's own drives? It is the mandatory guard `Format` runs before mkfs
|
|
// (the sudoers permits `mkfs /dev/*`, so the agent's code — this filter — is the real destructive-op
|
|
// guard; the pool-scoped token ACL does not touch a sudo mkfs).
|
|
//
|
|
// FAIL-SAFE is the rule: the filter returns unclaimed ONLY when it can positively read every required
|
|
// signal AND none indicates a claim. Any read error, undeterminable topology, or any claim signal →
|
|
// CLAIMED → refuse. It never offers a disk it cannot prove is free.
|
|
|
|
// felhomDrivesPrefix — a mount under here is one of Felhom's OWN managed drives, which is NOT a foreign
|
|
// claim: re-initialising our own drive stays allowed (the DataBearing wipe-confirm still gates the data
|
|
// loss). A mount anywhere else is a foreign claim → refuse.
|
|
const felhomDrivesPrefix = "/mnt/felhom-drives"
|
|
|
|
// memberFSTypes are lsblk/blkid FSTYPE values meaning the device is a MEMBER of a higher-level
|
|
// construct (LVM/ZFS/mdraid/LUKS) or active swap — always a claim, never a plain formattable data disk.
|
|
var memberFSTypes = map[string]bool{
|
|
"LVM2_member": true,
|
|
"zfs_member": true,
|
|
"linux_raid_member": true,
|
|
"crypto_LUKS": true,
|
|
"swap": true,
|
|
}
|
|
|
|
// claimNode is one block node — the whole disk or a partition/child — with the signals we classify on.
|
|
type claimNode struct {
|
|
name string
|
|
fstype string
|
|
mountpoint string
|
|
}
|
|
|
|
// claimFacts is the gathered evidence for one candidate device. classifyClaim is a PURE function of it
|
|
// (so the decision logic is fully unit-testable from fixtures, with no host).
|
|
type claimFacts struct {
|
|
device string
|
|
wholeDisk string
|
|
wholeDiskOK bool
|
|
isSystem bool
|
|
readonly bool
|
|
nodes []claimNode // the whole disk + its children (partitions)
|
|
lvmPV bool // pvs (authoritative): the disk / a partition is an LVM physical volume
|
|
zfsMember bool // zpool (authoritative): the disk / a partition is a ZFS pool member
|
|
// felhomOwnedMounts (R-220) — mountpoints OUTSIDE /mnt/felhom-drives that are nevertheless Felhom's
|
|
// OWN, corroborated from the host mount table: the same device is also mounted at the managed path.
|
|
// Empty means "nothing corroborated", which is the fail-safe direction.
|
|
felhomOwnedMounts map[string]bool
|
|
gatherErr string // non-empty ⇒ a REQUIRED read failed ⇒ fail-safe CLAIMED
|
|
}
|
|
|
|
// classifyClaim is the pure guard verdict. unclaimed=true ONLY when the device is provably free for
|
|
// Felhom to format; every claim signal / error / ambiguity ⇒ unclaimed=false with a human reason.
|
|
func classifyClaim(f claimFacts) (unclaimed bool, reason string) {
|
|
if f.gatherErr != "" {
|
|
return false, "could not determine device claims: " + f.gatherErr
|
|
}
|
|
if !f.wholeDiskOK {
|
|
return false, "undeterminable device topology (not a recognizable raw disk)"
|
|
}
|
|
if f.isSystem {
|
|
return false, "system/OS disk"
|
|
}
|
|
if f.readonly {
|
|
return false, "read-only device"
|
|
}
|
|
if f.lvmPV {
|
|
return false, "device holds an LVM physical volume"
|
|
}
|
|
if f.zfsMember {
|
|
return false, "device is a ZFS pool member"
|
|
}
|
|
for _, n := range f.nodes {
|
|
if memberFSTypes[n.fstype] {
|
|
return false, "device holds a " + n.fstype + " (" + n.name + ")"
|
|
}
|
|
// ── R-220 — A MOUNT FELHOM ITSELF MADE IS NOT "SOMETHING ELSE". ───────────────────────
|
|
//
|
|
// Enrolment mounts a drive TWICE: at the managed path `/mnt/felhom-drives/<name>` and at the
|
|
// raw `/mnt/<name>` it creates on the host. The host — and therefore that raw mount — survives
|
|
// a guest rebuild, while the controller's registry does not. So after a rebuild the customer's
|
|
// own drives looked foreign, `attach` returned an empty list, and the refusal told them to
|
|
// choose from it. Measured live three times (CAMPAIGN-11 Phase 1, and the R-201 re-walk twice);
|
|
// unmounting only the raw mounts flipped `attach: []` to both drives every time.
|
|
//
|
|
// The fence this must NOT breach: a disk genuinely in use by something else stays refused. So
|
|
// the exemption is not "any /mnt/* path" — it is CORROBORATED: the same device must ALSO be
|
|
// mounted at Felhom's managed path, which is a state only Felhom's own enrolment produces.
|
|
// A foreign disk at /srv/data or /media/x has no such counterpart and is still refused.
|
|
if n.mountpoint != "" && !underFelhomDrives(n.mountpoint) && !f.felhomOwnedMounts[n.mountpoint] {
|
|
return false, "device is mounted at " + n.mountpoint + " (" + n.name + ")"
|
|
}
|
|
}
|
|
// Fail-safe backstop (audit D2): a successful-but-EMPTY lsblk (or a tree that does not even contain
|
|
// the target whole-disk) means the member/mount loop above inspected nothing — that is undeterminable
|
|
// topology, not proof of freedom. Without this, "unclaimed" rested on the untested assumption that
|
|
// lsblk always ERRORS (non-zero exit) on a bad device rather than emitting empty success.
|
|
if len(f.nodes) == 0 {
|
|
return false, "empty block topology (undeterminable) — refusing"
|
|
}
|
|
base := path.Base(f.wholeDisk)
|
|
found := false
|
|
for _, n := range f.nodes {
|
|
if n.name == base {
|
|
found = true
|
|
break
|
|
}
|
|
}
|
|
if !found {
|
|
return false, "target disk " + base + " absent from block topology (undeterminable) — refusing"
|
|
}
|
|
return true, "unclaimed"
|
|
}
|
|
|
|
// underFelhomDrives reports whether a mountpoint is one of Felhom's own managed drive mounts.
|
|
func underFelhomDrives(mp string) bool {
|
|
mp = path.Clean(mp) // mountpoints are always unix paths — path.Clean, not filepath.Clean (Windows tests)
|
|
return mp == felhomDrivesPrefix || strings.HasPrefix(mp, felhomDrivesPrefix+"/")
|
|
}
|
|
|
|
// deviceUnclaimed gathers the claim evidence for device and returns the pure verdict.
|
|
func (h *SudoHostOps) deviceUnclaimed(ctx context.Context, device string) (bool, string) {
|
|
return classifyClaim(h.gatherClaimFacts(ctx, device))
|
|
}
|
|
|
|
// gatherClaimFacts reads every claim signal via host-visible reads (NOT the PVE token): the OS-disk set
|
|
// (mount table), the whole-disk lsblk tree (member FSTYPE + mountpoints — the authoritative backbone),
|
|
// the /sys read-only flag, and — when their tools are installed — the authoritative LVM (pvs) and ZFS
|
|
// (zpool) sources. A tool that is genuinely ABSENT contributes "no claim of that kind" (lsblk still
|
|
// detects the member FSTYPE); a tool that is present but ERRORS is a fail-safe CLAIMED.
|
|
func (h *SudoHostOps) gatherClaimFacts(ctx context.Context, device string) claimFacts {
|
|
f := claimFacts{device: device}
|
|
wd, ok := wholeDiskOf(device)
|
|
f.wholeDisk, f.wholeDiskOK = wd, ok
|
|
|
|
// OS/system disk — fail-safe inside (unknown topology / unknown system set ⇒ system).
|
|
sys, sysKnown := SystemDisks(h.host)
|
|
f.isSystem = isSystemBacked(device, sys, sysKnown)
|
|
if !ok {
|
|
return f // classifyClaim refuses on !wholeDiskOK
|
|
}
|
|
|
|
// Read-only flag (/sys/block/<disk>/ro is world-readable).
|
|
if ro, rerr := readWholeDiskRO(wd); rerr != nil {
|
|
f.gatherErr = "read-only flag unreadable"
|
|
return f
|
|
} else {
|
|
f.readonly = ro
|
|
}
|
|
|
|
// lsblk tree on the WHOLE disk — REQUIRED (error ⇒ claimed). Reuses the allowlisted command.
|
|
lout, _, lerr := h.runner.Run(ctx, h.bins.Lsblk, "-J", "-o", "NAME,FSTYPE,PTTYPE,MOUNTPOINT", wd)
|
|
if lerr != nil {
|
|
f.gatherErr = "lsblk failed"
|
|
return f
|
|
}
|
|
nodes, perr := parseLsblkNodes(lout)
|
|
if perr != nil {
|
|
f.gatherErr = "lsblk parse failed"
|
|
return f
|
|
}
|
|
f.nodes = nodes
|
|
// R-220: corroborate which non-managed mountpoints are nevertheless Felhom's own.
|
|
f.felhomOwnedMounts = felhomOwnedMounts(device, nodes, h.mountTable)
|
|
|
|
// LVM PV (authoritative). pvs installed but erroring ⇒ fail-safe claimed; absent ⇒ rely on lsblk's
|
|
// LVM2_member FSTYPE (already in nodes).
|
|
if h.binaryPresent(h.bins.Pvs) {
|
|
pvset, err := h.lvmPVSet(ctx)
|
|
if err != nil {
|
|
f.gatherErr = "pvs failed"
|
|
return f
|
|
}
|
|
for _, n := range nodes {
|
|
if pvset["/dev/"+n.name] {
|
|
f.lvmPV = true
|
|
}
|
|
}
|
|
if pvset[wd] {
|
|
f.lvmPV = true
|
|
}
|
|
}
|
|
|
|
// ZFS members (authoritative). Absent ⇒ no ZFS on this host; present but erroring ⇒ claimed.
|
|
if h.binaryPresent(h.bins.Zpool) {
|
|
member, err := h.zfsMembers(ctx, nodes, wd)
|
|
if err != nil {
|
|
f.gatherErr = "zpool failed"
|
|
return f
|
|
}
|
|
f.zfsMember = member
|
|
}
|
|
return f
|
|
}
|
|
|
|
// binaryPresent reports whether an absolute binary path exists (distinguishes "tool not installed"
|
|
// from "tool present but errored" — only the latter is a fail-safe CLAIMED).
|
|
func (h *SudoHostOps) binaryPresent(path string) bool {
|
|
if path == "" {
|
|
return false
|
|
}
|
|
_, err := os.Stat(path)
|
|
return err == nil
|
|
}
|
|
|
|
// readWholeDiskRO reads /sys/block/<disk>/ro ("1" ⇒ read-only). Whole-disk path e.g. /dev/sdd. A
|
|
// package var so tests can stub the /sys read (the only host read in the gather that isn't a stubbable
|
|
// runner/HostReader call).
|
|
var readWholeDiskRO = func(wholeDisk string) (bool, error) {
|
|
name := filepath.Base(wholeDisk)
|
|
b, err := os.ReadFile(filepath.Join("/sys/block", name, "ro"))
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
return strings.TrimSpace(string(b)) == "1", nil
|
|
}
|
|
|
|
// --- lsblk tree parsing (per-node FSTYPE + mountpoint for the disk and every child) ---
|
|
|
|
type lsblkDev struct {
|
|
Name string `json:"name"`
|
|
FSType string `json:"fstype"`
|
|
MountPoint string `json:"mountpoint"`
|
|
Children []lsblkDev `json:"children"`
|
|
}
|
|
|
|
// parseLsblkNodes flattens `lsblk -J` output into every node (the disk + all descendants).
|
|
func parseLsblkNodes(out []byte) ([]claimNode, error) {
|
|
var doc struct {
|
|
BlockDevices []lsblkDev `json:"blockdevices"`
|
|
}
|
|
if err := json.Unmarshal(out, &doc); err != nil {
|
|
return nil, err
|
|
}
|
|
var nodes []claimNode
|
|
var walk func(d lsblkDev)
|
|
walk = func(d lsblkDev) {
|
|
nodes = append(nodes, claimNode{name: d.Name, fstype: d.FSType, mountpoint: d.MountPoint})
|
|
for _, c := range d.Children {
|
|
walk(c)
|
|
}
|
|
}
|
|
for _, d := range doc.BlockDevices {
|
|
walk(d)
|
|
}
|
|
return nodes, nil
|
|
}
|
|
|
|
// lvmPVSet returns the set of PV device paths (canonicalized to whole-disk where possible is done by
|
|
// the caller; here we return the raw pv_name paths as pvs reports them, e.g. /dev/sda3 or /dev/sdb).
|
|
func (h *SudoHostOps) lvmPVSet(ctx context.Context) (map[string]bool, error) {
|
|
out, stderr, err := h.runner.Run(ctx, h.bins.Pvs, "--reportformat", "json", "--noheadings", "-o", "pv_name")
|
|
if err != nil {
|
|
return nil, fmt.Errorf("pvs: %w: %s", err, trim(stderr))
|
|
}
|
|
var doc struct {
|
|
Report []struct {
|
|
PV []struct {
|
|
PVName string `json:"pv_name"`
|
|
} `json:"pv"`
|
|
} `json:"report"`
|
|
}
|
|
if jerr := json.Unmarshal(out, &doc); jerr != nil {
|
|
return nil, jerr
|
|
}
|
|
set := map[string]bool{}
|
|
for _, r := range doc.Report {
|
|
for _, pv := range r.PV {
|
|
if n := strings.TrimSpace(pv.PVName); n != "" {
|
|
set[n] = true
|
|
}
|
|
}
|
|
}
|
|
return set, nil
|
|
}
|
|
|
|
// zfsMembers reports whether any of the device's nodes (or the whole disk) is a ZFS pool member, by
|
|
// scanning `zpool status -P` (which prints full /dev paths). Conservative substring match on the node
|
|
// device paths — a false positive only ever REFUSES (safe direction).
|
|
func (h *SudoHostOps) zfsMembers(ctx context.Context, nodes []claimNode, wholeDisk string) (bool, error) {
|
|
out, stderr, err := h.runner.Run(ctx, h.bins.Zpool, "status", "-P")
|
|
if err != nil {
|
|
return false, fmt.Errorf("zpool: %w: %s", err, trim(stderr))
|
|
}
|
|
text := string(out)
|
|
if strings.Contains(text, wholeDisk) {
|
|
return true, nil
|
|
}
|
|
for _, n := range nodes {
|
|
if n.name != "" && strings.Contains(text, "/dev/"+n.name) {
|
|
return true, nil
|
|
}
|
|
}
|
|
return false, nil
|
|
}
|
|
|
|
// mountTableSource yields the host mount table as (device, mountpoint) pairs. A seam so the R-220
|
|
// corroboration is unit-testable without a host. nil ⇒ the real /proc/mounts.
|
|
type mountTableSource func() ([][2]string, error)
|
|
|
|
// procMounts reads /proc/mounts — WORLD-READABLE, so this needs no sudo and no allowlisted command.
|
|
// That matters: the lsblk invocation is pinned verbatim in the sudoers file
|
|
// (`lsblk -J -o NAME,FSTYPE,PTTYPE,MOUNTPOINT /dev/*`), so switching it to the plural MOUNTPOINTS
|
|
// would have meant shipping a sudoers change with the binary — a far larger blast radius than this
|
|
// finding warrants. Reading the mount table directly sidesteps that entirely.
|
|
func procMounts() ([][2]string, error) {
|
|
data, err := os.ReadFile("/proc/mounts")
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
var out [][2]string
|
|
for _, line := range strings.Split(string(data), "\n") {
|
|
fields := strings.Fields(line)
|
|
if len(fields) < 2 {
|
|
continue
|
|
}
|
|
// /proc/mounts escapes spaces as \040; unescape so a path with a space still compares.
|
|
out = append(out, [2]string{fields[0], strings.ReplaceAll(fields[1], `\040`, " ")})
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
// felhomOwnedMounts returns the mountpoints of `device` (and its children) that sit OUTSIDE
|
|
// /mnt/felhom-drives but are still Felhom's own, corroborated by the same device also being mounted
|
|
// UNDER /mnt/felhom-drives. That pairing is what enrolment produces and nothing else does.
|
|
//
|
|
// ⚠ FAIL-SAFE: an unreadable mount table returns an EMPTY set, never a permissive one. The device then
|
|
// classifies exactly as it did before R-220 — refused — because "we could not corroborate" must never
|
|
// read as "it is ours".
|
|
func felhomOwnedMounts(device string, nodes []claimNode, src mountTableSource) map[string]bool {
|
|
if src == nil {
|
|
src = procMounts
|
|
}
|
|
table, err := src()
|
|
if err != nil {
|
|
return nil // unreadable ⇒ corroborate nothing
|
|
}
|
|
// Every device name this disk answers to: the whole disk and each child node.
|
|
devs := map[string]bool{device: true}
|
|
if wd, ok := wholeDiskOf(device); ok {
|
|
devs[wd] = true
|
|
}
|
|
for _, n := range nodes {
|
|
devs["/dev/"+n.name] = true
|
|
}
|
|
// A device is Felhom-managed only if it is mounted under the managed prefix.
|
|
managed := map[string]bool{}
|
|
for _, row := range table {
|
|
if devs[row[0]] && underFelhomDrives(row[1]) {
|
|
managed[row[0]] = true
|
|
}
|
|
}
|
|
if len(managed) == 0 {
|
|
return nil
|
|
}
|
|
owned := map[string]bool{}
|
|
for _, row := range table {
|
|
if managed[row[0]] && !underFelhomDrives(row[1]) {
|
|
owned[path.Clean(row[1])] = true
|
|
}
|
|
}
|
|
return owned
|
|
}
|