F20-BUG2 + F9-reporting: /disks surfaces wipe_durable_id (gate scheme) + guest_attached
F20-BUG2: the /disks list only carried DurableID in the uuid: scheme (for /disks/assign), but the wipe gate resolves devices in the byid:/byuuid: scheme — so a customer confirming a wipe with the advertised id was refused (binding_mismatch). Added a shared s.deviceDurableID seam used by BOTH handleDisks (new DiskInfo.WipeDurableID) and the format gate, so the id the customer copies from the list is exactly the id the gate accepts. DurableID (uuid:) is unchanged (still feeds assign). F9 (reporting half): added DiskInfo.GuestAttached — whether the drive's namespace is actually bound into THIS guest's config (guestBoundPaths), distinct from mere host presence (State). This is the signal whose absence made the HDD look available when it wasn't attached, and resolves the F2 hdd_configured-vs-/disks disagreement. Tests: wipe_durable_id is the gate scheme + distinct from uuid:; the list's wipe id matches the gate's device-id binding (no mismatch); guest_attached true iff bound into the guest.
This commit is contained in:
@@ -103,6 +103,17 @@ type DiskInfo struct {
|
||||
// controller strips the "uuid:" prefix to get the fs UUID it passes to POST /disks/assign —
|
||||
// the only way the (de-privileged) controller can learn the mount key it cannot read itself.
|
||||
DurableID string `json:"durable_id,omitempty"`
|
||||
// WipeDurableID is the device's WIPE-binding durable id in the SAME scheme the format gate resolves
|
||||
// against (byid:<wwn>/byuuid:<uuid>). F20-BUG2: the customer must confirm a data-bearing wipe with
|
||||
// THIS id, not DurableID (uuid:) — passing the uuid: id was rejected as a binding_mismatch. "" when
|
||||
// the device has no durable identity (a wipe of it can't be bound anyway). Distinct from DurableID
|
||||
// (uuid:, used for /disks/assign) on purpose; both are derived from the agent's own device read.
|
||||
WipeDurableID string `json:"wipe_durable_id,omitempty"`
|
||||
// GuestAttached reports whether THIS guest (the token's vmid) actually has the drive's felhom-data
|
||||
// namespace bound into its config — i.e. the drive is usable IN the guest, not merely present on the
|
||||
// host. F9: host presence (State=attached) != guest-usable; this is the missing signal that made the
|
||||
// HDD look available when it wasn't bound. Only meaningful for user-data drives.
|
||||
GuestAttached bool `json:"guest_attached"`
|
||||
}
|
||||
|
||||
// handleDisks lists the host's drives + data-bearing flags (read-only/benign).
|
||||
@@ -119,6 +130,9 @@ func (s *Server) handleDisks(w http.ResponseWriter, r *http.Request, vmid int) {
|
||||
// Resolve the OS/system disks ONCE for this request — role classification is agent-authoritative
|
||||
// (the agent's own mount/topology read, never the caller's claim).
|
||||
sysDisks, sysKnown := storage.SystemDisks(s.hostReader())
|
||||
// F9: which host mount paths are actually BOUND into THIS guest's config (guest-usable, not just
|
||||
// host-present). A bind's mp= equals the guest path, which is the drive's host mount path (`where`).
|
||||
boundPaths := s.guestBoundPaths(r.Context(), vmid)
|
||||
out := make([]DiskInfo, 0, len(targets))
|
||||
for _, t := range targets {
|
||||
di := DiskInfo{
|
||||
@@ -129,6 +143,7 @@ func (s *Server) handleDisks(w http.ResponseWriter, r *http.Request, vmid int) {
|
||||
TotalBytes: t.TotalBytes,
|
||||
UsedBytes: t.UsedBytes,
|
||||
UsedFraction: t.UsedFraction,
|
||||
GuestAttached: t.MountPath != "" && boundPaths[t.MountPath],
|
||||
}
|
||||
// Inspect the backing device for the UI's data-bearing hint (the authoritative check
|
||||
// is re-run at format time on the actual device).
|
||||
@@ -140,6 +155,11 @@ func (s *Server) handleDisks(w http.ResponseWriter, r *http.Request, vmid int) {
|
||||
di.DataBearing = true // fail-safe
|
||||
di.DataReason = "could not inspect device"
|
||||
}
|
||||
// F20-BUG2: surface the gate-scheme wipe id (byid:/byuuid:) so a customer-confirmed wipe
|
||||
// binds with the id the gate accepts. Same seam the gate uses → guaranteed to match.
|
||||
if wid, werr := s.deviceDurableID(t.BackingDevice); werr == nil {
|
||||
di.WipeDurableID = wid
|
||||
}
|
||||
}
|
||||
out = append(out, di)
|
||||
}
|
||||
@@ -447,7 +467,7 @@ func (s *Server) handleDiskFormat(w http.ResponseWriter, r *http.Request, vmid i
|
||||
// never the caller's claim). The agent also re-resolves the device's durable id; the customer's
|
||||
// confirmation must bind to it.
|
||||
role := s.deviceRole(r.Context(), req.Device)
|
||||
deviceDurable, derr := storage.DeviceDurableID(req.Device)
|
||||
deviceDurable, derr := s.deviceDurableID(req.Device)
|
||||
if derr != nil {
|
||||
deviceDurable = "" // refusal still stands; binding/pending-op just lack the id
|
||||
}
|
||||
@@ -517,6 +537,30 @@ func (s *Server) handleDiskFormat(w http.ResponseWriter, r *http.Request, vmid i
|
||||
"device is system/backup-protected — format requires an operator signature ("+dec.Reason+")")
|
||||
}
|
||||
|
||||
// guestBoundPaths returns the set of guest mountpoint paths (the `mp=` of each entry in the guest's
|
||||
// config) — i.e. the host drives actually BOUND into the guest. F9: this is the guest-attached signal
|
||||
// (`GuestAttached`) that distinguishes a guest-usable drive from one merely present on the host. A bind
|
||||
// created by AttachBind has `mp=<where>` where `where` is the drive's host mount path, so a storage
|
||||
// target is guest-attached iff its MountPath is in this set. Best-effort: a config-read error yields an
|
||||
// empty set (reported as not-attached — the safe direction).
|
||||
func (s *Server) guestBoundPaths(ctx context.Context, vmid int) map[string]bool {
|
||||
out := map[string]bool{}
|
||||
if s.guests == nil {
|
||||
return out
|
||||
}
|
||||
cfg, err := s.guests.GuestConfig(ctx, vmid)
|
||||
if err != nil {
|
||||
s.logger.Warn("local-api: guest-attached check — could not read guest config", "vmid", vmid, "err", err)
|
||||
return out
|
||||
}
|
||||
for _, spec := range cfg.MountPoints() {
|
||||
if _, mp, _ := parseMount(spec); mp != "" {
|
||||
out[mp] = true
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// durableIDForMount resolves the durable-id of the storage mounted at `where` (from the agent's own
|
||||
// storage view) — the key the intent store records enroll/eject against. "" if not resolvable.
|
||||
func (s *Server) durableIDForMount(ctx context.Context, where string) string {
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
package localapi
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"testing"
|
||||
|
||||
"gitea.dooplex.hu/admin/felhom-agent/internal/hub"
|
||||
"gitea.dooplex.hu/admin/felhom-agent/internal/storage"
|
||||
)
|
||||
|
||||
// decodeDisks pulls the disks array out of a GET /disks response.
|
||||
func decodeDisks(t *testing.T, body []byte) []DiskInfo {
|
||||
t.Helper()
|
||||
var resp struct {
|
||||
Data struct {
|
||||
Disks []DiskInfo `json:"disks"`
|
||||
} `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &resp); err != nil {
|
||||
t.Fatalf("decode /disks: %v (body=%s)", err, string(body))
|
||||
}
|
||||
return resp.Data.Disks
|
||||
}
|
||||
|
||||
// TestDisks_WipeDurableID_GateScheme asserts F20-BUG2: /disks surfaces a wipe_durable_id in the gate's
|
||||
// scheme (byid:/byuuid:, via the SAME s.deviceDurableID seam the format gate uses), DISTINCT from the
|
||||
// uuid: durable_id (which feeds /disks/assign). Pre-fix the only id was uuid:, which the gate rejected
|
||||
// as a binding_mismatch.
|
||||
func TestDisks_WipeDurableID_GateScheme(t *testing.T) {
|
||||
d := &fakeDiskOps{probe: storage.DeviceProbe{Probed: true, HasFilesystem: true, FSType: "ext4"}}
|
||||
sv := fakeStorage{targets: []hub.StorageTarget{
|
||||
{Name: "bulk", Type: hub.StorageTypeUSB, BackingDevice: "/dev/sdb1", MountPath: "/mnt/bulk", DurableID: "uuid:abc-123"},
|
||||
}}
|
||||
h := newDiskServer(t, d, &fakeGate{}, sv, nil)
|
||||
|
||||
w := do(t, h, "GET", "/disks", "A", "")
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("GET /disks: %d (%s)", w.Code, w.Body.String())
|
||||
}
|
||||
disks := decodeDisks(t, w.Body.Bytes())
|
||||
if len(disks) != 1 {
|
||||
t.Fatalf("want 1 disk, got %d", len(disks))
|
||||
}
|
||||
got := disks[0]
|
||||
// The stub in newDiskServer maps /dev/sdb1 → byid:wwn-sdb1 (the gate scheme).
|
||||
if got.WipeDurableID != "byid:wwn-sdb1" {
|
||||
t.Fatalf("WipeDurableID = %q, want byid:wwn-sdb1 (gate scheme)", got.WipeDurableID)
|
||||
}
|
||||
if got.DurableID != "uuid:abc-123" {
|
||||
t.Fatalf("DurableID = %q, want uuid:abc-123 (assign scheme, unchanged)", got.DurableID)
|
||||
}
|
||||
if got.WipeDurableID == got.DurableID {
|
||||
t.Fatal("wipe id must differ from the assign (uuid:) id")
|
||||
}
|
||||
}
|
||||
|
||||
// TestDisks_WipeID_MatchesGateBinding asserts the BUG2 end-to-end property: when the customer confirms a
|
||||
// wipe with the wipe_durable_id surfaced by /disks, the handler derives the device's gate id through the
|
||||
// SAME seam — so the value the gate compares as DeviceDurableID equals the customer's ConfirmDurableID
|
||||
// (no binding_mismatch). Asserted via the request the handler forwards to the gate.
|
||||
func TestDisks_WipeID_MatchesGateBinding(t *testing.T) {
|
||||
d := &fakeDiskOps{probe: storage.DeviceProbe{Probed: true, HasFilesystem: true, FSType: "ext4"}}
|
||||
sv := fakeStorage{targets: []hub.StorageTarget{
|
||||
{Name: "bulk", Type: hub.StorageTypeUSB, BackingDevice: "/dev/sdb1", MountPath: "/mnt/bulk", DurableID: "uuid:abc-123"},
|
||||
}}
|
||||
g := &fakeGate{decision: WipeDecision{Allowed: true, Tier: "customer_confirmable", Reason: "customer_confirmed"}}
|
||||
h := newDiskServer(t, d, g, sv, nil)
|
||||
|
||||
wipeID := decodeDisks(t, do(t, h, "GET", "/disks", "A", "").Body.Bytes())[0].WipeDurableID
|
||||
|
||||
body := `{"device":"/dev/sdb1","fstype":"ext4","confirmed":true,"durable_id":"` + wipeID + `"}`
|
||||
w := do(t, h, "POST", "/disks/format", "A", body)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("confirmed wipe with the list's wipe id: %d (%s)", w.Code, w.Body.String())
|
||||
}
|
||||
reqs := g.requests()
|
||||
if len(reqs) != 1 {
|
||||
t.Fatalf("gate consulted %d times, want 1", len(reqs))
|
||||
}
|
||||
// The crux: the customer's confirm id (from the list) equals the device id the gate resolves.
|
||||
if reqs[0].ConfirmDurableID != wipeID {
|
||||
t.Fatalf("ConfirmDurableID forwarded = %q, want the list's wipe id %q", reqs[0].ConfirmDurableID, wipeID)
|
||||
}
|
||||
if reqs[0].DeviceDurableID != reqs[0].ConfirmDurableID {
|
||||
t.Fatalf("gate binding mismatch: device id %q != confirm id %q (BUG2 not fixed)", reqs[0].DeviceDurableID, reqs[0].ConfirmDurableID)
|
||||
}
|
||||
}
|
||||
|
||||
// TestDisks_GuestAttached asserts F9 reporting: a drive bound into THIS guest's config (mp=<mount_path>)
|
||||
// reports guest_attached=true; a host-present-but-unbound drive reports false — the signal that was
|
||||
// missing when the HDD looked available but wasn't attached.
|
||||
func TestDisks_GuestAttached(t *testing.T) {
|
||||
d := &fakeDiskOps{probe: storage.DeviceProbe{Probed: true, HasFilesystem: true, FSType: "ext4"}}
|
||||
sv := fakeStorage{targets: []hub.StorageTarget{
|
||||
{Name: "usb", Type: hub.StorageTypeUSB, BackingDevice: "/dev/sdb1", MountPath: "/mnt/felhom-usb"},
|
||||
{Name: "extra", Type: hub.StorageTypeUSB, BackingDevice: "/dev/sdc1", MountPath: "/mnt/extra"},
|
||||
}}
|
||||
// Guest 8200 (token "A") has /mnt/felhom-usb bound (Model-A bind: source .../felhom-data, mp=where),
|
||||
// but NOT /mnt/extra.
|
||||
h := newDiskServerWithGuestConfigs(t, d, sv, nil, map[int]map[string]string{
|
||||
8200: {"mp3": "/mnt/felhom-usb/felhom-data,mp=/mnt/felhom-usb"},
|
||||
})
|
||||
|
||||
disks := decodeDisks(t, do(t, h, "GET", "/disks", "A", "").Body.Bytes())
|
||||
got := map[string]bool{}
|
||||
for _, di := range disks {
|
||||
got[di.MountPath] = di.GuestAttached
|
||||
}
|
||||
if !got["/mnt/felhom-usb"] {
|
||||
t.Errorf("/mnt/felhom-usb should be guest_attached=true (bound into the guest)")
|
||||
}
|
||||
if got["/mnt/extra"] {
|
||||
t.Errorf("/mnt/extra should be guest_attached=false (host-present but not bound)")
|
||||
}
|
||||
}
|
||||
@@ -118,6 +118,9 @@ func newDiskServer(t *testing.T, d *fakeDiskOps, g *fakeGate, sv StorageView, gl
|
||||
// device the format tests use; antiRetargetResolve itself is covered directly
|
||||
// in wipe_reresolve_test.go.
|
||||
srv.reresolveWipe = func(_ context.Context, _ string) (string, error) { return "/dev/sdb", nil }
|
||||
// F20-BUG2: the wipe id derivation hits /dev/disk/by-* in production; stub it deterministically so
|
||||
// both the /disks list and the gate (which share this seam) resolve the same id in tests.
|
||||
srv.deviceDurableID = func(device string) (string, error) { return "byid:wwn-" + strings.TrimPrefix(device, "/dev/"), nil }
|
||||
return srv.Handler()
|
||||
}
|
||||
|
||||
|
||||
@@ -154,6 +154,13 @@ type Server struct {
|
||||
// override it to avoid touching real /dev.
|
||||
reresolveWipe func(ctx context.Context, durableID string) (string, error)
|
||||
|
||||
// deviceDurableID derives the WIPE-binding durable id of a block device (the byid:/byuuid: scheme
|
||||
// the wipe gate resolves against). F20-BUG2: BOTH the /disks list (DiskInfo.WipeDurableID) and the
|
||||
// format gate use this single seam, so the id the customer copies from the list is exactly the id
|
||||
// the gate accepts (no more uuid: vs byid: binding_mismatch). Defaults to storage.DeviceDurableID;
|
||||
// tests override it. (DiskInfo.DurableID stays the uuid: storage id — that one feeds /disks/assign.)
|
||||
deviceDurableID func(device string) (string, error)
|
||||
|
||||
jobsMu sync.Mutex
|
||||
jobs map[int]*backupJob // per-guest backup job state (slice 8B)
|
||||
|
||||
@@ -197,6 +204,7 @@ func NewServer(o Options) (*Server, error) {
|
||||
jobs: map[int]*backupJob{},
|
||||
}
|
||||
s.reresolveWipe = s.reresolveDurableForWipe
|
||||
s.deviceDurableID = storage.DeviceDurableID
|
||||
return s, nil
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user