52098302ab
Impl-1. Format now runs a mandatory unclaimed-disk guard (internal/storage/claim.go: SystemDisks + lsblk member-FSTYPE + foreign-mount + RO + pvs/zpool; fail-safe → CLAIMED) before any mkfs — refuses the OS disk / LVM PV / ZFS-mdraid member / foreign-mounted device even when non-data-bearing (guard sits in Format, not the handler). Below the agent, mkfs goes ONLY through configs/felhom-mkfs-guarded.sh (sudoers no longer allowlists raw mkfs.*), which re-checks the catastrophic cases as root. Read-only pvs/zpool added to FELHOM_DISK. Tests + red-proof; capability manifest updated. go build/vet/test clean. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
270 lines
9.3 KiB
Go
270 lines
9.3 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
|
|
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 + ")"
|
|
}
|
|
if n.mountpoint != "" && !underFelhomDrives(n.mountpoint) {
|
|
return false, "device is mounted at " + n.mountpoint + " (" + n.name + ")"
|
|
}
|
|
}
|
|
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
|
|
|
|
// 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
|
|
}
|