Files
pulse/internal/monitoring/guest_filesystem_alias_usage_test.go
T
pulse-triage[bot] cce0cfd5a7 Check serialized guest filesystem totals and mount rows
Carry the exact test-only assertions from b1309dc3 onto the integrated mount-order repair. Both existing cluster and node fixtures must verify aggregate total, free and used bytes and every serialized per-mount counter and identity. The original runtime repair and completed component/connected proof are preserved, not replayed.

Contract-Neutral: Extend an existing backend regression test only; no production behaviour, public schema, permissions, alert policy or guest commands change.
Change-source: pulse-maintainer
2026-10-10 09:12:58 +01:00

228 lines
11 KiB
Go

package monitoring
import (
"context"
"encoding/json"
"fmt"
"math"
"net/http"
"net/http/httptest"
"reflect"
"strings"
"sync/atomic"
"testing"
"time"
"github.com/rcourtman/pulse-go-rewrite/internal/alerts"
"github.com/rcourtman/pulse-go-rewrite/internal/config"
"github.com/rcourtman/pulse-go-rewrite/internal/models"
"github.com/rcourtman/pulse-go-rewrite/internal/unifiedresources"
"github.com/rcourtman/pulse-go-rewrite/pkg/proxmox"
)
func TestVMFilesystemAliasUsageIsOrderIndependent(t *testing.T) {
for _, identity := range []string{"linux-device", "windows-guid", "windows-path", "btrfs-fallback", "zfs-fallback"} {
t.Run(identity, func(t *testing.T) {
rows := []proxmox.VMFileSystem{
{Mountpoint: "/", Type: "ext4", Disk: "/dev/vda1", TotalBytes: 1000, UsedBytes: 0},
{Mountpoint: "/bind", Type: "ext4", Disk: "/dev/vda1", TotalBytes: 1000, UsedBytes: 100},
{Mountpoint: "/data", Type: "ext4", Disk: "/dev/vda1", TotalBytes: 1000, UsedBytes: 900},
}
for i := range rows {
switch identity {
case "windows-guid":
rows[i].Name, rows[i].Type, rows[i].Disk = windowsVolumeC, "NTFS", windowsPhysicalDrive
rows[i].Mountpoint = []string{"C:\\", "D:\\mount\\", "E:\\mount\\"}[i]
case "windows-path":
rows[i].Type, rows[i].Disk = "NTFS", windowsPhysicalDrive
rows[i].Mountpoint = []string{"C:\\", "c:/", "C:"}[i]
case "btrfs-fallback", "zfs-fallback":
rows[i].Type, rows[i].Disk = strings.TrimSuffix(identity, "-fallback"), ""
}
}
for _, order := range [][3]int{{0, 1, 2}, {0, 2, 1}, {1, 0, 2}, {1, 2, 0}, {2, 0, 1}, {2, 1, 0}} {
t.Run(fmt.Sprint(order), func(t *testing.T) {
fs := []proxmox.VMFileSystem{rows[order[0]], rows[order[1]], rows[order[2]]}
summary := (&Monitor{}).summarizeVMFSInfo("aliases", proxmox.ClusterResource{}, fs)
if summary.totalBytes != 1000 || summary.usedBytes != 900 || summary.invalidBytes || len(summary.individualDisks) != 3 {
t.Fatalf("same volume changed with mount order: total=%d used=%d invalid=%t rows=%d", summary.totalBytes, summary.usedBytes, summary.invalidBytes, len(summary.individualDisks))
}
for i, disk := range summary.individualDisks {
if disk.Total != int64(fs[i].TotalBytes) || disk.Used != int64(fs[i].UsedBytes) || disk.Free != int64(fs[i].TotalBytes-fs[i].UsedBytes) || disk.Mountpoint != fs[i].Mountpoint || disk.Device != fs[i].Disk {
t.Fatal("aggregate maximum replaced an individual mount observation")
}
}
})
}
})
}
}
func TestVMFilesystemAliasUsagePreservesIndependentCapacityAndBounds(t *testing.T) {
row := func(device, mount string, total, used uint64) proxmox.VMFileSystem {
return proxmox.VMFileSystem{Disk: device, Mountpoint: mount, Type: "ext4", TotalBytes: total, UsedBytes: used}
}
for _, tc := range []struct {
name string
rows []proxmox.VMFileSystem
total, used uint64
}{
{"interleaved-volumes", []proxmox.VMFileSystem{row("a", "/a", 1000, 100), row("b", "/b", 2000, 200), row("a", "/bind-a", 1000, 900), row("b", "/bind-b", 2000, 1800)}, 3000, 2700},
{"genuine-zero", []proxmox.VMFileSystem{row("a", "/a", 1000, 0), row("a", "/bind-a", 1000, 0)}, 1000, 0},
{"no-device-ext4", []proxmox.VMFileSystem{row("", "/a", 1000, 100), row("", "/b", 1000, 900)}, 2000, 1000},
{"signed-boundary", []proxmox.VMFileSystem{row("a", "/a", math.MaxInt64, math.MaxInt64-3), row("a", "/bind-a", math.MaxInt64, math.MaxInt64-1)}, math.MaxInt64, math.MaxInt64 - 1},
} {
t.Run(tc.name, func(t *testing.T) {
summary := (&Monitor{}).summarizeVMFSInfo("aliases", proxmox.ClusterResource{}, tc.rows)
if summary.totalBytes != tc.total || summary.usedBytes != tc.used || summary.invalidBytes || len(summary.individualDisks) != len(tc.rows) {
t.Fatalf("capacity/zero/bounds changed: %+v, want %d/%d", summary, tc.total, tc.used)
}
})
}
}
// This is ordinary QEMU-only polling against synthetic endpoints, not a native
// guest probe or a freeze/recovery experiment. The per-mount readings remain
// untouched while overview/served metrics and History stop taking the first
// alias. Both collectors must retain real filesystem alerts and genuine zero.
func TestVMFilesystemAliasUsagePollingAndHistory(t *testing.T) {
for _, collector := range []string{"cluster", "node"} {
t.Run(collector, func(t *testing.T) {
var phase, fsCalls atomic.Int32
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
switch {
case strings.HasSuffix(r.URL.Path, "/config"):
fmt.Fprint(w, `{"data":{}}`)
case strings.HasSuffix(r.URL.Path, "/status/current"):
fmt.Fprint(w, `{"data":{"status":"running","agent":1,"maxmem":1000,"mem":400,"meminfo":{"total":1000,"available":600}}}`)
case strings.HasSuffix(r.URL.Path, "/agent/get-fsinfo"):
fsCalls.Add(1)
row := func(mount string, used int) map[string]any {
return map[string]any{"mountpoint": mount, "type": "ext4", "disk": []any{map[string]any{"dev": "/dev/vda1"}}, "total-bytes": 1000, "used-bytes": used}
}
rows := []any{row("/", 100), row("/bind", 980)}
if phase.Load() == 1 {
rows[0], rows[1] = rows[1], rows[0]
} else if phase.Load() == 2 {
rows = []any{row("/", 0), row("/bind", 0)}
}
_ = json.NewEncoder(w).Encode(map[string]any{"data": map[string]any{"result": rows}})
case strings.Contains(r.URL.Path, "/agent/"):
fmt.Fprint(w, `{"data":{"result":[]}}`)
default:
t.Errorf("unexpected request: %s", r.URL.Path)
http.NotFound(w, r)
}
}))
defer server.Close()
client, err := proxmox.NewClient(proxmox.ClientConfig{Host: server.URL, TokenName: "fixture@pve!pulse", TokenValue: "fixture", Timeout: time.Second})
if err != nil {
t.Fatal(err)
}
m := guestHistoryObservationMonitor(t)
m.alertManager = alerts.NewManagerWithDataDir(t.TempDir(), alerts.WithoutPersistedAlertRestore())
t.Cleanup(m.alertManager.Stop)
cfg := m.alertManager.GetConfig()
cfg.Enabled = true
cfg.GuestDefaults.Disk = &alerts.HysteresisThreshold{Trigger: 90, Clear: 85}
cfg.MetricTimeThresholds = map[string]map[string]int{"guest": {"disk": 0}}
m.alertManager.UpdateConfig(cfg)
m.config, m.state, m.rateTracker = &config.Config{}, models.NewState(), NewRateTracker()
m.guestMetadataLimiter = make(map[string]time.Time)
m.resourceStore = unifiedresources.NewMonitorAdapter(unifiedresources.NewRegistry(nil))
res := proxmox.ClusterResource{Type: "qemu", Node: "node", Name: "guest", VMID: 105, Status: "running", MaxMem: 1000, Mem: 400, MaxDisk: 1000, CPU: .2}
id := makeGuestID("aliases", "node", 105)
var breachID string
var breachStart time.Time
for step, wantUsed := range []int64{980, 980, 0} {
if step > 0 {
// Persisted History has second precision; each is a new poll.
time.Sleep(1100 * time.Millisecond)
}
phase.Store(int32(step))
previous := m.previousGuestContextForInstance("aliases").vmsByID[id]
var vm models.VM
if collector == "node" {
vms, _ := m.pollNodeVMsWithClusterResourceBuilder(context.Background(), "aliases", "node", []proxmox.VM{{VMID: 105, Name: res.Name, Status: res.Status, MaxMem: res.MaxMem, Mem: res.Mem, MaxDisk: res.MaxDisk, CPU: res.CPU}}, client, map[string]models.VM{id: previous}, nil)
if len(vms) != 1 {
t.Fatal("node collector lost guest")
}
vm = vms[0]
} else {
var ok bool
vm, _, _, _, _, ok = m.buildVMFromClusterResource(context.Background(), "aliases", res, client, id, nil, &previous)
if !ok {
t.Fatal("cluster builder lost guest")
}
m.checkGuestAlertsForVM("aliases", vm)
}
if vm.Disk.Total != 1000 || vm.Disk.Used != wantUsed || vm.Disk.Free != 1000-wantUsed || vm.Disk.Usage != float64(wantUsed)/10 || len(vm.Disks) != 2 || vm.DiskStatusReason != "" || vm.DiskObservation.ObservedAt.IsZero() {
t.Fatalf("poll %d published first alias instead of volume usage: %+v", step, vm)
}
mountUsed := map[string]int64{"/": 100, "/bind": 980}
if step == 2 {
mountUsed = map[string]int64{"/": 0, "/bind": 0}
}
for _, disk := range vm.Disks {
if disk.Used != mountUsed[disk.Mountpoint] {
t.Fatal("poll replaced individual mount observations")
}
}
m.state.UpdateVMs([]models.VM{vm})
m.updateResourceStore(m.currentStateWithScope())
m.recordGuestMetrics([]models.VM{vm}, nil, time.Now().Add(-time.Second))
view := m.currentModeReadState().VMs()[0]
if view.DiskTotal() != 1000 || view.DiskUsed() != wantUsed || view.DiskPercent() != vm.Disk.Usage || !reflect.DeepEqual(previousVMFromView(view).Disks, vm.Disks) {
t.Fatal("canonical state lost aggregate or individual mount evidence")
}
front := m.buildBroadcastFrontendStateFromSnapshot(models.StateSnapshot{VMs: []models.VM{vm}}, m.mockModeFence.begin())
wire, err := json.Marshal(front.Resources)
var served []struct {
Disk *models.ResourceMetricFrontend
Proxmox struct{ Disks []unifiedresources.DiskInfo }
}
if err != nil || json.Unmarshal(wire, &served) != nil || len(served) != 1 || served[0].Disk == nil {
t.Fatalf("served guest differs: %s / %v", wire, err)
}
diskMetric := served[0].Disk
if diskMetric.Total == nil || *diskMetric.Total != 1000 || diskMetric.Used == nil || *diskMetric.Used != wantUsed || diskMetric.Free == nil || *diskMetric.Free != 1000-wantUsed || diskMetric.Current != vm.Disk.Usage {
t.Fatalf("served aggregate differs: %+v", diskMetric)
}
if len(served[0].Proxmox.Disks) != len(vm.Disks) {
t.Fatal("served guest lost individual mount rows")
}
for i, disk := range served[0].Proxmox.Disks {
observed := vm.Disks[i]
if disk.Total != observed.Total || disk.Used != observed.Used || disk.Free != observed.Free || disk.Usage != observed.Usage || disk.Mountpoint != observed.Mountpoint || disk.Device != observed.Device || disk.Filesystem != observed.Type {
t.Fatalf("served mount %d differs from its observation: %+v / %+v", i, disk, observed)
}
}
active := m.alertManager.GetActiveAlerts()
if step < 2 {
if len(active) != 1 {
t.Fatalf("high mount lost its independent alert: %+v", active)
}
if step == 0 {
breachID, breachStart = active[0].ID, active[0].StartTime
} else if active[0].ID != breachID || !active[0].StartTime.Equal(breachStart) {
t.Fatal("mount reordering replaced the breach occurrence")
}
} else if len(active) != 0 {
t.Fatal("genuine zero did not clear the filesystem alert")
}
}
memory := m.metricsHistory.GetGuestMetrics(id, "disk", time.Hour)
stored := guestHistoryStoredPoints(t, m, "vm", id, "disk")
if len(memory) != 3 || len(stored) != 3 || fsCalls.Load() != 3 {
t.Fatalf("polling repeated commands or lost History: memory=%d stored=%d commands=%d", len(memory), len(stored), fsCalls.Load())
}
for i, want := range []float64{98, 98, 0} {
if memory[i].Value != want || stored[i].Value != want {
t.Fatalf("History[%d] took arbitrary alias usage: %v/%v, want %v", i, memory[i].Value, stored[i].Value, want)
}
}
})
}
}