R-280: attach list from mounted-but-unregistered filesystems; two-clicks promise made conditional
gates / gates (push) Successful in 17s

After a reinstall the data drive could not be re-attached through any dashboard
route: both candidate lists came from the agent's unclaimed-disk scan, and the
rebuilt box's drives are claimed. The restore page said it was two clicks while
pointing at an empty picker.

The attach list now also carries the controller's own mounted-but-unregistered
filesystems. initialize is untouched, so the format wizard's system/backup
protection is unchanged. The 'two clicks' sentence is conditional on the picker
being non-empty, and says something true and actionable when it is not.
This commit is contained in:
2026-08-10 13:41:32 +02:00
parent c732fe1283
commit b762a37097
9 changed files with 600 additions and 12 deletions
+41 -3
View File
@@ -126,8 +126,18 @@ 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 the controller passes the list through
// untouched — no controller-side filtering.
// 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.
//
// 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
// kattintás", because the drive that must be re-registered is an in-guest filesystem no host-disk
// scan can see. Attaching is non-destructive, so this list is additive by nature — it can only ever
// offer MORE places to put data back, never a new way to erase any. Why the union rather than a
// replacement: the agent's entries serve the case this endpoint was built for — a fresh external
// drive that already carries a filesystem and is not yet mounted — which the mount table cannot
// report precisely because it is not mounted. Dropping them would fix the reinstall and break the USB.
func (s *Server) agentDiskCandidatesHandler(w http.ResponseWriter, r *http.Request) {
client, err := s.agentClient()
if err != nil {
@@ -140,7 +150,35 @@ func (s *Server) agentDiskCandidatesHandler(w http.ResponseWriter, r *http.Reque
writeDiskJSON(w, http.StatusBadGateway, false, err.Error(), nil)
return
}
writeDiskJSON(w, http.StatusOK, true, "", resp)
writeDiskJSON(w, http.StatusOK, true, "", mergeAttachCandidates(resp, s.attachableStores()))
}
// 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
// be tested, and a change to it fails a test instead of shipping.
func mergeAttachCandidates(resp agentapi.CandidatesResult, stores []mountedStore) agentapi.CandidatesResult {
resp.Attach = append(resp.Attach, mountedStoreCandidates(stores)...)
return resp
}
// mountedStoreCandidates renders mounted-but-unregistered stores in the picker's shape. MountSource
// carries the mountpoint (the thing the register action needs); Device is display only.
func mountedStoreCandidates(stores []mountedStore) []agentapi.DiskCandidate {
out := make([]agentapi.DiskCandidate, 0, len(stores))
for _, m := range stores {
out = append(out, agentapi.DiskCandidate{
Device: m.Device,
FSType: m.FSType,
MountSource: m.Path,
DataBearing: true,
Mountable: true,
// Size is deliberately absent: measuring it means statfs on a possibly-wedged device
// inside a request handler, and a picker entry is actionable without it.
AlreadyMounted: true,
})
}
return out
}
// sortDisksForView orders the agent's disk list deterministically (user-data → system → backup →