c5daa9480d
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
201 lines
6.9 KiB
Go
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}
|
|
}
|