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:
2026-07-01 17:33:49 +02:00
parent 066e3bf153
commit 91f6a26490
10 changed files with 488 additions and 5 deletions
+55
View File
@@ -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)
}
}
+6
View File
@@ -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) {
+7 -1
View File
@@ -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))