Physical-disk identity of every storage place + POST /disks/same-disk (felhom.eu architecture/16 §3/§4)
gates / gates (push) Successful in 31s

The agent names the physical disk(s) under each storage place from /sys, udev and /proc/mounts only
(partition -> disk, dm/LVM thin -> slaves, loop -> backing file), keys them (eui / serial:<bus>:<model>
:<serial> / vendor WWN) and says proven or unknown (vm, network, usb_bridge, weak_wwn, no_serial).
GET /disks and the hub report's storage_targets gain kind, physical_disks, identity,
identity_reason (golden updated; the hub copy changes in the same session). POST /disks/same-disk
maps two guest paths to places the agent itself made and answers {different, proven, reason};
an absent drive is unmapped, never the root disk. Tests on fixture trees for every spike case,
red-proofed. CHANGELOG entry follows the release (release-complete gate).

Claude-Session: https://claude.ai/code/session_012qRErfCoiTkvDK9N5XHbzb
This commit is contained in:
2026-10-15 21:09:18 +02:00
parent d2053e7d47
commit c5daa9480d
13 changed files with 1219 additions and 1 deletions
+445
View File
@@ -0,0 +1,445 @@
package storage
import (
"bufio"
"context"
"os"
"os/exec"
"path/filepath"
"sort"
"strings"
"sync"
"time"
)
// Physical-disk identity of a storage place — felhom.eu architecture/16-storage-physical-identity.md §3.
//
// A storage place (a PVE storage, an enrolled drive, the guest's data volume, a network share) is
// reduced to the PHYSICAL whole disks under it — partition → parent disk, device-mapper / LVM thin →
// sysfs slaves (recursive), loop → its backing file → the filesystem holding that file — and each disk
// is named by a key the agent trusts: `eui:` for NVMe, `serial:<bus>:<model>:<serial>` from udev,
// `wwn:` only when no serial exists and the WWN carries a vendor (OUI ≠ 000000).
//
// The verdict is AGENT-COMPUTED evidence (`16` §10): it is read from /sys, /run/udev/data and
// /proc/mounts only, never from a caller's claim, so a compromised controller cannot make two places
// "different disks". `proven` means every disk under the place has a key the agent trusts; anything
// else is `unknown` with ONE reason, chosen by precedence (vm > network > usb_bridge > weak_wwn >
// no_serial). Nothing is cached: every call reads the current topology, so a disk swapped in the same
// bay is re-judged on the next read (the "right before each Tier-2 run" rule is the caller calling).
//
// Pinned by identity_test.go (fixture /sys + udev trees, one per §3 case) — the spike that proved the
// walk on real devices: felhom.eu documentation/audits/storage-identity-2026-10-15/.
const (
IdentityProven = "proven"
IdentityUnknown = "unknown"
ReasonVM = "vm"
ReasonNetwork = "network"
ReasonUSBBridge = "usb_bridge"
ReasonWeakWWN = "weak_wwn"
ReasonNoSerial = "no_serial"
PlaceKindSystem = "system"
PlaceKindInternal = "internal"
PlaceKindDisk = "disk"
PlaceKindPartition = "partition"
PlaceKindNetwork = "network"
)
// reasonRank orders the unknown reasons: the first one a place hits, by this precedence, is reported.
var reasonRank = map[string]int{ReasonVM: 0, ReasonNetwork: 1, ReasonUSBBridge: 2, ReasonWeakWWN: 3, ReasonNoSerial: 4}
// PlaceIdentity is what a storage place reports.
type PlaceIdentity struct {
PhysicalDisks []string // disk keys, sorted, deduplicated
Identity string // proven | unknown | "" (not computed — e.g. the device is absent)
Reason string // set only when Identity == unknown
}
// Computed reports whether a verdict was reached at all.
func (p PlaceIdentity) Computed() bool { return p.Identity != "" }
// IdentityHost is the read surface the resolver needs. SysfsIdentityHost is production; tests use the
// same struct pointed at fixture trees.
type IdentityHost interface {
Mounts() ([]Mount, error)
// KernelName maps a /dev path (symlinks allowed: /dev/mapper/x, /dev/pve/y) to its kernel block name.
KernelName(dev string) (string, bool)
// Parent returns the parent disk's kernel name when name is a partition.
Parent(name string) (string, bool)
// Slaves lists /sys/class/block/<name>/slaves.
Slaves(name string) []string
// LoopBackingFile returns a loop device's backing file.
LoopBackingFile(name string) (string, bool)
// UdevProps returns the udev E: properties of a block device.
UdevProps(name string) map[string]string
// Virt returns the VM technology ("none" on bare metal).
Virt() string
}
// SysfsIdentityHost reads the real host. Every path is a field so tests can point it at fixtures.
type SysfsIdentityHost struct {
SysClass string // default /sys/class/block
UdevData string // default /run/udev/data
ProcMounts string // default /proc/mounts
DevResolve func(string) (string, error) // default filepath.EvalSymlinks
VirtFn func() string // default `systemd-detect-virt --vm`, read once
virtOnce sync.Once
virt string
}
// NewSysfsIdentityHost returns the production reader.
func NewSysfsIdentityHost() *SysfsIdentityHost { return &SysfsIdentityHost{} }
func (h *SysfsIdentityHost) sysClass() string {
if h.SysClass != "" {
return h.SysClass
}
return "/sys/class/block"
}
func (h *SysfsIdentityHost) Mounts() ([]Mount, error) {
return (&ProcHostReader{ProcMounts: h.ProcMounts}).Mounts()
}
func (h *SysfsIdentityHost) KernelName(dev string) (string, bool) {
if !strings.HasPrefix(dev, "/dev/") {
return "", false
}
resolve := h.DevResolve
if resolve == nil {
resolve = filepath.EvalSymlinks
}
real := dev
if r, err := resolve(dev); err == nil {
real = r
}
name := filepath.Base(real)
if _, err := os.Stat(filepath.Join(h.sysClass(), name)); err != nil {
return "", false
}
return name, true
}
func (h *SysfsIdentityHost) Parent(name string) (string, bool) {
p := filepath.Join(h.sysClass(), name)
if _, err := os.Stat(filepath.Join(p, "partition")); err != nil {
return "", false
}
real, err := filepath.EvalSymlinks(p)
if err != nil {
return "", false
}
return filepath.Base(filepath.Dir(real)), true
}
func (h *SysfsIdentityHost) Slaves(name string) []string {
entries, err := os.ReadDir(filepath.Join(h.sysClass(), name, "slaves"))
if err != nil {
return nil
}
out := make([]string, 0, len(entries))
for _, e := range entries {
out = append(out, e.Name())
}
sort.Strings(out)
return out
}
func (h *SysfsIdentityHost) LoopBackingFile(name string) (string, bool) {
if !strings.HasPrefix(name, "loop") {
return "", false
}
b, err := os.ReadFile(filepath.Join(h.sysClass(), name, "loop", "backing_file"))
if err != nil {
return "", false
}
f := strings.TrimSpace(string(b))
return f, f != ""
}
func (h *SysfsIdentityHost) UdevProps(name string) map[string]string {
out := map[string]string{}
devB, err := os.ReadFile(filepath.Join(h.sysClass(), name, "dev"))
if err != nil {
return out
}
dir := h.UdevData
if dir == "" {
dir = "/run/udev/data"
}
f, err := os.Open(filepath.Join(dir, "b"+strings.TrimSpace(string(devB))))
if err != nil {
return out
}
defer f.Close()
sc := bufio.NewScanner(f)
for sc.Scan() {
line := sc.Text()
if !strings.HasPrefix(line, "E:") {
continue
}
k, v, ok := strings.Cut(line[2:], "=")
if ok {
out[k] = v
}
}
return out
}
func (h *SysfsIdentityHost) Virt() string {
h.virtOnce.Do(func() {
if h.VirtFn != nil {
h.virt = h.VirtFn()
return
}
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
// --vm: a container is not a reason (the agent runs on the host); exit 1 + "none" on metal.
out, _ := exec.CommandContext(ctx, "systemd-detect-virt", "--vm").Output()
h.virt = strings.TrimSpace(string(out))
if h.virt == "" {
h.virt = "unknown"
}
})
return h.virt
}
// IdentityResolver turns devices and host paths into PlaceIdentity.
type IdentityResolver struct {
Host IdentityHost
}
// NewIdentityResolver returns a resolver over the real host.
func NewIdentityResolver() *IdentityResolver {
return &IdentityResolver{Host: NewSysfsIdentityHost()}
}
var networkFSTypes = map[string]bool{
"nfs": true, "nfs4": true, "cifs": true, "smb3": true, "fuse.sshfs": true, "9p": true, "autofs": true, "ceph": true, "glusterfs": true,
}
// NetworkPlace is the fixed verdict for a network share or a remote store.
func NetworkPlace() PlaceIdentity {
return PlaceIdentity{PhysicalDisks: []string{}, Identity: IdentityUnknown, Reason: ReasonNetwork}
}
// MountFor returns the mount that holds path (longest mountpoint prefix; the LAST entry wins on an equal
// mountpoint, which is the one stacked on top — an automount's real filesystem over its autofs trigger).
func MountFor(mounts []Mount, path string) (Mount, bool) {
p := filepath.Clean(path)
var best Mount
found := false
for _, m := range mounts {
mp := filepath.Clean(m.MountPoint)
if p != mp && !strings.HasPrefix(p, strings.TrimSuffix(mp, "/")+"/") {
continue
}
if !found || len(mp) >= len(filepath.Clean(best.MountPoint)) {
best, found = m, true
}
}
return best, found
}
// HostPath resolves the filesystem holding a HOST path.
func (r *IdentityResolver) HostPath(path string) PlaceIdentity {
return r.hostPath(path, 0)
}
func (r *IdentityResolver) hostPath(path string, depth int) PlaceIdentity {
if r == nil || r.Host == nil || depth > 4 {
return PlaceIdentity{}
}
mounts, err := r.Host.Mounts()
if err != nil {
return PlaceIdentity{}
}
m, ok := MountFor(mounts, path)
if !ok {
return PlaceIdentity{}
}
if networkFSTypes[m.FSType] || !strings.HasPrefix(m.Device, "/dev/") {
return NetworkPlace()
}
return r.device(m.Device, depth)
}
// Device resolves a block device (a /dev path, symlinks allowed).
func (r *IdentityResolver) Device(dev string) PlaceIdentity {
return r.device(dev, 0)
}
func (r *IdentityResolver) device(dev string, depth int) PlaceIdentity {
if r == nil || r.Host == nil || dev == "" {
return PlaceIdentity{}
}
name, ok := r.Host.KernelName(dev)
if !ok {
return PlaceIdentity{}
}
keys := map[string]bool{}
reason := ""
if !r.walk(name, depth, map[string]bool{}, keys, &reason) {
return PlaceIdentity{}
}
out := PlaceIdentity{PhysicalDisks: sortedKeys(keys), Identity: IdentityProven}
if reason != "" {
out.Identity, out.Reason = IdentityUnknown, reason
}
return out
}
// walk adds the keys of every physical disk under name; ok=false when a branch cannot be grounded.
func (r *IdentityResolver) walk(name string, depth int, seen, keys map[string]bool, reason *string) bool {
if seen[name] {
return true // a diamond (a thin pool's tmeta and tdata on one partition): already counted
}
if len(seen) > 64 {
return false
}
seen[name] = true
if parent, ok := r.Host.Parent(name); ok {
return r.walk(parent, depth, seen, keys, reason)
}
if file, ok := r.Host.LoopBackingFile(name); ok {
p := r.hostPath(file, depth+1)
if !p.Computed() {
return false
}
for _, k := range p.PhysicalDisks {
keys[k] = true
}
noteReason(reason, p.Reason)
return true
}
if slaves := r.Host.Slaves(name); len(slaves) > 0 {
for _, s := range slaves {
if !r.walk(s, depth, seen, keys, reason) {
return false
}
}
return true
}
if strings.HasPrefix(name, "dm-") || strings.HasPrefix(name, "md") || strings.HasPrefix(name, "loop") {
return false // a virtual device with nothing beneath it cannot be grounded
}
key, why := r.diskKey(name)
keys[key] = true
noteReason(reason, why)
return true
}
func noteReason(cur *string, why string) {
if why == "" {
return
}
if *cur == "" || reasonRank[why] < reasonRank[*cur] {
*cur = why
}
}
// diskKey names one whole disk and says why it is not trusted ("" when it is).
func (r *IdentityResolver) diskKey(name string) (key, reason string) {
p := r.Host.UdevProps(name)
serial := strings.TrimSpace(p["ID_SERIAL_SHORT"])
model := strings.TrimSpace(p["ID_MODEL"])
bus := strings.TrimSpace(p["ID_BUS"])
wwn := strings.ToLower(strings.TrimSpace(p["ID_WWN"]))
if bus == "" && strings.HasPrefix(name, "nvme") {
bus = "nvme"
}
usb := bus == "usb" || strings.Contains(p["DEVPATH"], "/usb")
switch {
case strings.HasPrefix(name, "nvme") && (strings.HasPrefix(wwn, "eui.") || strings.HasPrefix(wwn, "nvme.")):
key = "eui:" + strings.TrimPrefix(strings.TrimPrefix(wwn, "eui."), "nvme.")
case serial != "":
key = "serial:" + bus + ":" + model + ":" + serial
if usb && FakeBridgeSerial(serial) {
reason = ReasonUSBBridge
}
case wwn != "" && WWNHasVendor(wwn):
key = "wwn:" + wwn
case wwn != "":
key, reason = "wwn:"+wwn, ReasonWeakWWN
default:
key, reason = "dev:"+name, ReasonNoSerial
}
if v := r.Host.Virt(); v != "none" {
reason = ReasonVM // inside a VM a virtual disk says nothing about the host disk (spike cases 5–6)
}
return key, reason
}
// WWNHasVendor reports whether an NAA-5/6 WWN carries a registered vendor (OUI ≠ 000000). Other NAA
// formats are taken as they are. Measured: the N100's AirDisk SSD reports 0x5000000123456f1f.
func WWNHasVendor(wwn string) bool {
h := strings.TrimPrefix(strings.ToLower(wwn), "0x")
if len(h) < 7 {
return false
}
if h[0] == '5' || h[0] == '6' {
return h[1:7] != "000000"
}
return true
}
// FakeBridgeSerial reports a serial that a USB bridge reports for any disk: too short, one repeated
// character, or a known placeholder. UNPROVEN on a real enclosure (R-1077) — the rule is the design's.
func FakeBridgeSerial(s string) bool {
s = strings.ToUpper(strings.TrimSpace(s))
if len(s) < 6 {
return true
}
if strings.Count(s, s[:1]) == len(s) {
return true
}
switch s {
case "123456789", "1234567890", "123456789ABC", "0123456789ABCDEF", "1234567890ABCDEF", "DEADBEEF":
return true
}
return false
}
func sortedKeys(m map[string]bool) []string {
out := make([]string, 0, len(m))
for k := range m {
out = append(out, k)
}
sort.Strings(out)
return out
}
// Relation compares two places (`16` §4). A SHARED disk key is a positive fact, so two places on one disk
// are "same, proven" even when that disk is unknown (inside a VM, two parts of one virtual disk). Otherwise
// proven is true only when both verdicts are proven; different then means "no disk in common". When proven
// is false, different only says the known keys do not overlap — callers must act on proven first.
// reason: "" (proven, different), "same_disk", "unmapped" (a side has no verdict), or the unknown reason.
func Relation(a, b PlaceIdentity) (different, proven bool, reason string) {
if !a.Computed() || !b.Computed() {
return false, false, "unmapped"
}
set := map[string]bool{}
for _, k := range a.PhysicalDisks {
set[k] = true
}
shared := false
for _, k := range b.PhysicalDisks {
if set[k] {
shared = true
}
}
if shared {
return false, true, "same_disk"
}
if a.Identity == IdentityProven && b.Identity == IdentityProven {
return true, true, ""
}
why := a.Reason
if why == "" || (b.Reason != "" && reasonRank[b.Reason] < reasonRank[why]) {
why = b.Reason
}
return true, false, why
}
+257
View File
@@ -0,0 +1,257 @@
package storage
import (
"os"
"path/filepath"
"reflect"
"strconv"
"strings"
"testing"
)
// Fixture /sys/class/block + /run/udev/data + /proc/mounts trees, one per `16` §3 case. The tree mimics the
// kernel: /sys/devices/.../<disk>/<part> real dirs (a partition carries a "partition" file), /sys/class/block
// symlinks, a "dev" file per node, udev "E:" lines keyed by major:minor. The cases are the ones the spike
// measured on real devices (felhom.eu documentation/audits/storage-identity-2026-10-15/).
type fx struct {
t *testing.T
root string
minor int
mounts []string
links map[string]string // /dev path -> kernel name
virt string
}
func newFx(t *testing.T) *fx {
t.Helper()
r := t.TempDir()
for _, d := range []string{"sys/class/block", "sys/devices", "udev"} {
if err := os.MkdirAll(filepath.Join(r, d), 0o755); err != nil {
t.Fatal(err)
}
}
return &fx{t: t, root: r, links: map[string]string{}, virt: "none"}
}
func (f *fx) write(p, s string) {
f.t.Helper()
if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil {
f.t.Fatal(err)
}
if err := os.WriteFile(p, []byte(s), 0o644); err != nil {
f.t.Fatal(err)
}
}
// node makes a block node. parent "" → a top-level device. udev lines are "KEY=VAL".
func (f *fx) node(name, parent string, udev ...string) {
f.t.Helper()
real := filepath.Join(f.root, "sys/devices", name)
if parent != "" {
pr, _ := filepath.EvalSymlinks(filepath.Join(f.root, "sys/class/block", parent))
real = filepath.Join(pr, name)
f.write(filepath.Join(real, "partition"), "1\n")
}
f.minor++
dev := "8:" + strconv.Itoa(f.minor)
f.write(filepath.Join(real, "dev"), dev+"\n")
if err := os.Symlink(real, filepath.Join(f.root, "sys/class/block", name)); err != nil {
f.t.Fatal(err)
}
var b strings.Builder
for _, u := range udev {
b.WriteString("E:" + u + "\n")
}
f.write(filepath.Join(f.root, "udev", "b"+dev), b.String())
f.links["/dev/"+name] = name
}
func (f *fx) slaves(name string, slaves ...string) {
f.t.Helper()
real, _ := filepath.EvalSymlinks(filepath.Join(f.root, "sys/class/block", name))
for _, s := range slaves {
if err := os.MkdirAll(filepath.Join(real, "slaves", s), 0o755); err != nil {
f.t.Fatal(err)
}
}
}
func (f *fx) loop(name, file string) {
real, _ := filepath.EvalSymlinks(filepath.Join(f.root, "sys/class/block", name))
f.write(filepath.Join(real, "loop", "backing_file"), file+"\n")
}
func (f *fx) mount(dev, mp, fstype string) {
f.mounts = append(f.mounts, dev+" "+mp+" "+fstype+" rw 0 0")
}
func (f *fx) resolver() *IdentityResolver {
f.write(filepath.Join(f.root, "mounts"), strings.Join(f.mounts, "\n")+"\n")
return &IdentityResolver{Host: &SysfsIdentityHost{
SysClass: filepath.Join(f.root, "sys/class/block"),
UdevData: filepath.Join(f.root, "udev"),
ProcMounts: filepath.Join(f.root, "mounts"),
DevResolve: func(p string) (string, error) {
if n, ok := f.links[p]; ok {
return "/dev/" + n, nil
}
return p, nil
},
VirtFn: func() string { return f.virt },
}}
}
var sanDisk = []string{"ID_BUS=ata", "ID_MODEL=SanDisk_X600", "ID_SERIAL_SHORT=182195804614", "ID_WWN=0x5001b448b60a9d6e"}
// oneDiskLVM builds the PVE default layout on one SATA SSD: sda3 → pve-root, pve-data tmeta/tdata → tpool →
// a guest volume (the shape of all three test boxes).
func oneDiskLVM(f *fx) {
f.node("sda", "", sanDisk...)
f.node("sda3", "sda")
for _, d := range []string{"dm-1", "dm-2", "dm-3", "dm-4", "dm-6"} {
f.node(d, "")
}
f.slaves("dm-1", "sda3")
f.slaves("dm-2", "sda3")
f.slaves("dm-3", "sda3")
f.slaves("dm-4", "dm-2", "dm-3")
f.slaves("dm-6", "dm-4")
f.links["/dev/mapper/pve-root"] = "dm-1"
f.links["/dev/mapper/pve-data-tpool"] = "dm-4"
f.links["/dev/pve/vm-9201-disk-1"] = "dm-6"
f.mount("/dev/mapper/pve-root", "/", "ext4")
}
func TestIdentity_OneDiskLVM_AllPlacesSameProvenDisk(t *testing.T) {
f := newFx(t)
oneDiskLVM(f)
r := f.resolver()
want := []string{"serial:ata:SanDisk_X600:182195804614"}
for _, p := range []PlaceIdentity{r.HostPath("/var/lib/vz"), r.Device("/dev/mapper/pve-data-tpool"), r.Device("/dev/pve/vm-9201-disk-1")} {
if p.Identity != IdentityProven || !reflect.DeepEqual(p.PhysicalDisks, want) {
t.Fatalf("want proven %v, got %+v", want, p)
}
}
diff, proven, why := Relation(r.HostPath("/var/lib/vz"), r.Device("/dev/pve/vm-9201-disk-1"))
if diff || !proven || why != "same_disk" {
t.Fatalf("root vs guest volume: want same+proven, got diff=%v proven=%v why=%q", diff, proven, why)
}
}
func TestIdentity_TwoDisks_ProvenDifferent(t *testing.T) {
f := newFx(t)
oneDiskLVM(f)
f.node("sdb", "", "ID_BUS=ata", "ID_MODEL=TOSHIBA_MQ04ABF100", "ID_SERIAL_SHORT=65NOP3HDT", "ID_WWN=0x5000039ddb108568", "DEVPATH=/devices/pci0000:00/0000:00:14.0/usb1/1-3/block/sdb")
f.mount("/dev/sdb", "/mnt/hdd_1", "ext4")
f.mount("/dev/sdb", "/mnt/felhom-drives/hdd_1", "ext4") // the felhom-data bind shows the source device
r := f.resolver()
drive := r.HostPath("/mnt/felhom-drives/hdd_1/userdata")
if drive.Identity != IdentityProven || drive.PhysicalDisks[0] != "serial:ata:TOSHIBA_MQ04ABF100:65NOP3HDT" {
t.Fatalf("USB drive passing its identity: want proven, got %+v", drive)
}
diff, proven, why := Relation(r.Device("/dev/pve/vm-9201-disk-1"), drive)
if !diff || !proven || why != "" {
t.Fatalf("system vs drive: want different+proven, got %v %v %q", diff, proven, why)
}
}
func TestIdentity_TwoPartitionsOneDisk_Same(t *testing.T) {
f := newFx(t)
f.node("sdc", "", "ID_BUS=usb", "ID_MODEL=Portable", "ID_SERIAL_SHORT=NAAB7C21")
f.node("sdc1", "sdc")
f.node("sdc2", "sdc")
f.mount("/dev/sdc1", "/mnt/a", "ext4")
f.mount("/dev/sdc2", "/mnt/b", "ext4")
r := f.resolver()
diff, proven, why := Relation(r.HostPath("/mnt/a"), r.HostPath("/mnt/b"))
if diff || !proven || why != "same_disk" {
t.Fatalf("two partitions of one disk: want same+proven, got %v %v %q", diff, proven, why)
}
}
func TestIdentity_LoopPartitionsOnAFile_ResolveToTheFilesDisk(t *testing.T) {
f := newFx(t)
f.node("nvme0n1", "", "ID_MODEL=KXG50PNV1T02", "ID_SERIAL_SHORT=58BS11AFT8MQ", "ID_WWN=eui.000000000000001000080d020046c07b")
f.mount("/dev/nvme0n1", "/mnt/hdd_1", "ext4")
f.node("loop2", "")
f.loop("loop2", "/mnt/hdd_1/scratch.img")
f.node("loop2p1", "loop2") // the partition step must run BEFORE the loop step (the spike's probe bug)
f.mount("/dev/loop2p1", "/tmp/scrA", "ext4")
r := f.resolver()
p := r.HostPath("/tmp/scrA")
if p.Identity != IdentityProven || !reflect.DeepEqual(p.PhysicalDisks, []string{"eui:000000000000001000080d020046c07b"}) {
t.Fatalf("loop partition: want the NVMe's eui, got %+v", p)
}
}
func TestIdentity_WWNWithoutVendor(t *testing.T) {
f := newFx(t)
f.node("sda", "", "ID_BUS=ata", "ID_MODEL=AirDisk_512GB_SSD", "ID_SERIAL_SHORT=QDF922W009654S30EX", "ID_WWN=0x5000000123456f1f")
f.node("sdb", "", "ID_BUS=ata", "ID_MODEL=Cheap", "ID_WWN=0x5000000123456f1f")
r := f.resolver()
if p := r.Device("/dev/sda"); p.Identity != IdentityProven || p.PhysicalDisks[0] != "serial:ata:AirDisk_512GB_SSD:QDF922W009654S30EX" {
t.Fatalf("a real serial wins over a vendor-less WWN: got %+v", p)
}
if p := r.Device("/dev/sdb"); p.Identity != IdentityUnknown || p.Reason != ReasonWeakWWN {
t.Fatalf("no serial + vendor-less WWN: want unknown/weak_wwn, got %+v", p)
}
}
func TestIdentity_NoSerial(t *testing.T) {
f := newFx(t)
f.node("vdb", "", "ID_MODEL=")
if p := f.resolver().Device("/dev/vdb"); p.Identity != IdentityUnknown || p.Reason != ReasonNoSerial {
t.Fatalf("want unknown/no_serial, got %+v", p)
}
}
func TestIdentity_InsideAVM_EverythingUnknown(t *testing.T) {
f := newFx(t)
oneDiskLVM(f)
f.virt = "kvm"
f.node("sdb", "", "ID_BUS=scsi", "ID_MODEL=QEMU_HARDDISK", "ID_SERIAL_SHORT=drive-scsi1")
f.mount("/dev/sdb", "/mnt/felhom-drives/adatlemez", "ext4")
r := f.resolver()
a, b := r.Device("/dev/pve/vm-9201-disk-1"), r.HostPath("/mnt/felhom-drives/adatlemez")
for _, p := range []PlaceIdentity{a, b} {
if p.Identity != IdentityUnknown || p.Reason != ReasonVM {
t.Fatalf("inside a VM: want unknown/vm, got %+v", p)
}
}
diff, proven, why := Relation(a, b)
if proven || why != ReasonVM || !diff {
t.Fatalf("two virtual disks: want not proven (vm), got %v %v %q", diff, proven, why)
}
}
func TestIdentity_USBBridgeSerial(t *testing.T) {
f := newFx(t)
f.node("sdd", "", "ID_BUS=usb", "ID_MODEL=External_USB3.0", "ID_SERIAL_SHORT=000000000000")
f.node("sde", "", "ID_BUS=usb", "ID_MODEL=External_USB3.0", "ID_SERIAL_SHORT=20231106A1B2")
r := f.resolver()
if p := r.Device("/dev/sdd"); p.Identity != IdentityUnknown || p.Reason != ReasonUSBBridge {
t.Fatalf("a bridge placeholder serial: want unknown/usb_bridge, got %+v", p)
}
if p := r.Device("/dev/sde"); p.Identity != IdentityProven {
t.Fatalf("a USB disk with a plausible serial: want proven, got %+v", p)
}
}
func TestIdentity_NetworkShareAndUnmapped(t *testing.T) {
f := newFx(t)
oneDiskLVM(f)
f.mount("//192.168.0.123/TesztFilmek", "/mnt/felhom-drives/omv-filmek", "cifs")
r := f.resolver()
n := r.HostPath("/mnt/felhom-drives/omv-filmek/x")
if n.Identity != IdentityUnknown || n.Reason != ReasonNetwork || len(n.PhysicalDisks) != 0 {
t.Fatalf("network share: want unknown/network with no disks, got %+v", n)
}
_, proven, why := Relation(r.HostPath("/"), n)
if proven || why != ReasonNetwork {
t.Fatalf("system vs network: want not proven (network), got %v %q", proven, why)
}
if _, proven, why := Relation(PlaceIdentity{}, n); proven || why != "unmapped" {
t.Fatalf("an uncomputed side: want unmapped, got %v %q", proven, why)
}
}
+52
View File
@@ -40,6 +40,10 @@ type Observer struct {
// R-672 (v0.133.0): a thin pool crossing thinPoolAlarmFraction (data OR metadata) requests an
// out-of-band host report at once, so the hub's storage-fill alarm sees it in seconds instead of at
// the next 15-minute report. Rising edge per pool; re-armed below thinPoolRearmFraction.
// ident (agent v0.157.0, `16` §3) computes each target's physical-disk identity. nil → the fields stay
// empty (the pre-0.157 shape), which every consumer reads as "not computed".
ident *IdentityResolver
highMu sync.Mutex
high map[string]bool
onThinHigh func()
@@ -50,6 +54,51 @@ const (
thinPoolRearmFraction = 0.85
)
// SetIdentity wires the physical-disk identity resolver (main: NewIdentityResolver()).
func (o *Observer) SetIdentity(r *IdentityResolver) { o.ident = r }
// placeIdentity fills a target's identity fields (`16` §3). Network and remote stores are fixed
// "unknown/network"; a dir storage is judged only on its OWN mount (the same rule as backing_device —
// never the containing filesystem, so an absent drive is "not computed", never the root disk); the
// builtin local and an lvmthin pool are the system disk(s) they sit on.
func (o *Observer) placeIdentity(tgt *hub.StorageTarget, s proxmox.Storage) {
if o.ident == nil {
return
}
var p PlaceIdentity
switch tgt.Type {
case hub.StorageTypeNFS, hub.StorageTypeCIFS, hub.StorageTypePBS:
tgt.Kind = PlaceKindNetwork
p = NetworkPlace()
case hub.StorageTypeLocal:
tgt.Kind = PlaceKindSystem
if s.Path != "" {
p = o.ident.HostPath(s.Path)
}
case hub.StorageTypeLVMThin:
tgt.Kind = PlaceKindSystem
if s.VGName != "" && s.ThinPool != "" {
p = o.ident.Device("/dev/mapper/" + dmEscape(s.VGName) + "-" + dmEscape(s.ThinPool) + "-tpool")
}
default:
if tgt.BackingDevice == "" {
return // not its own mount (absent, or a dir on root): not computed
}
tgt.Kind = PlaceKindDisk
if _, known := wholeDiskOf(tgt.BackingDevice); known && !reWholeDisk.MatchString(tgt.BackingDevice) {
tgt.Kind = PlaceKindPartition
}
p = o.ident.Device(tgt.BackingDevice)
}
if !p.Computed() {
return
}
tgt.PhysicalDisks, tgt.Identity, tgt.IdentityReason = p.PhysicalDisks, p.Identity, p.Reason
}
// dmEscape doubles dashes the way device-mapper names an LV (pve + vm-1-disk-0 → pve-vm--1--disk--0).
func dmEscape(s string) string { return strings.ReplaceAll(s, "-", "--") }
// SetThinHighTrigger wires the out-of-band report request (main: the storage trigger channel).
func (o *Observer) SetThinHighTrigger(f func()) { o.onThinHigh = f }
@@ -303,6 +352,9 @@ func (o *Observer) build(s proxmox.Storage, mounts []Mount) observed {
o.noteThinFill(s.Storage+"/data", frac)
}
// Physical-disk identity (`16` §3) — after typ/backing are settled; never from a caller's claim.
o.placeIdentity(&tgt, s)
// SMART-only device hint (v0.95.0): a dir-storage that lives INSIDE a shared filesystem (the
// builtin `local` on the LVM root) has empty backing by design, yet we can still read the PHYSICAL
// disk's SMART by resolving its containing filesystem. Gated to catDir + no own backing + reachable,
+73
View File
@@ -0,0 +1,73 @@
package storage
import (
"context"
"reflect"
"testing"
"gitea.dooplex.hu/admin/felhom-agent/internal/proxmox"
)
// The report rows carry the `16` §3 identity per storage type: local + lvmthin → the system disk; a dir
// on its own partition → that disk, kind "partition"; nfs/pbs → network; an UNMOUNTED dir → not computed
// (never the root disk — the same rule as backing_device).
func TestObserve_FillsPhysicalDiskIdentity(t *testing.T) {
f := newFx(t)
oneDiskLVM(f)
f.node("sdb", "", "ID_BUS=ata", "ID_MODEL=TOSHIBA_MQ04ABF100", "ID_SERIAL_SHORT=65NOP3HDT")
f.node("sdb1", "sdb")
f.mount("/dev/sdb1", "/mnt/usb-backup", "ext4")
res := f.resolver()
api := &fakeStorageAPI{
node: "demo",
cluster: []proxmox.Storage{
{Storage: "local", Type: "dir", Path: "/var/lib/vz"},
{Storage: "local-lvm", Type: "lvmthin", VGName: "pve", ThinPool: "data"},
{Storage: "usb-backup", Type: "dir", Path: "/mnt/usb-backup"},
{Storage: "gone", Type: "dir", Path: "/mnt/gone"},
{Storage: "nfs-arch", Type: "nfs", Server: "10.0.0.5", Export: "/x"},
},
nodeSt: []proxmox.Storage{
{Storage: "local", Type: "dir", Path: "/var/lib/vz", Active: 1},
{Storage: "local-lvm", Type: "lvmthin", Active: 1},
{Storage: "usb-backup", Type: "dir", Path: "/mnt/usb-backup", Active: 1},
{Storage: "gone", Type: "dir", Path: "/mnt/gone", Active: 1},
{Storage: "nfs-arch", Type: "nfs", Active: 1},
},
}
host := &fakeHostReader{
mounts: []Mount{
{Device: "/dev/mapper/pve-root", MountPoint: "/", FSType: "ext4"},
{Device: "/dev/sdb1", MountPoint: "/mnt/usb-backup", FSType: "ext4"},
},
exists: map[string]bool{"/dev/sdb1": true, "/dev/mapper/pve-root": true},
}
ob := NewObserver(api, host, nil, quietLogger())
ob.SetIdentity(res)
got, err := ob.Observe(context.Background())
if err != nil {
t.Fatal(err)
}
m := byName(got)
ssd := []string{"serial:ata:SanDisk_X600:182195804614"}
for _, n := range []string{"local", "local-lvm"} {
if tg := m[n]; tg.Kind != "system" || tg.Identity != "proven" || !reflect.DeepEqual(tg.PhysicalDisks, ssd) {
t.Errorf("%s: want system/proven on the SSD, got kind=%q id=%q disks=%v", n, tg.Kind, tg.Identity, tg.PhysicalDisks)
}
}
if tg := m["usb-backup"]; tg.Kind != "partition" || tg.Identity != "proven" || tg.PhysicalDisks[0] != "serial:ata:TOSHIBA_MQ04ABF100:65NOP3HDT" {
t.Errorf("usb-backup: got %+v", tg)
}
if tg := m["nfs-arch"]; tg.Kind != "network" || tg.IdentityReason != "network" {
t.Errorf("nfs: got kind=%q reason=%q", tg.Kind, tg.IdentityReason)
}
if tg := m["gone"]; tg.Identity != "" || len(tg.PhysicalDisks) != 0 {
t.Errorf("an unmounted dir must not be computed (never root's disk), got %+v", tg)
}
// Without a resolver the fields stay empty (the pre-0.157 shape).
plain, _ := NewObserver(api, host, nil, quietLogger()).Observe(context.Background())
if tg := byName(plain)["local"]; tg.Identity != "" || tg.Kind != "" {
t.Errorf("no resolver: want empty identity, got %+v", tg)
}
}