bf8e3be3f4
HostReport.LeafFingerprint rides the served fp (from EnsureLeaf) on every report; empty when local API disabled. Collector.SetLeafFingerprint threads it like Capabilities. Hub watches it for a re-key. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Pg8ANF97SEeKYSN5Jxw3qJ
173 lines
6.1 KiB
Go
173 lines
6.1 KiB
Go
package hub
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"testing"
|
|
|
|
"gitea.dooplex.hu/admin/felhom-agent/internal/proxmox"
|
|
)
|
|
|
|
func newTestNodeStatus() proxmox.NodeStatus {
|
|
var ns proxmox.NodeStatus
|
|
ns.CPU = 0.05 // → 5%
|
|
ns.Uptime = 86400
|
|
ns.LoadAvg = []string{"0.10", "0.20", "0.15"}
|
|
ns.Memory.Total = 16000000000
|
|
ns.Memory.Used = 4000000000
|
|
ns.RootFS.Total = 100000000000
|
|
ns.RootFS.Used = 20000000000
|
|
return ns
|
|
}
|
|
|
|
// fakeObserver is a StorageObserver returning fixed targets (or an error).
|
|
type fakeObserver struct {
|
|
targets []StorageTarget
|
|
err error
|
|
}
|
|
|
|
func (f fakeObserver) Observe(context.Context) ([]StorageTarget, error) { return f.targets, f.err }
|
|
|
|
func TestCollect_StorageTargetsFromObserver(t *testing.T) {
|
|
px := &fakePx{node: "n", ns: newTestNodeStatus()}
|
|
obs := fakeObserver{targets: []StorageTarget{
|
|
{Name: "local-lvm", Type: StorageTypeLVMThin, State: StorageStateAttached, Reachable: true},
|
|
}}
|
|
c := NewCollector(px, fakeProber{status: "active"}, obs, nil, nil, nil, "h", "0.5.0", quietLogger())
|
|
r, err := c.Collect(context.Background())
|
|
if err != nil {
|
|
t.Fatalf("Collect: %v", err)
|
|
}
|
|
if len(r.StorageTargets) != 1 || r.StorageTargets[0].Name != "local-lvm" {
|
|
t.Fatalf("storage targets = %+v", r.StorageTargets)
|
|
}
|
|
}
|
|
|
|
func TestCollect_StorageObserverErrorDegradesToEmpty(t *testing.T) {
|
|
px := &fakePx{node: "n", ns: newTestNodeStatus()}
|
|
c := NewCollector(px, fakeProber{status: "active"}, fakeObserver{err: errors.New("proxmox down")}, nil, nil, nil, "h", "0.5.0", quietLogger())
|
|
r, err := c.Collect(context.Background())
|
|
if err != nil {
|
|
t.Fatalf("a storage observe error must not sink the heartbeat: %v", err)
|
|
}
|
|
if r.StorageTargets == nil || len(r.StorageTargets) != 0 {
|
|
t.Errorf("storage targets must degrade to empty non-nil, got %+v", r.StorageTargets)
|
|
}
|
|
}
|
|
|
|
func TestCollect_HostAndGuests(t *testing.T) {
|
|
px := &fakePx{
|
|
node: "demo-felhom",
|
|
ns: newTestNodeStatus(),
|
|
lxc: []proxmox.Guest{
|
|
{VMID: 100, Name: "acme", Status: "running", MaxMem: 2147483648, MaxDisk: 21474836480},
|
|
},
|
|
cfg: map[int]proxmox.GuestConfig{100: {Cores: 2, Memory: 2048}},
|
|
}
|
|
c := NewCollector(px, fakeProber{status: "active"}, nil, nil, nil, nil, "demo-host-01", "0.3.0", quietLogger())
|
|
r, err := c.Collect(context.Background())
|
|
if err != nil {
|
|
t.Fatalf("Collect: %v", err)
|
|
}
|
|
if r.HostID != "demo-host-01" || r.AgentVersion != "0.3.0" {
|
|
t.Errorf("top-level wrong: %+v", r)
|
|
}
|
|
if r.Host.Node != "demo-felhom" || r.Host.CPUPercent != 5 {
|
|
t.Errorf("host = %+v", r.Host)
|
|
}
|
|
if r.Host.MemoryPercent != 25 || r.Host.DiskPercent != 20 {
|
|
t.Errorf("percents = mem %v disk %v", r.Host.MemoryPercent, r.Host.DiskPercent)
|
|
}
|
|
if len(r.Guests) != 1 {
|
|
t.Fatalf("guests = %d", len(r.Guests))
|
|
}
|
|
g := r.Guests[0]
|
|
if g.VMID != 100 || g.Status != "running" || g.Spec == nil {
|
|
t.Fatalf("guest = %+v", g)
|
|
}
|
|
if g.Spec.Cores != 2 || g.Spec.MemoryBytes != 2147483648 || g.Spec.DiskBytes != 21474836480 {
|
|
t.Errorf("spec = %+v", g.Spec)
|
|
}
|
|
if r.Cloudflared.Status != "active" {
|
|
t.Errorf("cloudflared = %q", r.Cloudflared.Status)
|
|
}
|
|
}
|
|
|
|
func TestCollect_GuestConfigFailureKeepsStatusOmitsSpec(t *testing.T) {
|
|
px := &fakePx{
|
|
node: "demo-felhom",
|
|
ns: newTestNodeStatus(),
|
|
lxc: []proxmox.Guest{
|
|
{VMID: 100, Name: "ok", Status: "running", MaxMem: 1 << 31, MaxDisk: 1 << 34},
|
|
{VMID: 200, Name: "bad", Status: "running"},
|
|
},
|
|
cfg: map[int]proxmox.GuestConfig{100: {Cores: 2}},
|
|
cfgErr: map[int]error{200: errors.New("config read failed")},
|
|
}
|
|
c := NewCollector(px, fakeProber{status: "active"}, nil, nil, nil, nil, "h", "0.3.1", quietLogger())
|
|
r, err := c.Collect(context.Background())
|
|
if err != nil {
|
|
t.Fatalf("a per-guest failure must NOT fail the whole report: %v", err)
|
|
}
|
|
if len(r.Guests) != 2 {
|
|
t.Fatalf("guests = %d", len(r.Guests))
|
|
}
|
|
// GuestConfig failed for vmid 200, but its run-status (from ListLXC) is known and
|
|
// must be PRESERVED — only the spec is dropped.
|
|
bad := r.Guests[1]
|
|
if bad.Status != "running" {
|
|
t.Errorf("status = %q, want preserved \"running\" (not forced to unknown)", bad.Status)
|
|
}
|
|
if bad.Spec != nil {
|
|
t.Errorf("spec = %+v, want nil (omitted on config failure)", bad.Spec)
|
|
}
|
|
}
|
|
|
|
func TestCollect_NodeStatusFailureIsHardError(t *testing.T) {
|
|
px := &fakePx{node: "n", nsErr: errors.New("proxmox down")}
|
|
c := NewCollector(px, fakeProber{status: "active"}, nil, nil, nil, nil, "h", "0.3.0", quietLogger())
|
|
if _, err := c.Collect(context.Background()); err == nil {
|
|
t.Fatal("NodeStatus failure must be a hard error (no useful report)")
|
|
}
|
|
}
|
|
|
|
func TestCollect_CloudflaredProbeErrorIsUnknown(t *testing.T) {
|
|
px := &fakePx{node: "n", ns: newTestNodeStatus()}
|
|
c := NewCollector(px, fakeProber{err: errors.New("no systemctl")}, nil, nil, nil, nil, "h", "0.3.0", quietLogger())
|
|
r, err := c.Collect(context.Background())
|
|
if err != nil {
|
|
t.Fatalf("cloudflared failure must not be fatal: %v", err)
|
|
}
|
|
if r.Cloudflared.Status != "unknown" {
|
|
t.Errorf("cloudflared = %q, want unknown", r.Cloudflared.Status)
|
|
}
|
|
// Empty collections still present as non-nil.
|
|
if r.Guests == nil || r.StorageTargets == nil || r.AuditTail == nil {
|
|
t.Error("empty collections must be non-nil")
|
|
}
|
|
}
|
|
|
|
// Part A: the served leaf fp rides the report when set (v0.48.0); empty when the local API is disabled
|
|
// (no SetLeafFingerprint). Companion: the unset case proves the threading is what populates it.
|
|
func TestCollect_LeafFingerprint(t *testing.T) {
|
|
px := &fakePx{node: "n", ns: newTestNodeStatus()}
|
|
const fp = "60b5974d586f5f3c8ec41eb998d0f07406178219c36bf6d3ff377570279d8245"
|
|
|
|
c := NewCollector(px, fakeProber{status: "active"}, nil, nil, nil, nil, "h", "0.48.0", quietLogger())
|
|
c.SetLeafFingerprint(fp)
|
|
r, err := c.Collect(context.Background())
|
|
if err != nil {
|
|
t.Fatalf("Collect: %v", err)
|
|
}
|
|
if r.LeafFingerprint != fp {
|
|
t.Fatalf("leaf_fingerprint = %q, want %q", r.LeafFingerprint, fp)
|
|
}
|
|
|
|
// Companion: no SetLeafFingerprint (local API disabled) → empty, never a fabricated value.
|
|
c2 := NewCollector(px, fakeProber{status: "active"}, nil, nil, nil, nil, "h", "0.48.0", quietLogger())
|
|
r2, _ := c2.Collect(context.Background())
|
|
if r2.LeafFingerprint != "" {
|
|
t.Fatalf("unset leaf_fingerprint = %q, want empty", r2.LeafFingerprint)
|
|
}
|
|
}
|