v0.55.0: raw-device discovery + registry-sourced drive tracking (Impl-2a)
GET /disks/candidates enumerates host disks the Impl-1 unclaimed filter proves free (init/attach split). RegistryKnownTargets sources the watchdog's known-drive set from the intent registry + Felhom .mount units (not Observe/PVE storages) — decouples drive health from PVE storage (closes the registry-only false-detach class); Observe kept for real PVE storages + a deduped /disks union. Idempotent existing-drive migration at start. Tests + red-proof (Observe misses a registry-only drive; registry provider tracks it). go build/vet/test clean. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -27,6 +27,9 @@ type DiskOps interface {
|
||||
Unmount(ctx context.Context, where string) error
|
||||
Format(ctx context.Context, device, fstype string) error
|
||||
InspectDevice(ctx context.Context, device string) (storage.DeviceProbe, error)
|
||||
// ListCandidateDisks enumerates host disks the Impl-1 unclaimed filter proves are free to enroll
|
||||
// (Impl-2a discovery). Fail-safe: a device not provably unclaimed is omitted.
|
||||
ListCandidateDisks(ctx context.Context) ([]storage.CandidateDisk, error)
|
||||
}
|
||||
|
||||
// StorageGate authorizes a DESTRUCTIVE storage op (a data-bearing wipe/format) through the
|
||||
@@ -205,6 +208,33 @@ func (s *Server) handleDisks(w http.ResponseWriter, r *http.Request, vmid int) {
|
||||
}
|
||||
out = append(out, di)
|
||||
}
|
||||
// Impl-2a: union in registry+units drives that Observe() does NOT surface (a drive with no PVE
|
||||
// dir-storage). Additive + deduped by mount path — an existing drive already shown via Observe is
|
||||
// NOT duplicated, and no Observe row is dropped (so this can never regress the current view).
|
||||
if s.driveTargets != nil {
|
||||
seen := make(map[string]bool, len(out))
|
||||
for _, d := range out {
|
||||
if d.MountPath != "" {
|
||||
seen[d.MountPath] = true
|
||||
}
|
||||
}
|
||||
if drives, derr := s.driveTargets.Known(r.Context()); derr == nil {
|
||||
for _, d := range drives {
|
||||
if d.MountPath == "" || seen[d.MountPath] {
|
||||
continue
|
||||
}
|
||||
out = append(out, DiskInfo{
|
||||
Name: d.Name, Type: d.Type, State: "attached",
|
||||
MountPath: d.MountPath, DurableID: d.DurableID,
|
||||
Role: string(storage.RoleUserData),
|
||||
GuestAttached: boundPaths[d.MountPath],
|
||||
})
|
||||
}
|
||||
} else {
|
||||
s.logger.Warn("disks: registry drive union skipped", "err", derr)
|
||||
}
|
||||
}
|
||||
|
||||
// Guest boot-id (intermediary model): changes on every guest boot, stable across controller restarts.
|
||||
// The controller persists it and deterministically recreates drive-backed apps when it changes.
|
||||
bootID := ""
|
||||
@@ -214,6 +244,31 @@ func (s *Server) handleDisks(w http.ResponseWriter, r *http.Request, vmid int) {
|
||||
writeOK(w, map[string]any{"vmid": vmid, "disks": out, "guest_boot_id": bootID})
|
||||
}
|
||||
|
||||
// handleDiskCandidates (Impl-2a) lists the host disks FREE for Felhom to enroll — the raw-device
|
||||
// discovery the enrollment wizard (Impl-2b) consumes. Read-only + benign (the Impl-1 filter never
|
||||
// offers a claimed/OS disk). Response splits into `initialize` (all unclaimed) and `attach` (the subset
|
||||
// already carrying a mountable FS). GET /disks/candidates.
|
||||
func (s *Server) handleDiskCandidates(w http.ResponseWriter, r *http.Request, vmid int) {
|
||||
if s.disks == nil {
|
||||
writeErr(w, http.StatusServiceUnavailable, "disk management not configured on this host")
|
||||
return
|
||||
}
|
||||
cands, err := s.disks.ListCandidateDisks(r.Context())
|
||||
if err != nil {
|
||||
writeErr(w, http.StatusBadGateway, "could not scan host disks")
|
||||
return
|
||||
}
|
||||
initialize := make([]storage.CandidateDisk, 0, len(cands))
|
||||
attach := make([]storage.CandidateDisk, 0)
|
||||
for _, c := range cands {
|
||||
initialize = append(initialize, c) // every unclaimed disk can be initialized (format → enroll)
|
||||
if c.Mountable {
|
||||
attach = append(attach, c) // already has a mountable FS → attach without formatting
|
||||
}
|
||||
}
|
||||
writeOK(w, map[string]any{"vmid": vmid, "initialize": initialize, "attach": attach})
|
||||
}
|
||||
|
||||
type assignRequest struct {
|
||||
VMID int `json:"vmid"`
|
||||
UUID string `json:"uuid"`
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
package localapi
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"testing"
|
||||
|
||||
"gitea.dooplex.hu/admin/felhom-agent/internal/storage"
|
||||
)
|
||||
|
||||
// handleDiskCandidates splits discovery into initialize (all unclaimed) + attach (mountable-FS subset).
|
||||
func TestDiskCandidates_Split(t *testing.T) {
|
||||
d := &fakeDiskOps{candidates: []storage.CandidateDisk{
|
||||
{Device: "/dev/sdd", SizeBytes: 64 << 30, DataBearing: false}, // blank → initialize only
|
||||
{Device: "/dev/sde", FSType: "ext4", DataBearing: true, Mountable: true, MountSource: "/dev/sde1"}, // FS → init + attach
|
||||
{Device: "/dev/sdf", FSType: "ntfs", DataBearing: true, Mountable: false}, // ntfs → initialize only
|
||||
}}
|
||||
h := newDiskServer(t, d, &fakeGate{}, nil, nil)
|
||||
|
||||
w := do(t, h, "GET", "/disks/candidates", "A", "")
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("candidates: got %d want 200 (%s)", w.Code, w.Body.String())
|
||||
}
|
||||
var env struct {
|
||||
Data struct {
|
||||
Initialize []storage.CandidateDisk `json:"initialize"`
|
||||
Attach []storage.CandidateDisk `json:"attach"`
|
||||
} `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &env); err != nil {
|
||||
t.Fatalf("parse: %v", err)
|
||||
}
|
||||
resp := env.Data
|
||||
if len(resp.Initialize) != 3 {
|
||||
t.Fatalf("initialize must list all 3 unclaimed disks, got %d", len(resp.Initialize))
|
||||
}
|
||||
if len(resp.Attach) != 1 || resp.Attach[0].Device != "/dev/sde" {
|
||||
t.Fatalf("attach must list only the mountable-FS disk, got %+v", resp.Attach)
|
||||
}
|
||||
}
|
||||
@@ -26,6 +26,12 @@ type fakeDiskOps struct {
|
||||
formatCalls []string
|
||||
mountCalls []storage.MountSpec
|
||||
unmountCalls []string
|
||||
candidates []storage.CandidateDisk // returned by ListCandidateDisks
|
||||
candErr error
|
||||
}
|
||||
|
||||
func (f *fakeDiskOps) ListCandidateDisks(_ context.Context) ([]storage.CandidateDisk, error) {
|
||||
return f.candidates, f.candErr
|
||||
}
|
||||
|
||||
func (f *fakeDiskOps) InspectDevice(_ context.Context, device string) (storage.DeviceProbe, error) {
|
||||
|
||||
@@ -70,6 +70,9 @@ type Options struct {
|
||||
Backups BackupService
|
||||
Store BackupStore
|
||||
Storage StorageView
|
||||
// DriveTargets (Impl-2a, optional) yields registry+units-sourced drives for the /disks view, so a
|
||||
// drive with NO PVE storage still appears. Unioned with Storage.Observe (deduped by mount path).
|
||||
DriveTargets storage.KnownTargets
|
||||
Tokens TokenAuthority
|
||||
// BackupCadence is the per-guest backup interval driving GET /backup/due (slice 8B). A guest
|
||||
// is "due" when no successful backup is recorded OR the newest one is older than this. 0 → a
|
||||
@@ -154,7 +157,8 @@ type Server struct {
|
||||
guests GuestAPI
|
||||
backups BackupService
|
||||
store BackupStore
|
||||
storage StorageView
|
||||
storage StorageView
|
||||
driveTargets storage.KnownTargets // Impl-2a: registry+units drives for /disks (optional)
|
||||
tokens TokenAuthority
|
||||
cadence time.Duration
|
||||
logger *slog.Logger
|
||||
@@ -226,6 +230,7 @@ func NewServer(o Options) (*Server, error) {
|
||||
backups: o.Backups,
|
||||
store: o.Store,
|
||||
storage: o.Storage,
|
||||
driveTargets: o.DriveTargets,
|
||||
tokens: o.Tokens,
|
||||
cadence: cadence,
|
||||
logger: o.Logger,
|
||||
@@ -270,6 +275,7 @@ func (s *Server) Handler() http.Handler {
|
||||
mux.HandleFunc("GET /host/metrics", s.withGuest(s.handleHostMetrics))
|
||||
// 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/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