Physical-disk identity of every storage place + POST /disks/same-disk (felhom.eu architecture/16 §3/§4)
gates / gates (push) Successful in 31s
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:
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user