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:
@@ -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
|
||||
|
||||
@@ -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}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
|
||||
Reference in New Issue
Block a user