a43d0423d5
gates / gates (push) Successful in 46s
felhom.eu architecture/16 §4-§6 (build B). MinAgent 0.157.0 — the internal place and the partitions appear only when agent >= 0.158.0 reports them. Tests red-proved; four parity fixtures refreshed (only the new lines). Claude-Session: https://claude.ai/code/session_012qRErfCoiTkvDK9N5XHbzb
208 lines
8.5 KiB
Go
208 lines
8.5 KiB
Go
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)
|
|
}
|
|
|
|
// mountTable is readMountTable through the mountsFn test seam (nil → the controller's real /proc/mounts).
|
|
func (s *Server) mountTable() string {
|
|
if s.mountsFn != nil {
|
|
return s.mountsFn()
|
|
}
|
|
return readMountTable()
|
|
}
|
|
|
|
// 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 s.attachableStoresFrom(readMountTable())
|
|
}
|
|
|
|
// attachableStoresFrom is attachableStores for a given mount table (the seam its tests use).
|
|
func (s *Server) attachableStoresFrom(mounts string) []mountedStore {
|
|
sdp := ""
|
|
if s.cfg != nil {
|
|
sdp = s.cfg.Paths.SystemDataPath
|
|
}
|
|
return withoutSystemVolume(mountedUnregisteredStores(mounts, s.registeredStoragePaths()), mounts, sdp)
|
|
}
|
|
|
|
// withoutSystemVolume drops every offered store that lives on the guest's OWN data volume (R-1082, operator ruling
|
|
// 2026-10-16 „hide it now"; felhom.eu architecture/16 §11): /mnt/sys_drive is a folder of that volume, which rides in
|
|
// every whole-box copy, local and off-site — films put there would fill them. Excluded by DEVICE, like the rootfs
|
|
// guard above, so a second bind of the same volume under another /mnt/<name> is hidden too. A box that already
|
|
// REGISTERED sys_drive keeps it: this filters only what is OFFERED. systemDataPath "" falls back to the config
|
|
// default /mnt/sys_drive (fail toward hiding). Pinned by r1082_sysdrive_hidden_test.go.
|
|
func withoutSystemVolume(stores []mountedStore, mountsText, systemDataPath string) []mountedStore {
|
|
if systemDataPath == "" {
|
|
systemDataPath = "/mnt/sys_drive"
|
|
}
|
|
volumePaths := map[string]bool{path.Clean(systemDataPath): true, "/var/lib/felhom": true, "/var/lib/docker": true}
|
|
volumeDevices := map[string]bool{}
|
|
for _, row := range parseMountTable(mountsText) {
|
|
if volumePaths[path.Clean(row[1])] {
|
|
volumeDevices[row[0]] = true
|
|
}
|
|
}
|
|
out := stores[:0:0]
|
|
for _, m := range stores {
|
|
if volumeDevices[m.Device] || volumePaths[path.Clean(m.Path)] {
|
|
continue
|
|
}
|
|
out = append(out, m)
|
|
}
|
|
return out
|
|
}
|