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/[/…] → the host mount at exactly /mnt/felhom-drives/ (the drive's // felhom-data bind, or a network share's mount). A path whose 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} }