R-280: attach list from mounted-but-unregistered filesystems; two-clicks promise made conditional
gates / gates (push) Successful in 17s
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:
@@ -0,0 +1,163 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path"
|
||||
"sort"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// R-280 — where the `attach` list comes from, and why it is NOT the agent's disk scan.
|
||||
//
|
||||
// After a reinstall the customer's drives survive but their REGISTRATION does not, and every restore
|
||||
// then refuses. The restore page diagnosed that correctly and sent the customer to a page offering
|
||||
// nothing: `GET /disks/candidates` returned `{"initialize":[],"attach":[]}`.
|
||||
//
|
||||
// The agent builds BOTH lists from its unclaimed-DISK scan (felhom-agent
|
||||
// internal/localapi/disks.go handleDiskCandidates → storage.ListCandidateDisks). That filter is
|
||||
// CORRECT for `initialize` — never offer to format a disk in use — and over-broad for `attach`,
|
||||
// which is non-destructive.
|
||||
//
|
||||
// But widening the agent's scan would still not fix it, and that is the part worth writing down:
|
||||
// the filesystem that must be re-registered after a reinstall is an IN-GUEST one. On the rebuilt
|
||||
// demo-hp the drive the customer needed was `/mnt/sys_drive` (the guest's own data volume), and the
|
||||
// escape hatch that unblocked everything registered exactly that path. The agent's host-disk scan
|
||||
// cannot see it — it enumerates HOST block devices, and would have offered the 1 TB NVMe (the
|
||||
// felhom-backup target) instead: the wrong drive, non-destructively attached, and the customer's
|
||||
// data still not reachable.
|
||||
//
|
||||
// So the attach source is the controller's OWN mount table. The controller runs in-guest with /mnt
|
||||
// bind-mounted in, so the filesystems it can see ARE the ones it can register — the source and the
|
||||
// action finally agree.
|
||||
//
|
||||
// These candidates are ALREADY MOUNTED, so the action is REGISTER, never mount-a-device. That is why
|
||||
// they carry AlreadyMounted: the wizard must not send them down the device-attach path, which would
|
||||
// try to mount an in-guest path as if it were a raw device.
|
||||
|
||||
// mountedFSTypes are the on-disk filesystems a mounted store may carry. Deliberately the same pair
|
||||
// the agent calls attach-mountable (storage.mountableFSTypes) and the init flow offers
|
||||
// (validFSTypes) — a third dialect here is how the three lists drift apart.
|
||||
var mountedFSTypes = map[string]bool{"ext4": true, "xfs": true}
|
||||
|
||||
// managedDrivesParent is the intermediary-model PARENT. Children of it are real drives; the parent
|
||||
// itself is the container that holds them and is never a storage destination of its own.
|
||||
const managedDrivesParent = "/mnt/felhom-drives"
|
||||
|
||||
// mountedStore is one already-mounted filesystem the controller can register as a storage location.
|
||||
type mountedStore struct {
|
||||
Path string // the in-guest mountpoint, e.g. /mnt/sys_drive — what gets registered, verbatim
|
||||
Device string // backing device, for display only ("/dev/mapper/pve-vm--9201--disk--1")
|
||||
FSType string
|
||||
}
|
||||
|
||||
// parseMountTable turns mount-table text (/proc/mounts format) into (device, mountpoint, fstype)
|
||||
// rows. Space escaping (\040) is undone so a path with a space still compares — the same handling
|
||||
// the agent's own procMounts does.
|
||||
func parseMountTable(text string) [][3]string {
|
||||
var out [][3]string
|
||||
for _, line := range strings.Split(text, "\n") {
|
||||
fields := strings.Fields(line)
|
||||
if len(fields) < 3 {
|
||||
continue
|
||||
}
|
||||
out = append(out, [3]string{
|
||||
fields[0],
|
||||
strings.ReplaceAll(fields[1], `\040`, " "),
|
||||
fields[2],
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// mountedUnregisteredStores returns the mounted filesystems under /mnt that are NOT already in the
|
||||
// storage registry — the drives a customer can attach (register) without anything being erased.
|
||||
//
|
||||
// ⚠ FAIL-SAFE: an unreadable mount table yields an EMPTY list, never a permissive one. "We could not
|
||||
// look" must never render as "here is what you may attach" — and, because the caller gates the
|
||||
// „two kattintás" sentence on this list being non-empty, an empty list makes the page say so plainly
|
||||
// rather than promise a click that does not exist.
|
||||
func mountedUnregisteredStores(mountsText string, registered map[string]bool) []mountedStore {
|
||||
rows := parseMountTable(mountsText)
|
||||
|
||||
// Devices that back the box's OWN root. A bind mount republishes a filesystem under a second
|
||||
// path, and a bind of the rootfs at /mnt/<name> looks exactly like a data drive to everything
|
||||
// below — offering it would invite the customer to store app data on the root filesystem and
|
||||
// fill it. Excluded by DEVICE, so no alias can smuggle it back in under a different path.
|
||||
rootDevices := map[string]bool{}
|
||||
for _, row := range rows {
|
||||
if w := path.Clean(row[1]); w == "/" || w == "/mnt" {
|
||||
rootDevices[row[0]] = true
|
||||
}
|
||||
}
|
||||
|
||||
var out []mountedStore
|
||||
seen := map[string]bool{}
|
||||
for _, row := range rows {
|
||||
dev, where, fstype := row[0], path.Clean(row[1]), row[2]
|
||||
|
||||
if rootDevices[dev] {
|
||||
continue
|
||||
}
|
||||
|
||||
// A real block device only. Excludes overlay/tmpfs/proc/sysfs and every virtual mount.
|
||||
if !strings.HasPrefix(dev, "/dev/") {
|
||||
continue
|
||||
}
|
||||
// An on-disk filesystem we can actually hand to apps.
|
||||
if !mountedFSTypes[fstype] {
|
||||
continue
|
||||
}
|
||||
// The storage convention is /mnt/<name>. `/mnt` itself is the guest rootfs mount, not a drive.
|
||||
if !strings.HasPrefix(where, "/mnt/") || where == "/mnt" {
|
||||
continue
|
||||
}
|
||||
// The intermediary-model parent holds drives; it is not one.
|
||||
if where == managedDrivesParent {
|
||||
continue
|
||||
}
|
||||
// Already registered → not a candidate. This is what makes a healthy box render exactly as
|
||||
// before: its store is registered, so it never appears here (Scenario D).
|
||||
if registered[where] {
|
||||
continue
|
||||
}
|
||||
// /proc/mounts lists a mountpoint once per mount event; a bind or a re-mount would otherwise
|
||||
// produce the same path twice in the picker.
|
||||
if seen[where] {
|
||||
continue
|
||||
}
|
||||
seen[where] = true
|
||||
out = append(out, mountedStore{Path: where, Device: dev, FSType: fstype})
|
||||
}
|
||||
// Deterministic order — the picker must not reshuffle between reloads.
|
||||
sort.Slice(out, func(i, j int) bool { return out[i].Path < out[j].Path })
|
||||
return out
|
||||
}
|
||||
|
||||
// readMountTable reads the controller's own mount table. Returns "" on failure, which
|
||||
// mountedUnregisteredStores turns into an empty (never permissive) list.
|
||||
func readMountTable() string {
|
||||
b, err := os.ReadFile("/proc/mounts")
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return string(b)
|
||||
}
|
||||
|
||||
// registeredStoragePaths is the set of paths already in the registry, for the exclusion above.
|
||||
func (s *Server) registeredStoragePaths() map[string]bool {
|
||||
out := map[string]bool{}
|
||||
if s.settings == nil {
|
||||
return out
|
||||
}
|
||||
for _, sp := range s.settings.GetStoragePaths() {
|
||||
out[path.Clean(sp.Path)] = true
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// attachableStores is the one derivation both the candidates endpoint and the restore page's
|
||||
// precondition read, so the picker and the sentence pointing at it cannot disagree — the same
|
||||
// single-derivation rule R-252 applied to HasRestoreDestination.
|
||||
func (s *Server) attachableStores() []mountedStore {
|
||||
return mountedUnregisteredStores(readMountTable(), s.registeredStoragePaths())
|
||||
}
|
||||
Reference in New Issue
Block a user