R-542: the disk candidates no longer offer a registered, in-use drive
gates / gates (push) Successful in 1m2s
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:
@@ -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)||[];
|
||||
|
||||
Reference in New Issue
Block a user