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/ 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/. `/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()) }