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
@@ -0,0 +1,77 @@
package web
import (
"testing"
"gitea.dooplex.hu/admin/felhom-controller/internal/agentapi"
)
// R-542 — a drive that backs a REGISTERED storage path must not be offered for formatting (nor again
// for attaching). The mount table is the shape measured on demo-hp 2026-10-06 (guest /proc/mounts).
const r542Mounts = `/dev/mapper/pve-root /mnt/felhom-drives ext4 rw,relatime 0 0
/dev/nvme0n1 /mnt/felhom-drives/hdd_1 ext4 rw,relatime 0 0
/dev/sdb1 /mnt/felhom-drives/adatlemez ext4 rw,relatime 0 0
/dev/mapper/pve-vm--9201--disk--1 /mnt/sys_drive ext4 rw,relatime 0 0
`
// COMPANION RED-PROOF (observed): make dropRegisteredDrives `return resp` at its top (the pre-R-542
// pass-through) → this fails with "a registered, in-use drive is offered for FORMATTING". Restored.
func TestR542_RegisteredDriveNotOfferedForFormat(t *testing.T) {
agentSaid := agentapi.CandidatesResult{
Initialize: []agentapi.DiskCandidate{
{Device: "/dev/sdb", MountSource: "/dev/sdb1", FSType: "ext4", DataBearing: true}, // registered (partition source)
{Device: "/dev/nvme0n1", FSType: "ext4", DataBearing: true}, // registered (whole-disk source)
{Device: "/dev/sdc"}, // a fresh, blank drive
},
Attach: []agentapi.DiskCandidate{
{Device: "/dev/sdb", MountSource: "/dev/sdb1", FSType: "ext4"},
{Device: "/dev/sdd", MountSource: "/dev/sdd1", FSType: "ext4"}, // a fresh USB with a filesystem
},
}
registered := map[string]bool{"/mnt/felhom-drives/hdd_1": true, "/mnt/felhom-drives/adatlemez": true, "/mnt/sys_drive": true}
got := dropRegisteredDrives(agentSaid, r542Mounts, registered)
if len(got.Initialize) != 1 || got.Initialize[0].Device != "/dev/sdc" {
t.Fatalf("a registered, in-use drive is offered for FORMATTING: initialize=%+v (want only /dev/sdc)", got.Initialize)
}
if len(got.Attach) != 1 || got.Attach[0].Device != "/dev/sdd" {
t.Fatalf("a registered drive is offered again under attach: %+v (want only /dev/sdd)", got.Attach)
}
}
// An UNregistered drive mounted under the managed path keeps today's behaviour (the agent's rule:
// re-initialising Felhom's own, unregistered drive stays allowed — e.g. after a rebuild).
func TestR542_UnregisteredDriveStillOffered(t *testing.T) {
agentSaid := agentapi.CandidatesResult{Initialize: []agentapi.DiskCandidate{{Device: "/dev/sdb", MountSource: "/dev/sdb1"}}}
got := dropRegisteredDrives(agentSaid, r542Mounts, map[string]bool{"/mnt/sys_drive": true})
if len(got.Initialize) != 1 {
t.Fatalf("an unregistered drive must stay offered; got %+v", got.Initialize)
}
}
// FAIL-SAFE: an unreadable mount table cannot prove a drive is free → initialize is EMPTY, attach unchanged.
//
// COMPANION RED-PROOF (observed): drop the empty-mounts branch → this fails with "unreadable mount
// table must empty initialize". Restored.
func TestR542_UnreadableMountTableEmptiesInitialize(t *testing.T) {
agentSaid := agentapi.CandidatesResult{
Initialize: []agentapi.DiskCandidate{{Device: "/dev/sdb"}},
Attach: []agentapi.DiskCandidate{{Device: "/dev/sdd", MountSource: "/dev/sdd1"}},
}
got := dropRegisteredDrives(agentSaid, "", map[string]bool{"/mnt/felhom-drives/adatlemez": true})
if len(got.Initialize) != 0 || got.Initialize == nil {
t.Fatalf("unreadable mount table must empty initialize (non-nil, so JSON is []); got %+v", got.Initialize)
}
if len(got.Attach) != 1 {
t.Fatalf("attach is non-destructive and must pass through; got %+v", got.Attach)
}
}
// Partition matching must not over-match: /dev/sdb1 in use does not hide /dev/sdba or /dev/sdc.
func TestR542_PartitionMatchIsExact(t *testing.T) {
inUse := map[string]bool{"/dev/sdb1": true, "/dev/nvme0n1p2": true}
for dev, want := range map[string]bool{"/dev/sdb": true, "/dev/sdba": false, "/dev/sdc": false, "/dev/nvme0n1": true, "/dev/nvme0n12": false} {
if got := candidateBacksInUse(agentapi.DiskCandidate{Device: dev}, inUse); got != want {
t.Errorf("candidateBacksInUse(%s) = %v, want %v", dev, got, want)
}
}
}
@@ -75,8 +75,8 @@ function fmtSize(b){
async function loadDisks(){
try{
// Impl-2b: the raw-device scan (agent GET /disks/candidates via the controller proxy). The agent's
// unclaimed-disk filter already excludes OS/enrolled/claimed disks (fail-safe), so we trust the list
// as-is — no client-side filtering. `initialize` = every unclaimed disk (blank or data-bearing).
// unclaimed-disk filter excludes OS/claimed disks (fail-safe) and the proxy removes every drive behind a
// registered storage path (R-542), so we trust the list as-is — no client-side filtering. `initialize` = every unclaimed disk (blank or data-bearing).
var r = await fetch('/api/disks/candidates'); var j = await r.json();
if(!j.ok){ throw new Error(j.error||'{{T "storage_init.hiba"}}'); }
var cands = (j.data&&j.data.initialize)||[];