R-542: the disk candidates no longer offer a registered, in-use drive
gates / gates (push) Successful in 1m2s

The format wizard rendered the agent's initialize list as-is, and the
agent deliberately allows re-initialising Felhom's own drives, so a
registered data drive was offered for formatting. The controller proxy
now drops every candidate that backs a registered storage path (joined
through the guest mount table) from both lists; an unreadable mount
table empties initialize.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
This commit is contained in:
2026-10-06 20:50:39 +02:00
parent 5e7522e023
commit f65ace0ca6
4 changed files with 158 additions and 5 deletions
+73 -3
View File
@@ -5,7 +5,9 @@ import (
"encoding/json"
"errors"
"net/http"
"path"
"sort"
"strings"
"gitea.dooplex.hu/admin/felhom-controller/internal/agentapi"
)
@@ -126,9 +128,10 @@ func (s *Server) agentDisksListHandler(w http.ResponseWriter, r *http.Request) {
// agentDiskCandidatesHandler proxies GET /api/disks/candidates → agent GET /disks/candidates (Impl-2b):
// the raw-device scan (Impl-2a) that feeds the enrollment wizards. The agent's unclaimed-disk filter
// already excludes claimed/OS/enrolled disks (fail-safe), so `initialize` passes through UNTOUCHED —
// no controller-side filtering, and the system/backup drives it hides from the format wizard stay
// hidden.
// already excludes claimed/OS disks (fail-safe), and the system/backup drives it hides from the format
// wizard stay hidden. It deliberately does NOT exclude Felhom's own drives, so the controller removes
// every drive backing a REGISTERED storage path from both lists (R-542, dropRegisteredDrives) — the
// one filter the controller adds; it only ever removes entries.
//
// R-280: `attach` additionally carries the controller's own mounted-but-unregistered filesystems.
// The agent's scan alone left a rebuilt box with an empty picker under a sentence promising „két
@@ -150,9 +153,76 @@ func (s *Server) agentDiskCandidatesHandler(w http.ResponseWriter, r *http.Reque
writeDiskJSON(w, http.StatusBadGateway, false, s.errText(r, err), nil)
return
}
mounts := readMountTable()
resp = dropRegisteredDrives(resp, mounts, s.registeredStoragePaths())
writeDiskJSON(w, http.StatusOK, true, "", mergeAttachCandidates(resp, s.attachableStores()))
}
// dropRegisteredDrives (R-542) removes from BOTH lists every agent candidate that backs a REGISTERED
// storage path. The agent deliberately lets Felhom re-initialise its own drives (a mount under
// /mnt/felhom-drives is not a foreign claim — felhom-agent internal/storage/claim.go), so a drive the
// household registered and uses came back under `initialize` — the format wizard (storage_init.html)
// renders that list as-is. Measured 2026-09-16 on a fresh box: the registered default data drive was
// offered for formatting and again under `attach`.
//
// The join is the guest's own mount table: a registered path's mount SOURCE is the host device
// (measured on demo-hp 2026-10-06: `/dev/nvme0n1 /mnt/felhom-drives/hdd_1`), which is the candidate's
// Device (whole disk) or MountSource (partition).
//
// ⚠ FAIL-SAFE: an unreadable mount table cannot prove any candidate is NOT in use, so `initialize`
// comes back EMPTY (the wizard then says no drive is available) — never the unfiltered list. `attach`
// is non-destructive and is left as the agent sent it in that case.
func dropRegisteredDrives(resp agentapi.CandidatesResult, mountsText string, registered map[string]bool) agentapi.CandidatesResult {
if strings.TrimSpace(mountsText) == "" {
resp.Initialize = []agentapi.DiskCandidate{}
return resp
}
inUse := map[string]bool{}
for _, row := range parseMountTable(mountsText) {
if registered[path.Clean(row[1])] && strings.HasPrefix(row[0], "/dev/") {
inUse[row[0]] = true
}
}
if len(inUse) == 0 {
return resp
}
keep := func(in []agentapi.DiskCandidate) []agentapi.DiskCandidate {
out := make([]agentapi.DiskCandidate, 0, len(in))
for _, c := range in {
if candidateBacksInUse(c, inUse) {
continue
}
out = append(out, c)
}
return out
}
resp.Initialize = keep(resp.Initialize)
resp.Attach = keep(resp.Attach)
return resp
}
// candidateBacksInUse reports whether a candidate's disk is a source in inUse: the whole disk itself,
// its mountable partition, or any partition of it (/dev/sdb1, /dev/nvme0n1p1 under /dev/sdb, /dev/nvme0n1).
func candidateBacksInUse(c agentapi.DiskCandidate, inUse map[string]bool) bool {
if inUse[c.Device] || (c.MountSource != "" && inUse[c.MountSource]) {
return true
}
if c.Device == "" {
return false
}
for src := range inUse {
rest, ok := strings.CutPrefix(src, c.Device)
if !ok || rest == "" {
continue
}
rest = strings.TrimPrefix(rest, "p")
if rest != "" && strings.Trim(rest, "0123456789") == "" {
return true
}
}
return false
}
// mergeAttachCandidates adds the mounted-but-unregistered stores to `attach` and returns the result.
// `initialize` is passed through untouched — the ONE line that keeps the format wizard's protection
// intact, and the reason this is a separate function rather than two appends at the call site: it can