Files
felhom-controller/controller/internal/web/attach_sources.go
T
admin a43d0423d5
gates / gates (push) Successful in 46s
v0.318.0: the internal data place, partitions, an honest whole-box backup offer (R-1084), sys_drive never offered (R-1082)
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
2026-10-16 03:57:49 +02:00

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
}