diff --git a/CHANGELOG.md b/CHANGELOG.md index 782de66..f9fb0a3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,9 @@ +## Unreleased (2026-10-06 night) — the burn-down night's controller fixes; ships with the next release + +**MinAgent: 0.131.0** (unchanged — no new agent route is read). + +- **R-542:** `GET /api/disks/candidates` no longer offers a drive that backs a REGISTERED storage path — not under `initialize` (the format wizard renders that list as-is, so a household's in-use data drive was offered for formatting) and not again under `attach`. The join is the guest's own mount table (a registered path's mount source is the host device). Fail-safe: an unreadable mount table empties `initialize`; `attach` passes through. Tests `TestR542_*` (two red-proofs observed). + ## v0.301.0 — one short stop per backup tier; restores replay before the app starts; the family window reopens a command-closed sign-up (R-518, R-638, R-717; `09` §3 154, 156) (2026-10-06) **MinAgent: 0.131.0** (unchanged — R-518 reads the agent's existing `Primary` tier flag, R-82 agents ≥ 0.97.0). diff --git a/controller/internal/web/agent_disk_handlers.go b/controller/internal/web/agent_disk_handlers.go index e031f45..6a1792c 100644 --- a/controller/internal/web/agent_disk_handlers.go +++ b/controller/internal/web/agent_disk_handlers.go @@ -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 diff --git a/controller/internal/web/r542_registered_drive_test.go b/controller/internal/web/r542_registered_drive_test.go new file mode 100644 index 0000000..9459370 --- /dev/null +++ b/controller/internal/web/r542_registered_drive_test.go @@ -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) + } + } +} diff --git a/controller/internal/web/templates/storage_init.html b/controller/internal/web/templates/storage_init.html index 7ed0a2b..2383322 100644 --- a/controller/internal/web/templates/storage_init.html +++ b/controller/internal/web/templates/storage_init.html @@ -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)||[];