package storage import ( "context" "io" "log/slog" "testing" "gitea.dooplex.hu/admin/felhom-agent/internal/hub" "gitea.dooplex.hu/admin/felhom-agent/internal/proxmox" ) func quietLogger() *slog.Logger { return slog.New(slog.NewTextHandler(io.Discard, nil)) } // fakeStorageAPI serves fixed cluster + node storage lists. type fakeStorageAPI struct { node string cluster []proxmox.Storage nodeSt []proxmox.Storage listErr error nodeErr error } func (f *fakeStorageAPI) Node() string { return f.node } func (f *fakeStorageAPI) ListStorage(context.Context) ([]proxmox.Storage, error) { return f.cluster, f.listErr } func (f *fakeStorageAPI) NodeStorage(context.Context) ([]proxmox.Storage, error) { return f.nodeSt, f.nodeErr } // fakeHostReader is a fully synthetic HostReader — no real devices touched. type fakeHostReader struct { mounts []Mount mountsErr error uuids map[string]string // device -> uuid exists map[string]bool // device -> present rotational map[string]bool // device -> rotational (presence => known) removable map[string]bool // device -> removable (presence => known) } func (h *fakeHostReader) Mounts() ([]Mount, error) { return h.mounts, h.mountsErr } func (h *fakeHostReader) ResolveUUID(device string) (string, bool) { u, ok := h.uuids[device] return u, ok } func (h *fakeHostReader) DeviceExists(device string) bool { return h.exists[device] } func (h *fakeHostReader) Rotational(device string) (bool, bool) { v, ok := h.rotational[device] return v, ok } func (h *fakeHostReader) Removable(device string) (bool, bool) { v, ok := h.removable[device] return v, ok } // byName indexes observed targets for assertions. func byName(targets []hub.StorageTarget) map[string]hub.StorageTarget { m := make(map[string]hub.StorageTarget, len(targets)) for _, t := range targets { m[t.Name] = t } return m } func TestObserve_BuildsTargetsFromProxmoxAndHostReads(t *testing.T) { api := &fakeStorageAPI{ node: "demo-felhom", cluster: []proxmox.Storage{ {Storage: "local", Type: "dir", Content: "vztmpl,backup", Path: "/var/lib/vz"}, {Storage: "local-lvm", Type: "lvmthin", Content: "rootdir,images", VGName: "pve", ThinPool: "data"}, {Storage: "usb-backup", Type: "dir", Content: "backup", Path: "/mnt/usb-backup"}, {Storage: "nfs-arch", Type: "nfs", Content: "backup", Server: "10.0.0.5", Export: "/export/backups"}, }, nodeSt: []proxmox.Storage{ {Storage: "local", Type: "dir", Content: "vztmpl,backup", Path: "/var/lib/vz", Total: 100, Used: 20, Avail: 80, Active: 1, UsedFraction: 0.2}, {Storage: "local-lvm", Type: "lvmthin", Content: "rootdir,images", Total: 1000, Used: 900, Avail: 100, Active: 1, UsedFraction: 0.9}, {Storage: "usb-backup", Type: "dir", Content: "backup", Path: "/mnt/usb-backup", Total: 2000, Used: 500, Avail: 1500, Active: 1, UsedFraction: 0.25}, {Storage: "nfs-arch", Type: "nfs", Content: "backup", Total: 5000, Used: 1000, Avail: 4000, Active: 1, UsedFraction: 0.2}, }, } host := &fakeHostReader{ mounts: []Mount{ {Device: "/dev/mapper/pve-root", MountPoint: "/", FSType: "ext4"}, {Device: "/dev/sdb1", MountPoint: "/mnt/usb-backup", FSType: "ext4"}, }, uuids: map[string]string{"/dev/sdb1": "1111-2222"}, exists: map[string]bool{"/dev/sdb1": true, "/dev/mapper/pve-root": true}, rotational: map[string]bool{"/dev/sdb1": true, "/dev/mapper/pve-root": false}, removable: map[string]bool{"/dev/sdb1": true, "/dev/mapper/pve-root": false}, } got, err := NewObserver(api, host, quietLogger()).Observe(context.Background()) if err != nil { t.Fatalf("Observe: %v", err) } if len(got) != 4 { t.Fatalf("got %d targets, want 4", len(got)) } m := byName(got) // builtin local: dir within root → type "local", attached via active flag. if local := m["local"]; local.Type != hub.StorageTypeLocal || local.State != hub.StorageStateAttached { t.Errorf("local = %+v, want type=local state=attached", local) } // lvmthin: thin-pool DATA fill surfaced; durable_id = vg/pool; no mount/device. lvm := m["local-lvm"] if lvm.Type != hub.StorageTypeLVMThin || lvm.DurableID != "pve/data" { t.Errorf("local-lvm type/durable = %q/%q", lvm.Type, lvm.DurableID) } if lvm.ThinPool == nil || lvm.ThinPool.DataUsedFraction != 0.9 { t.Errorf("local-lvm thin_pool = %+v, want data_used_fraction=0.9", lvm.ThinPool) } if lvm.ThinPool.MetadataUsedFraction != nil { t.Errorf("metadata fill must be nil in Phase A (lvs is Phase B)") } // usb: removable dir, mounted → type usb, durable_id from UUID, class_hint slow (rotational). usb := m["usb-backup"] if usb.Type != hub.StorageTypeUSB { t.Errorf("usb-backup type = %q, want usb", usb.Type) } if usb.DurableID != "uuid:1111-2222" { t.Errorf("usb-backup durable_id = %q, want uuid:1111-2222", usb.DurableID) } if usb.ClassHint != "slow" { t.Errorf("usb-backup class_hint = %q, want slow (rotational)", usb.ClassHint) } if usb.MountPath != "/mnt/usb-backup" || usb.BackingDevice != "/dev/sdb1" { t.Errorf("usb-backup mount/device = %q/%q", usb.MountPath, usb.BackingDevice) } if usb.State != hub.StorageStateAttached || !usb.Reachable { t.Errorf("usb-backup should be attached+reachable: %+v", usb) } if usb.ThinPool != nil { t.Errorf("non-lvmthin must omit thin_pool") } // nfs: durable_id = server:export; attached via active flag; no class hint. nfs := m["nfs-arch"] if nfs.Type != hub.StorageTypeNFS || nfs.DurableID != "10.0.0.5:/export/backups" { t.Errorf("nfs-arch type/durable = %q/%q", nfs.Type, nfs.DurableID) } if nfs.ClassHint != "" { t.Errorf("network target must have no class hint, got %q", nfs.ClassHint) } // SMART is UNKNOWN in Phase A for every target. for _, tgt := range got { if tgt.Smart.Health != hub.SmartUnknown { t.Errorf("%s smart health = %q, want UNKNOWN in Phase A", tgt.Name, tgt.Smart.Health) } } } func TestObserve_USBUnpluggedIsDisconnected(t *testing.T) { api := &fakeStorageAPI{ node: "demo-felhom", cluster: []proxmox.Storage{ {Storage: "usb-backup", Type: "dir", Content: "backup", Path: "/mnt/usb-backup"}, }, nodeSt: []proxmox.Storage{ // PVE may still show the storage entry (active possibly 1) but the mount is gone. {Storage: "usb-backup", Type: "dir", Content: "backup", Path: "/mnt/usb-backup", Active: 1}, }, } host := &fakeHostReader{ mounts: []Mount{{Device: "/dev/mapper/pve-root", MountPoint: "/", FSType: "ext4"}}, // no /mnt/usb-backup } got, err := NewObserver(api, host, quietLogger()).Observe(context.Background()) if err != nil { t.Fatal(err) } usb := byName(got)["usb-backup"] // Without its own mount, a USB target is unplugged regardless of PVE's stale active flag. if usb.State != hub.StorageStateDisconnected || usb.Reachable { t.Errorf("unplugged usb should be disconnected/unreachable, got state=%q reachable=%v", usb.State, usb.Reachable) } // Its durable_id falls back to a stable form (no UUID resolvable while detached). if usb.DurableID == "" { t.Errorf("durable_id must never be empty (DR re-attach lookup)") } } func TestObserve_ProxmoxErrorIsFatalForStorage(t *testing.T) { api := &fakeStorageAPI{node: "n", listErr: context.DeadlineExceeded} if _, err := NewObserver(api, &fakeHostReader{}, quietLogger()).Observe(context.Background()); err == nil { t.Fatal("a Proxmox read error must surface (the collector then omits storage this cycle)") } } func TestObserve_MountReadFailureDegradesNotFatal(t *testing.T) { api := &fakeStorageAPI{ node: "n", cluster: []proxmox.Storage{{Storage: "local-lvm", Type: "lvmthin", VGName: "pve", ThinPool: "data"}}, nodeSt: []proxmox.Storage{{Storage: "local-lvm", Type: "lvmthin", Active: 1, UsedFraction: 0.1}}, } host := &fakeHostReader{mountsErr: io.ErrUnexpectedEOF} got, err := NewObserver(api, host, quietLogger()).Observe(context.Background()) if err != nil { t.Fatalf("a host mount-read failure must degrade, not fail: %v", err) } if len(got) != 1 || got[0].DurableID != "pve/data" { t.Errorf("lvmthin still derivable without mounts: %+v", got) } }