Files
felhom-agent/internal/localapi/samedisk.go
T
admin c5daa9480d
gates / gates (push) Successful in 31s
Physical-disk identity of every storage place + POST /disks/same-disk (felhom.eu architecture/16 §3/§4)
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
2026-10-15 21:09:18 +02:00

201 lines
6.9 KiB
Go

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}
}