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"`