v0.5.0-rc1: slice 5 Phase A — storage observe/report + watchdog (read-only, live)
Fill the slice-3 storage_targets stub and add the fast-poll storage watchdog. Read-only this phase; the host-root surface (mounts/SMART/grow/destructive gate) is Phase B. Hub-owned desired manifest is slice 10, so reconcile against it is built-but-unfed. - internal/storage: StorageTarget wire contract, durable_id derivation per type, HostReader seam (procfs/sysfs, root-free), Observer (storage_targets from ListStorage/NodeStorage + host reads, lvmthin thin-pool fill), and the watchdog (third daemon goroutine; debounced out-of-band report on a known target's attach/disconnect transition). - proxmox.Storage: additive parse-only config fields (durable_id sources). - collector StorageObserver seam; Loop.SetTrigger out-of-band report; daemon runs the watchdog as a third goroutine; StorageConfig knobs. - cross-repo golden kept byte-identical with felhom.eu/hub; bidirectional key-set test. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,209 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user