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
+13
View File
@@ -188,6 +188,13 @@ type DiskInfo struct {
// device that exposes no SMART (USB bridge, unread) is ABSENT, not a misleading zero-value UNKNOWN;
// the controller feature-detects presence and renders "Nincs adat" when nil (never alarms).
Smart *hub.SmartSummary `json:"smart,omitempty"`
// Physical-disk identity (v0.157.0, felhom.eu architecture/16-storage-physical-identity.md §3) — the
// same agent-computed verdict the hub report carries. Empty when not computed (an absent drive, no
// resolver). Additive; an older controller ignores it.
Kind string `json:"kind,omitempty"`
PhysicalDisks []string `json:"physical_disks,omitempty"`
Identity string `json:"identity,omitempty"`
IdentityReason string `json:"identity_reason,omitempty"`
}
// handleDisks lists the host's drives + data-bearing flags (read-only/benign).
@@ -227,6 +234,8 @@ func (s *Server) handleDisks(w http.ResponseWriter, r *http.Request, vmid int) {
// really is the target. Reported here so the controller can name the drive in an
// absent-target alarm and hide the destructive controls the agent would refuse anyway.
BackupTarget: t.Name == primaryTargetID,
// `16` §3: copied from the observer, which computed it from /sys + udev.
Kind: t.Kind, PhysicalDisks: t.PhysicalDisks, Identity: t.Identity, IdentityReason: t.IdentityReason,
}
// Intermediary model: the stable in-guest path + whether felhom-data is bound under the parent.
// Only user-data /mnt/<name> drives have a guest path (system/backup mounts never cross in).
@@ -398,6 +407,10 @@ func (s *Server) handleDisks(w http.ResponseWriter, r *http.Request, vmid int) {
di.Smart = &sm
}
}
// `16` §3: a registry drive's identity, judged on its OWN mount only (never root's).
if p, kind, ok := s.ownMountIdentity(d.MountPath); ok {
di.Kind, di.PhysicalDisks, di.Identity, di.IdentityReason = kind, p.PhysicalDisks, p.Identity, p.Reason
}
// R-118: statfs ONLY while the drive's device is present. With the device gone the raw
// mountpoint reverts to a bare directory on the ROOT filesystem, and statfs would report
// pve-root's size as this drive's (measured: a 4 GB drive advertising 46 GiB). Same trap
+200
View File
@@ -0,0 +1,200 @@
package localapi
import (
"context"
"net/http"
"path"
"regexp"
"strings"
"gitea.dooplex.hu/admin/felhom-agent/internal/hub"
"gitea.dooplex.hu/admin/felhom-agent/internal/storage"
)
// POST /disks/same-disk (v0.157.0) — felhom.eu architecture/16-storage-physical-identity.md §4.
//
// The controller asks whether two of ITS paths sit on the same physical disk; the agent answers from its
// own /sys + udev read, never from the caller's claim (`16` §10). A guest path is mapped only to a place
// the agent itself made:
// - /mnt/felhom-drives/<name>[/…] → the host mount at exactly /mnt/felhom-drives/<name> (the drive's
// felhom-data bind, or a network share's mount). A path whose <name> is not mounted there (an absent
// drive: the bare directory on the root disk) is UNMAPPED — never "the system disk".
// - /mnt/sys_drive, /var/lib/felhom, /var/lib/docker [/…] → the guest's data volume (mp0, mp=/var/lib/felhom;
// or a legacy mp at that exact path) → the PVE storage holding it → that storage's identity.
// - anything else → unmapped.
// Answer: {different, proven, reason, a, b}. Act on `proven` first: different+proven = a counted second
// copy; same_disk+proven = not a second copy; proven=false = unknown (reason vm|network|usb_bridge|
// weak_wwn|no_serial|unmapped). Pinned by samedisk_test.go.
type sameDiskRequest struct {
VMID int `json:"vmid,omitempty"`
A string `json:"a"`
B string `json:"b"`
}
// SamePlace is one side of the answer.
type SamePlace struct {
Path string `json:"path"`
Mapped bool `json:"mapped"`
Kind string `json:"kind,omitempty"`
PhysicalDisks []string `json:"physical_disks,omitempty"`
Identity string `json:"identity,omitempty"`
Reason string `json:"reason,omitempty"`
}
// SameDiskResponse is the wire answer.
type SameDiskResponse struct {
Different bool `json:"different"`
Proven bool `json:"proven"`
Reason string `json:"reason,omitempty"`
A SamePlace `json:"a"`
B SamePlace `json:"b"`
}
// sysDataPaths are the guest paths that live on the guest's one data volume (golden v3: mp0 at
// /var/lib/felhom, its docker/ and sys_drive/ folders bound in the guest — 07 §7.5.1).
var sysDataPaths = []string{"/var/lib/felhom", "/mnt/sys_drive", "/var/lib/docker"}
func (s *Server) handleSameDisk(w http.ResponseWriter, r *http.Request, vmid int) {
if s.ident == nil {
writeErr(w, http.StatusServiceUnavailable, "disk identity not configured on this host")
return
}
var req sameDiskRequest
if !decodeBody(w, r, &req) {
return
}
if !s.scopedFromBody(w, req.VMID, vmid, r.URL.Path) {
return
}
if !path.IsAbs(req.A) || !path.IsAbs(req.B) {
writeErr(w, http.StatusBadRequest, "a and b must be absolute guest paths")
return
}
a := s.mapGuestPlace(r.Context(), vmid, req.A)
b := s.mapGuestPlace(r.Context(), vmid, req.B)
resp := SameDiskResponse{A: a.view, B: b.view}
resp.Different, resp.Proven, resp.Reason = storage.Relation(a.id, b.id)
s.logger.Info("local-api: same-disk", "vmid", vmid, "a", req.A, "b", req.B,
"different", resp.Different, "proven", resp.Proven, "reason", resp.Reason)
writeOK(w, resp)
}
type mappedPlace struct {
view SamePlace
id storage.PlaceIdentity
}
func (s *Server) mapGuestPlace(ctx context.Context, vmid int, guestPath string) mappedPlace {
p := path.Clean(guestPath)
out := mappedPlace{view: SamePlace{Path: p}}
set := func(kind string, id storage.PlaceIdentity) mappedPlace {
if !id.Computed() {
return out
}
out.id = id
out.view.Mapped, out.view.Kind = true, kind
out.view.PhysicalDisks, out.view.Identity, out.view.Reason = id.PhysicalDisks, id.Identity, id.Reason
return out
}
if strings.HasPrefix(p, StableParentDir+"/") {
name := strings.SplitN(strings.TrimPrefix(p, StableParentDir+"/"), "/", 2)[0]
if id, kind, ok := s.ownMountIdentity(StableParentDir + "/" + name); ok {
return set(kind, id)
}
return out
}
for _, root := range sysDataPaths {
if p != root && !strings.HasPrefix(p, root+"/") {
continue
}
id := s.guestDataVolumeIdentity(ctx, vmid, root)
return set(storage.PlaceKindSystem, id)
}
return out
}
// ownMountIdentity judges a HOST path only when it is ITSELF a mountpoint (an enrolled drive's raw mount
// or its felhom-data bind, a network share). A path that is not its own mount — an absent drive, whose
// directory now sits on the root disk — is not computed, never root's identity (the R-118 trap).
func (s *Server) ownMountIdentity(hostPath string) (storage.PlaceIdentity, string, bool) {
if s.ident == nil || hostPath == "" {
return storage.PlaceIdentity{}, "", false
}
mounts, err := s.hostReader().Mounts()
if err != nil {
return storage.PlaceIdentity{}, "", false
}
m, ok := storage.MountFor(mounts, hostPath)
if !ok || path.Clean(m.MountPoint) != path.Clean(hostPath) {
return storage.PlaceIdentity{}, "", false
}
id := s.ident.HostPath(hostPath)
if !id.Computed() {
return id, "", false
}
kind := storage.PlaceKindDisk
switch {
case id.Reason == storage.ReasonNetwork:
kind = storage.PlaceKindNetwork
case strings.HasPrefix(m.Device, "/dev/") && !strings.HasPrefix(m.Device, "/dev/mapper/") && isPartitionPath(m.Device):
kind = storage.PlaceKindPartition
}
return id, kind, true
}
var rePartitionName = regexp.MustCompile(`^(nvme[0-9]+n[0-9]+p[0-9]+|(sd|vd|hd)[a-z]+[0-9]+)$`)
func isPartitionPath(dev string) bool { return rePartitionName.MatchString(path.Base(dev)) }
// guestDataVolumeIdentity finds the guest mountpoint that holds root (mp=/var/lib/felhom on golden v3, or a
// legacy mp at that exact path) and returns the identity of the PVE storage that holds its volume.
func (s *Server) guestDataVolumeIdentity(ctx context.Context, vmid int, root string) storage.PlaceIdentity {
if s.guests == nil || s.storage == nil {
return storage.PlaceIdentity{}
}
cfg, err := s.guests.GuestConfig(ctx, vmid)
if err != nil {
return storage.PlaceIdentity{}
}
storeID, fallback := "", ""
for _, spec := range cfg.MountPoints() {
st, mp, _ := parseMount(spec)
if strings.HasPrefix(st, "/") {
continue // a host bind (mp8, mp9), not a volume
}
switch mp {
case root:
storeID = st // a legacy mp at exactly this path wins
case "/var/lib/felhom":
fallback = st // golden v3: the one data volume
}
}
if storeID == "" {
storeID = fallback
}
if storeID == "" {
return storage.PlaceIdentity{}
}
targets, err := s.storage.Observe(ctx)
if err != nil {
return storage.PlaceIdentity{}
}
for _, t := range targets {
if t.Name == storeID {
return identityOfTarget(t)
}
}
return storage.PlaceIdentity{}
}
func identityOfTarget(t hub.StorageTarget) storage.PlaceIdentity {
if t.Identity == "" {
return storage.PlaceIdentity{}
}
disks := t.PhysicalDisks
if disks == nil {
disks = []string{}
}
return storage.PlaceIdentity{PhysicalDisks: disks, Identity: t.Identity, Reason: t.IdentityReason}
}
+146
View File
@@ -0,0 +1,146 @@
package localapi
import (
"context"
"encoding/json"
"io"
"log/slog"
"net/http"
"testing"
"gitea.dooplex.hu/admin/felhom-agent/internal/hub"
"gitea.dooplex.hu/admin/felhom-agent/internal/storage"
)
// POST /disks/same-disk (`16` §4) — the controller's Tier-2 question, answered from the agent's own read.
type fakeIdentHost struct {
mounts []storage.Mount
parent map[string]string
props map[string]map[string]string
virt string
}
func (f *fakeIdentHost) Mounts() ([]storage.Mount, error) { return f.mounts, nil }
func (f *fakeIdentHost) KernelName(dev string) (string, bool) {
if len(dev) > 5 && dev[:5] == "/dev/" {
return dev[5:], true
}
return "", false
}
func (f *fakeIdentHost) Parent(n string) (string, bool) { p, ok := f.parent[n]; return p, ok }
func (f *fakeIdentHost) Slaves(string) []string { return nil }
func (f *fakeIdentHost) LoopBackingFile(string) (string, bool) { return "", false }
func (f *fakeIdentHost) UdevProps(n string) map[string]string { return f.props[n] }
func (f *fakeIdentHost) Virt() string { return f.virt }
// sameDiskRig: guest 8200 (token A) with mp0 on local-lvm (the system SSD, proven) + the given mounts.
func sameDiskRig(t *testing.T, host *fakeIdentHost, localLVM hub.StorageTarget) http.Handler {
t.Helper()
fg := &fakeGuestsCfg{mounts: map[int]map[string]string{8200: {
"mp0": "local-lvm:vm-8200-disk-1,mp=/var/lib/felhom,backup=1,size=70G",
"mp8": "/mnt/felhom-drives,mp=/mnt/felhom-drives",
}}}
srv, err := NewServer(Options{
ListenAddr: "127.0.0.1:0", Guests: fg, Backups: &fakeBackups{}, Store: &fakeStore{},
Storage: fakeStorage{targets: []hub.StorageTarget{localLVM}}, Tokens: staticTokens{"A": 8200},
HostReader: fakeHostReader{mounts: host.mounts},
Identity: &storage.IdentityResolver{Host: host},
Logger: slog.New(slog.NewTextHandler(io.Discard, nil)),
})
if err != nil {
t.Fatal(err)
}
srv.baseCtx = context.Background()
return srv.Handler()
}
var provenSSD = hub.StorageTarget{Name: "local-lvm", Type: hub.StorageTypeLVMThin, Kind: "system",
PhysicalDisks: []string{"serial:ata:SanDisk:1"}, Identity: "proven"}
func baseHost() *fakeIdentHost {
return &fakeIdentHost{
virt: "none",
parent: map[string]string{"sdc1": "sdc", "sdc2": "sdc"},
props: map[string]map[string]string{
"sdb": {"ID_BUS": "ata", "ID_MODEL": "TOSHIBA", "ID_SERIAL_SHORT": "65NOP3HDT"},
"sdc": {"ID_BUS": "usb", "ID_MODEL": "Portable", "ID_SERIAL_SHORT": "NAAB7C21"},
},
mounts: []storage.Mount{
{Device: "/dev/mapper/pve-root", MountPoint: "/", FSType: "ext4"},
{Device: "/dev/mapper/pve-root", MountPoint: "/mnt/felhom-drives", FSType: "ext4"},
{Device: "/dev/sdb", MountPoint: "/mnt/felhom-drives/hdd_1", FSType: "ext4"},
{Device: "/dev/sdc1", MountPoint: "/mnt/felhom-drives/a", FSType: "ext4"},
{Device: "/dev/sdc2", MountPoint: "/mnt/felhom-drives/b", FSType: "ext4"},
{Device: "//nas/films", MountPoint: "/mnt/felhom-drives/nas", FSType: "cifs"},
},
}
}
func sameDisk(t *testing.T, h http.Handler, a, b string) (SameDiskResponse, int) {
t.Helper()
body, _ := json.Marshal(map[string]string{"a": a, "b": b})
w := do(t, h, "POST", "/disks/same-disk", "A", string(body))
var env struct {
Data SameDiskResponse `json:"data"`
}
_ = json.Unmarshal(w.Body.Bytes(), &env)
return env.Data, w.Code
}
func TestSameDisk_DriveVsSystemData_DifferentProven(t *testing.T) {
h := sameDiskRig(t, baseHost(), provenSSD)
got, code := sameDisk(t, h, "/mnt/felhom-drives/hdd_1/felhom-data/paperless", "/mnt/sys_drive")
if code != http.StatusOK || !got.Different || !got.Proven || got.Reason != "" {
t.Fatalf("drive vs sys_drive: want different+proven, got %d %+v", code, got)
}
if got.B.Kind != "system" || got.A.Kind != "disk" {
t.Fatalf("kinds: got a=%q b=%q", got.A.Kind, got.B.Kind)
}
}
func TestSameDisk_TwoPartitionsOfOneDisk_SameProven(t *testing.T) {
h := sameDiskRig(t, baseHost(), provenSSD)
got, _ := sameDisk(t, h, "/mnt/felhom-drives/a", "/mnt/felhom-drives/b/x")
if got.Different || !got.Proven || got.Reason != "same_disk" || got.A.Kind != "partition" {
t.Fatalf("two partitions: want same+proven, got %+v", got)
}
}
func TestSameDisk_InsideAVM_NotProven(t *testing.T) {
host := baseHost()
host.virt = "kvm"
h := sameDiskRig(t, host, hub.StorageTarget{Name: "local-lvm", Identity: "unknown", IdentityReason: "vm",
PhysicalDisks: []string{"serial:scsi:QEMU_HARDDISK:drive-scsi0"}})
got, _ := sameDisk(t, h, "/mnt/felhom-drives/hdd_1", "/mnt/sys_drive")
if got.Proven || got.Reason != "vm" {
t.Fatalf("inside a VM: want not proven (vm), got %+v", got)
}
}
func TestSameDisk_NetworkAndUnmapped(t *testing.T) {
h := sameDiskRig(t, baseHost(), provenSSD)
if got, _ := sameDisk(t, h, "/mnt/felhom-drives/nas", "/mnt/sys_drive"); got.Proven || got.Reason != "network" {
t.Fatalf("a NAS: want not proven (network), got %+v", got)
}
// An ABSENT drive: its directory sits on the root disk — must be unmapped, never "the system disk".
if got, _ := sameDisk(t, h, "/mnt/felhom-drives/gone", "/mnt/sys_drive"); got.Proven || got.Reason != "unmapped" || got.A.Mapped {
t.Fatalf("an absent drive: want unmapped, got %+v", got)
}
if got, _ := sameDisk(t, h, "/opt/elsewhere", "/mnt/sys_drive"); got.Proven || got.Reason != "unmapped" {
t.Fatalf("an unknown guest path: want unmapped, got %+v", got)
}
}
func TestSameDisk_ScopedAndValidated(t *testing.T) {
h := sameDiskRig(t, baseHost(), provenSSD)
if w := do(t, h, "POST", "/disks/same-disk", "A", `{"vmid":9300,"a":"/mnt/sys_drive","b":"/mnt/sys_drive"}`); w.Code != http.StatusForbidden {
t.Fatalf("another guest's vmid: got %d want 403", w.Code)
}
if w := do(t, h, "POST", "/disks/same-disk", "A", `{"a":"relative","b":"/mnt/sys_drive"}`); w.Code != http.StatusBadRequest {
t.Fatalf("a relative path: got %d want 400", w.Code)
}
if w := do(t, h, "POST", "/disks/same-disk", "", `{"a":"/x","b":"/y"}`); w.Code != http.StatusUnauthorized {
t.Fatalf("no token: got %d want 401", w.Code)
}
}
+8 -1
View File
@@ -220,6 +220,10 @@ type Options struct {
// ROLE (it backs SystemDisks for the eject role-gate + the /disks role hints). OPTIONAL — when nil
// it defaults to the production *storage.ProcHostReader. Injectable so the role-gate is testable.
HostReader storage.HostReader
// Identity computes physical-disk identity (felhom.eu architecture/16-storage-physical-identity.md §3)
// for the /disks rows and POST /disks/same-disk. OPTIONAL — when nil, /disks carries no identity and
// same-disk answers 503 "not configured" (the controller then keeps its old st_dev rule).
Identity *storage.IdentityResolver
// HostMetrics serves GET /host/metrics (slice 9) — host-wide health (cpu%/mem/load/uptime/
// cpu-temp) + per-storage capacity, host-wide and token-authed (one-customer-per-host). When
// nil the endpoint reports "not configured" (host still reports/reconciles).
@@ -325,7 +329,8 @@ type Server struct {
// guestPowerSweeps counts completed guest-power sweeps, for the liveness observable. Touched only
// from GuestPowerTick, which the ticker calls serially.
guestPowerSweeps int
host storage.HostReader // role classification source (optional; defaults to ProcHostReader)
host storage.HostReader // role classification source (optional; defaults to ProcHostReader)
ident *storage.IdentityResolver // `16` §3 physical-disk identity (optional)
hostMetrics HostMetricsProvider // slice 9 (optional)
hostID string // slice 10B: for the data-bearing-format pending-op hint
@@ -480,6 +485,7 @@ func NewServer(o Options) (*Server, error) {
formatJobs: o.FormatJobs,
staleLock: o.StaleLock,
host: o.HostReader,
ident: o.Identity,
hostMetrics: o.HostMetrics,
hostID: o.HostID,
agentVersion: o.AgentVersion,
@@ -546,6 +552,7 @@ func (s *Server) Handler() http.Handler {
// Disk management (slice 8C) — self-scoped; format routes through the data-bearing classifier+gate.
mux.HandleFunc("GET /disks", s.withGuest(s.handleDisks))
mux.HandleFunc("GET /disks/candidates", s.withGuest(s.handleDiskCandidates))
mux.HandleFunc("POST /disks/same-disk", s.withGuest(s.handleSameDisk)) // `16` §4 (v0.157.0)
mux.HandleFunc("POST /disks/assign", s.withGuest(s.handleDiskAssign))
mux.HandleFunc("POST /disks/eject", s.withGuest(s.handleDiskEject))
mux.HandleFunc("POST /disks/decommission", s.withGuest(s.handleDiskDecommission))