Files
pulse/internal/monitoring/monitor_additional_test.go
T
pulse-triage[bot] 277ec2110a Preserve contradictory saved cluster trust through producer and refresh
Carry exact duplicate-row conflicts into owned client trust before map deduplication. Keep unresolved saved trust through initialization, missing-client retry and topology refresh without replacing pins or stopping trusted siblings. Cover actual monitor lifecycle routes and member projections alongside client TLS controls.

Change-source: pulse-maintainer
2026-10-14 14:27:25 +01:00

2023 lines
87 KiB
Go

package monitoring
import (
"context"
"errors"
"net"
"strings"
"sync"
"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"
unifiedresources "github.com/rcourtman/pulse-go-rewrite/internal/unifiedresources"
"github.com/rcourtman/pulse-go-rewrite/pkg/proxmox"
)
type fakeDockerChecker struct{}
func (f *fakeDockerChecker) CheckDockerInContainer(ctx context.Context, node string, vmid int) (bool, error) {
return false, nil
}
func TestMonitorGetConfig(t *testing.T) {
cfg := &config.Config{DataPath: "/tmp/pulse-test"}
monitor := &Monitor{config: cfg}
if got := monitor.GetConfig(); got != cfg {
t.Fatalf("GetConfig = %v, want %v", got, cfg)
}
}
func TestMonitorSetGetDockerChecker(t *testing.T) {
monitor := &Monitor{}
checker := &fakeDockerChecker{}
monitor.SetDockerChecker(checker)
if got := monitor.GetDockerChecker(); got != checker {
t.Fatalf("GetDockerChecker = %v, want %v", got, checker)
}
monitor.SetDockerChecker(nil)
if got := monitor.GetDockerChecker(); got != nil {
t.Fatalf("GetDockerChecker = %v, want nil", got)
}
}
func TestMonitorGetDockerHosts(t *testing.T) {
monitor := &Monitor{state: models.NewState()}
monitor.state.UpsertDockerHost(models.DockerHost{ID: "host-1", Hostname: "host-1"})
hosts := monitor.GetDockerHosts()
if len(hosts) != 1 {
t.Fatalf("GetDockerHosts length = %d, want 1", len(hosts))
}
if hosts[0].ID != "host-1" {
t.Fatalf("GetDockerHosts[0].ID = %q, want %q", hosts[0].ID, "host-1")
}
}
func TestMonitorGetDockerHostsNilReceiver(t *testing.T) {
var monitor *Monitor
if got := monitor.GetDockerHosts(); got != nil {
t.Fatalf("GetDockerHosts = %v, want nil", got)
}
}
func TestMonitorLinkHostAgent(t *testing.T) {
monitor := &Monitor{state: models.NewState(), hostContinuityStore: config.NewHostContinuityStore(t.TempDir(), nil)}
if err := monitor.LinkHostAgent("", "node-1"); err == nil {
t.Fatalf("expected error on empty host ID")
}
if err := monitor.LinkHostAgent("host-1", ""); err == nil {
t.Fatalf("expected error on empty node ID")
}
monitor.state.UpsertHost(models.Host{ID: "host-1", Hostname: "host-1"})
monitor.state.UpdateNodes([]models.Node{{ID: "node-1", Name: "node-1"}})
if err := monitor.LinkHostAgent("host-1", "node-1"); err != nil {
t.Fatalf("LinkHostAgent error: %v", err)
}
hosts := monitor.state.GetHosts()
if len(hosts) != 1 || hosts[0].LinkedNodeID != "node-1" {
t.Fatalf("LinkedNodeID = %q, want %q", hosts[0].LinkedNodeID, "node-1")
}
if len(monitor.state.Nodes) != 1 || monitor.state.Nodes[0].LinkedAgentID != "host-1" {
t.Fatalf("LinkedAgentID = %q, want %q", monitor.state.Nodes[0].LinkedAgentID, "host-1")
}
}
func TestMonitorInvalidateAgentProfileCache(t *testing.T) {
monitor := &Monitor{
agentProfileCache: &agentProfileCacheEntry{
profiles: []models.AgentProfile{{ID: "profile-1"}},
loadedAt: time.Now(),
},
}
monitor.InvalidateAgentProfileCache()
if monitor.agentProfileCache != nil {
t.Fatalf("expected cache to be cleared")
}
}
func TestMonitorMarkDockerHostPendingUninstall(t *testing.T) {
monitor := &Monitor{state: models.NewState()}
if _, err := monitor.MarkDockerHostPendingUninstall(""); err == nil {
t.Fatalf("expected error on empty host ID")
}
if _, err := monitor.MarkDockerHostPendingUninstall("missing"); err == nil {
t.Fatalf("expected error on missing host")
}
monitor.state.UpsertDockerHost(models.DockerHost{ID: "host-1", Hostname: "host-1"})
host, err := monitor.MarkDockerHostPendingUninstall("host-1")
if err != nil {
t.Fatalf("MarkDockerHostPendingUninstall error: %v", err)
}
if !host.PendingUninstall {
t.Fatalf("expected PendingUninstall to be true")
}
hosts := monitor.state.GetDockerHosts()
if len(hosts) != 1 || !hosts[0].PendingUninstall {
t.Fatalf("state PendingUninstall = %v, want true", hosts[0].PendingUninstall)
}
}
func wireUnifiedDockerHostForMonitor(m *Monitor, host models.DockerHost) string {
registry := unifiedresources.NewRegistry(nil)
registry.IngestSnapshot(models.StateSnapshot{
DockerHosts: []models.DockerHost{host},
})
adapter := unifiedresources.NewMonitorAdapter(registry)
m.resourceStore = adapter
readState := unifiedresources.ReadState(adapter)
return readState.DockerHosts()[0].ID()
}
func TestMonitorDockerRuntimeActionsAcceptUnifiedID(t *testing.T) {
monitor := &Monitor{
state: models.NewState(),
removedDockerHosts: make(map[string]time.Time),
dockerCommands: make(map[string]*dockerHostCommand),
dockerCommandIndex: make(map[string]string),
dockerMetadataStore: config.NewDockerMetadataStore(t.TempDir(), nil),
}
host := models.DockerHost{ID: "host-1", Hostname: "host-1", DisplayName: "Host 1", Status: "online"}
monitor.state.UpsertDockerHost(host)
unifiedID := wireUnifiedDockerHostForMonitor(monitor, host)
got, found := monitor.GetDockerHost(unifiedID)
if !found || got.ID != host.ID {
t.Fatalf("GetDockerHost(%q) = (%+v, %v), want raw host id %q", unifiedID, got, found, host.ID)
}
updated, err := monitor.SetDockerHostCustomDisplayName(unifiedID, "Unified Name")
if err != nil {
t.Fatalf("SetDockerHostCustomDisplayName with unified id: %v", err)
}
if updated.CustomDisplayName != "Unified Name" {
t.Fatalf("expected custom display name to update, got %q", updated.CustomDisplayName)
}
meta := monitor.dockerMetadataStore.GetHostMetadata(host.ID)
if meta == nil || meta.CustomDisplayName != "Unified Name" {
t.Fatalf("expected metadata keyed by raw host id, got %#v", meta)
}
hidden, err := monitor.HideDockerHost(unifiedID)
if err != nil {
t.Fatalf("HideDockerHost with unified id: %v", err)
}
if !hidden.Hidden {
t.Fatal("expected hidden flag to be set")
}
visible, err := monitor.UnhideDockerHost(unifiedID)
if err != nil {
t.Fatalf("UnhideDockerHost with unified id: %v", err)
}
if visible.Hidden {
t.Fatal("expected hidden flag to be cleared")
}
pending, err := monitor.MarkDockerHostPendingUninstall(unifiedID)
if err != nil {
t.Fatalf("MarkDockerHostPendingUninstall with unified id: %v", err)
}
if !pending.PendingUninstall {
t.Fatal("expected pending uninstall flag to be set")
}
removed, err := monitor.RemoveDockerHost(unifiedID)
if err != nil {
t.Fatalf("RemoveDockerHost with unified id: %v", err)
}
if removed.ID != host.ID {
t.Fatalf("expected removed host id %q, got %q", host.ID, removed.ID)
}
if hosts := monitor.state.GetDockerHosts(); len(hosts) != 0 {
t.Fatalf("expected host to be removed from state, got %d hosts", len(hosts))
}
if _, exists := monitor.removedDockerHosts[host.ID]; !exists {
t.Fatalf("expected raw host id %q to be blocklisted after removal", host.ID)
}
}
func TestAllowDockerHostReenrollAcceptsUnifiedID(t *testing.T) {
monitor := &Monitor{
state: models.NewState(),
removedDockerHosts: make(map[string]time.Time),
dockerCommands: make(map[string]*dockerHostCommand),
dockerCommandIndex: make(map[string]string),
dockerMetadataStore: config.NewDockerMetadataStore(t.TempDir(), nil),
}
host := models.DockerHost{ID: "host-reenroll", Hostname: "host-reenroll", DisplayName: "Host Reenroll", Status: "online"}
monitor.state.UpsertDockerHost(host)
unifiedID := wireUnifiedDockerHostForMonitor(monitor, host)
monitor.removedDockerHosts[host.ID] = time.Now()
if err := monitor.AllowDockerHostReenroll(unifiedID); err != nil {
t.Fatalf("AllowDockerHostReenroll with unified id: %v", err)
}
if _, exists := monitor.removedDockerHosts[host.ID]; exists {
t.Fatalf("expected raw host id %q to be removed from blocklist", host.ID)
}
}
func TestEnsureClusterEndpointURL(t *testing.T) {
tests := []struct {
input string
expected string
}{
{"", ""},
{"https://node.example:8006", "https://node.example:8006"},
{"node.example", "https://node.example:8006"},
{"node.example:9006", "https://node.example:9006"},
{" node.example ", "https://node.example:8006"},
}
for _, tt := range tests {
if got := ensureClusterEndpointURL(tt.input); got != tt.expected {
t.Fatalf("ensureClusterEndpointURL(%q) = %q, want %q", tt.input, got, tt.expected)
}
}
}
func TestClusterEndpointEffectiveURL(t *testing.T) {
endpoint := config.ClusterEndpoint{
Host: "node.local",
IP: "10.0.0.1",
}
if got := clusterEndpointEffectiveURL(endpoint, true, false); got != "https://node.local:8006" {
t.Fatalf("verifySSL host preference = %q, want %q", got, "https://node.local:8006")
}
endpoint.Host = ""
if got := clusterEndpointEffectiveURL(endpoint, true, false); got != "https://10.0.0.1:8006" {
t.Fatalf("verifySSL fallback to IP = %q, want %q", got, "https://10.0.0.1:8006")
}
endpoint.Host = "node.local"
if got := clusterEndpointEffectiveURL(endpoint, false, false); got != "https://10.0.0.1:8006" {
t.Fatalf("non-SSL IP preference = %q, want %q", got, "https://10.0.0.1:8006")
}
endpoint.IPOverride = "192.168.1.10"
if got := clusterEndpointEffectiveURL(endpoint, false, false); got != "https://192.168.1.10:8006" {
t.Fatalf("override IP preference = %q, want %q", got, "https://192.168.1.10:8006")
}
// #1665: an explicit override must win under verifySSL too, even without a
// per-endpoint fingerprint. The hostname-for-TLS preference only applies to
// auto-discovered addresses.
if got := clusterEndpointEffectiveURL(endpoint, true, false); got != "https://192.168.1.10:8006" {
t.Fatalf("override under verifySSL = %q, want %q", got, "https://192.168.1.10:8006")
}
// An override may be a hostname with a port, not just an IP.
endpoint.IPOverride = "pve2.internal:9006"
if got := clusterEndpointEffectiveURL(endpoint, true, false); got != "https://pve2.internal:9006" {
t.Fatalf("hostname override = %q, want %q", got, "https://pve2.internal:9006")
}
// #1199: a cluster-level fingerprint (passed as hasFingerprint=true) must NOT
// force IP routing for a member endpoint that has no per-endpoint fingerprint;
// hostname routing must be preserved so TLS validation still works.
endpoint = config.ClusterEndpoint{Host: "node.local", IP: "10.0.0.1"}
if got := clusterEndpointEffectiveURL(endpoint, true, true); got != "https://node.local:8006" {
t.Fatalf("cluster-level fingerprint must not force IP routing for a fingerprint-less endpoint, got %q", got)
}
// A per-endpoint fingerprint still allows IP routing under verifySSL.
endpoint.Fingerprint = "endpoint-fp"
if got := clusterEndpointEffectiveURL(endpoint, true, false); got != "https://10.0.0.1:8006" {
t.Fatalf("per-endpoint fingerprint should allow IP routing, got %q", got)
}
endpoint = config.ClusterEndpoint{}
if got := clusterEndpointEffectiveURL(endpoint, true, false); got != "" {
t.Fatalf("empty endpoint = %q, want empty", got)
}
}
func TestBuildClusterEndpointsForInit_RespectsDiscoveryPolicy(t *testing.T) {
oldLookup := lookupIPFunc
lookupIPFunc = func(host string) ([]net.IP, error) {
switch host {
case "allowed.local":
return []net.IP{net.ParseIP("10.0.0.10")}, nil
case "blocked.local":
return []net.IP{net.ParseIP("192.168.1.10")}, nil
default:
return nil, nil
}
}
t.Cleanup(func() {
lookupIPFunc = oldLookup
})
monitor := &Monitor{
config: &config.Config{
Discovery: config.DiscoveryConfig{
SubnetAllowlist: []string{"10.0.0.0/8"},
},
},
}
endpoints, _, _ := monitor.buildClusterEndpointsForInit(config.PVEInstance{
Name: "cluster-a",
Host: "https://main.local:8006",
VerifySSL: true,
ClusterEndpoints: []config.ClusterEndpoint{
{NodeName: "node-a", Host: "allowed.local"},
{NodeName: "node-b", Host: "blocked.local"},
},
})
if len(endpoints) != 2 {
t.Fatalf("expected configured authority plus allowed member failover, got %#v", endpoints)
}
if endpoints[0] != "https://main.local:8006" {
t.Fatalf("expected configured authority first, got %#v", endpoints)
}
if endpoints[1] != "https://allowed.local:8006" {
t.Fatalf("expected only the discovery-allowed member as failover, got %#v", endpoints)
}
}
// TestGuestObservedInCycleFailsOpenWithoutEvidence pins the direction of the
// guard. Dropping a legitimate sample is worse than recording a fabricated one,
// so a guest with no LastSeen evidence is recorded rather than skipped.
func TestGuestObservedInCycleFailsOpenWithoutEvidence(t *testing.T) {
cycleStart := time.Date(2026, 7, 24, 12, 0, 0, 0, time.UTC)
for _, tc := range []struct {
name string
lastSeen time.Time
want bool
}{
{name: "observed this cycle", lastSeen: cycleStart.Add(2 * time.Second), want: true},
{name: "observed exactly at cycle start", lastSeen: cycleStart, want: true},
{name: "carried forward from an earlier cycle", lastSeen: cycleStart.Add(-30 * time.Second), want: false},
{name: "no evidence either way", lastSeen: time.Time{}, want: true},
} {
t.Run(tc.name, func(t *testing.T) {
if got := guestObservedInCycle(tc.lastSeen, cycleStart); got != tc.want {
t.Fatalf("guestObservedInCycle(%v) = %v, want %v", tc.lastSeen, got, tc.want)
}
})
}
}
// TestRecordGuestMetricsSkipsGracePeriodGuestsButKeepsObservedOnes is the
// regression guard for the fabricated-zero defect. ac0fb263c made preserved
// guests carry their real runtime status ("running") instead of the stringified
// aggregate status ("online"), so they started passing recordGuestMetrics'
// status filter. The carried-forward projection has no counters, so every cycle
// a node spent in its grace period wrote CPU, disk and network zeroes into the
// persistent store, showing a collapse to zero rather than a gap.
//
// Both directions matter: the observed guest must still be recorded, because
// silently losing real samples would be a worse regression than the one being
// fixed.
func TestRecordGuestMetricsSkipsGracePeriodGuestsButKeepsObservedOnes(t *testing.T) {
history := NewMetricsHistory(32, time.Hour)
monitor := &Monitor{metricsHistory: history}
cycleStart := time.Now().UTC()
observedAt := cycleStart.Add(time.Second)
carriedForward := cycleStart.Add(-2 * time.Minute)
monitor.recordGuestMetrics(
[]models.VM{
{ID: "vm-observed", Status: "running", CPU: 0.42, CPUs: 4, LastSeen: observedAt},
{ID: "vm-preserved", Status: "running", CPU: 0, CPUs: 4, LastSeen: carriedForward},
},
[]models.Container{
{ID: "ct-observed", Status: "running", Type: "lxc", CPU: 0.25, CPUs: 1, LastSeen: observedAt},
{ID: "ct-preserved", Status: "running", Type: "lxc", CPU: 0, CPUs: 1, LastSeen: carriedForward},
},
cycleStart,
)
for _, id := range []string{"vm-observed", "ct-observed"} {
if got := history.GetGuestMetrics(id, "cpu", time.Hour); len(got) == 0 {
t.Fatalf("%s: observed guest lost its sample entirely", id)
}
}
for _, id := range []string{"vm-preserved", "ct-preserved"} {
if got := history.GetGuestMetrics(id, "cpu", time.Hour); len(got) != 0 {
t.Fatalf("%s: grace-period guest recorded %d fabricated sample(s): %+v", id, len(got), got)
}
}
}
// Issue1645: PVE's per-node endpoint GET /nodes/{node}/storage returns every
// storage in the datacenter config, including ones the node is not allowed to
// use. Those come back enabled:0/active:0 and used to be ingested as "disabled"
// rows, which the UI rendered as an offline storage on every node.
func TestIssue1645StorageRestrictedToOtherNodesIsNotListed(t *testing.T) {
monitor := &Monitor{
state: models.NewState(),
config: &config.Config{
PVEInstances: []config.PVEInstance{
{
Name: "inst1",
IsCluster: true,
ClusterName: "cluster-a",
},
},
},
}
client := &fakeStorageClient{
allStorage: []proxmox.Storage{
// Restricted to node1 only.
{Storage: "node1-only", Type: "dir", Content: "images", Nodes: "node1", Enabled: 1, Active: 1, Total: 100, Used: 10, Available: 90},
// No node restriction at all.
{Storage: "everywhere", Type: "dir", Content: "images", Enabled: 1, Active: 1, Total: 200, Used: 20, Available: 180},
// No node restriction, but disabled across the whole datacenter.
{Storage: "off-everywhere", Type: "dir", Content: "images", Enabled: 0, Active: 0, Total: 300, Used: 30, Available: 270},
},
storageByNode: map[string][]proxmox.Storage{
"node1": {
{Storage: "node1-only", Type: "dir", Content: "images", Enabled: 1, Active: 1, Total: 100, Used: 10, Available: 90},
{Storage: "everywhere", Type: "dir", Content: "images", Enabled: 1, Active: 1, Total: 200, Used: 20, Available: 180},
{Storage: "off-everywhere", Type: "dir", Content: "images", Enabled: 0, Active: 0, Total: 300, Used: 30, Available: 270},
},
"node2": {
// PVE still lists the restricted storage here, flagged unusable.
{Storage: "node1-only", Type: "dir", Content: "images", Enabled: 0, Active: 0, Total: 100, Used: 10, Available: 90},
{Storage: "everywhere", Type: "dir", Content: "images", Enabled: 1, Active: 1, Total: 200, Used: 25, Available: 175},
{Storage: "off-everywhere", Type: "dir", Content: "images", Enabled: 0, Active: 0, Total: 300, Used: 30, Available: 270},
},
},
}
nodes := []proxmox.Node{
{Node: "node1", Status: "online"},
{Node: "node2", Status: "online"},
}
monitor.pollStorageWithNodes(context.Background(), "inst1", client, nodes)
byName := map[string][]models.Storage{}
for _, storage := range monitor.state.GetSnapshot().Storage {
byName[storage.Name] = append(byName[storage.Name], storage)
}
// (a) The restricted storage must only exist on the node it is assigned to.
restricted := byName["node1-only"]
if len(restricted) != 1 {
t.Fatalf("expected node-restricted storage on exactly one node, got %+v", restricted)
}
if restricted[0].Node != "node1" {
t.Fatalf("expected node-restricted storage on node1, got %+v", restricted[0])
}
// (b) An unrestricted storage still shows up on every node.
unrestricted := byName["everywhere"]
if len(unrestricted) != 2 {
t.Fatalf("expected unrestricted storage on both nodes, got %+v", unrestricted)
}
seenNodes := map[string]bool{}
for _, storage := range unrestricted {
seenNodes[storage.Node] = true
}
if !seenNodes["node1"] || !seenNodes["node2"] {
t.Fatalf("expected unrestricted storage on node1 and node2, got %+v", unrestricted)
}
// (c) A globally disabled storage with no restriction is still reported, as disabled.
disabled := byName["off-everywhere"]
if len(disabled) != 2 {
t.Fatalf("expected globally disabled storage to remain visible on both nodes, got %+v", disabled)
}
for _, storage := range disabled {
if storage.Status != "disabled" || storage.Enabled {
t.Fatalf("expected globally disabled storage to report disabled, got %+v", storage)
}
}
}
// Issue1645: the shared-storage aggregation derives its node list from the
// per-node rows, so honouring the datacenter restriction must also stop
// non-member nodes from being credited with the storage.
func TestIssue1645SharedStorageNodeListExcludesRestrictedNodes(t *testing.T) {
monitor := &Monitor{
state: models.NewState(),
config: &config.Config{
PVEInstances: []config.PVEInstance{
{
Name: "inst1",
IsCluster: true,
ClusterName: "cluster-a",
},
},
},
}
sharedRow := proxmox.Storage{
Storage: "shared-nfs",
Type: "nfs",
Content: "images,backup",
Shared: 1,
Enabled: 1,
Active: 1,
Total: 1000,
Used: 400,
Available: 600,
}
unusableRow := sharedRow
unusableRow.Enabled = 0
unusableRow.Active = 0
clusterRow := sharedRow
clusterRow.Nodes = "node1,node2"
client := &fakeStorageClient{
allStorage: []proxmox.Storage{clusterRow},
storageByNode: map[string][]proxmox.Storage{
"node1": {sharedRow},
"node2": {sharedRow},
// node3 is not in the restriction but PVE lists the storage anyway.
"node3": {unusableRow},
},
}
nodes := []proxmox.Node{
{Node: "node1", Status: "online"},
{Node: "node2", Status: "online"},
{Node: "node3", Status: "online"},
}
monitor.pollStorageWithNodes(context.Background(), "inst1", client, nodes)
var shared *models.Storage
for _, storage := range monitor.state.GetSnapshot().Storage {
if storage.Name == "shared-nfs" {
storageCopy := storage
shared = &storageCopy
break
}
}
if shared == nil {
t.Fatalf("expected shared storage in state, got %+v", monitor.state.GetSnapshot().Storage)
}
if shared.NodeCount != 2 {
t.Fatalf("expected shared storage node count of 2, got %+v", *shared)
}
for _, nodeName := range shared.Nodes {
if nodeName == "node3" {
t.Fatalf("node3 is excluded by the storage node restriction but was listed: %+v", *shared)
}
}
for _, nodeID := range shared.NodeIDs {
if nodeID == "cluster-a-node3" {
t.Fatalf("node3 is excluded by the storage node restriction but was listed: %+v", *shared)
}
}
}
func TestIssue1645ClusterStorageRestrictedToOtherNodes(t *testing.T) {
cases := []struct {
name string
restriction string
node string
want bool
}{
{name: "no restriction", restriction: "", node: "node1", want: false},
{name: "member", restriction: "node1,node2", node: "node2", want: false},
{name: "non member", restriction: "node1,node2", node: "node3", want: true},
{name: "case insensitive member", restriction: "Node1", node: "node1", want: false},
{name: "spaced restriction", restriction: " node1 ; node2 ", node: "node2", want: false},
{name: "empty node name", restriction: "node1", node: "", want: false},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
if got := clusterStorageRestrictedToOtherNodes(tc.restriction, tc.node); got != tc.want {
t.Fatalf("clusterStorageRestrictedToOtherNodes(%q, %q) = %v, want %v", tc.restriction, tc.node, got, tc.want)
}
})
}
}
// A node-local ZFS pool must never be attached to inherently shared/remote
// storage types. On a single-pool node the sole-pool fallback previously
// attached the pool to every storage in the datacenter config, so one failing
// device raised a duplicate ZFS alert per NFS/PBS storage (#1731).
func TestMatchZFSPoolForStorageSkipsInherentlySharedTypes(t *testing.T) {
rpool := &models.ZFSPool{Name: "rpool"}
singlePool := map[string]*models.ZFSPool{"rpool": rpool}
sharedStorages := []models.Storage{
{Name: "NFS_Qnap_Proxmox_Backup", Type: "nfs", Path: "/mnt/pve/NFS_Qnap_Proxmox_Backup"},
{Name: "PBS_01_QNAP", Type: "pbs"},
{Name: "smb_share", Type: "cifs", Path: "/mnt/pve/smb_share"},
{Name: "ceph_pool", Type: "rbd", Pool: "rpool"},
// A name collision with the pool must not override the type gate.
{Name: "rpool", Type: "nfs", Path: "/mnt/pve/rpool"},
}
for _, storage := range sharedStorages {
if got := matchZFSPoolForStorage(storage, singlePool); got != nil {
t.Fatalf("expected no pool for shared storage %q (type %s), got %q", storage.Name, storage.Type, got.Name)
}
}
}
func TestMatchZFSPoolForStorageKeepsLocalMatches(t *testing.T) {
rpool := &models.ZFSPool{Name: "rpool"}
tank := &models.ZFSPool{Name: "tank"}
singlePool := map[string]*models.ZFSPool{"rpool": rpool}
multiPool := map[string]*models.ZFSPool{"rpool": rpool, "tank": tank}
cases := []struct {
name string
storage models.Storage
pools map[string]*models.ZFSPool
want *models.ZFSPool
}{
{
name: "zfspool storage matches by pool dataset prefix",
storage: models.Storage{Name: "vm_storage0_01", Type: "zfspool", Pool: "tank/data"},
pools: multiPool,
want: tank,
},
{
name: "zfspool storage matches by pool name",
storage: models.Storage{Name: "local-zfs", Type: "zfspool", Pool: "rpool"},
pools: multiPool,
want: rpool,
},
{
name: "dir storage on single-pool node keeps sole-pool fallback",
storage: models.Storage{Name: "local", Type: "dir", Path: "/var/lib/vz"},
pools: singlePool,
want: rpool,
},
{
name: "dir storage on multi-pool node stays unmatched",
storage: models.Storage{Name: "backup_dir", Type: "dir", Path: "/mnt/backup"},
pools: multiPool,
want: nil,
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
if got := matchZFSPoolForStorage(tc.storage, tc.pools); got != tc.want {
t.Fatalf("matchZFSPoolForStorage(%q) = %v, want %v", tc.storage.Name, got, tc.want)
}
})
}
}
// A per-resource grace override saved through the intent policy UI is keyed by
// the unified registry resource ID. For a pulse-agent merged with its Proxmox
// node the alert evaluator references the host as "agent:{hostID}", so the
// override only applies when that reference resolves back to the same merged
// resource the UI saved against (#1497).
func TestMergedProxmoxHostAgentGraceOverrideHoldsCPUAlert(t *testing.T) {
host := models.Host{
ID: "host-uuid-1",
Hostname: "proxmox2",
MachineID: "machine-abc",
LinkedNodeID: "node/proxmox2",
Platform: "linux",
Status: "online",
CPUUsage: 95,
CPUCount: 8,
IntervalSeconds: 30,
LastSeen: time.Now(),
}
node := models.Node{
ID: "node/proxmox2",
Name: "proxmox2",
Instance: "pve",
Status: "online",
LinkedAgentID: "host-uuid-1",
}
newAdapter := func() (*unifiedresources.MonitorAdapter, unifiedresources.Resource) {
adapter := unifiedresources.NewMonitorAdapter(unifiedresources.NewRegistry(nil))
adapter.PopulateFromSnapshot(models.StateSnapshot{
Nodes: []models.Node{node},
Hosts: []models.Host{host},
})
for _, resource := range adapter.GetAll() {
if resource.Agent != nil && resource.Proxmox != nil {
return adapter, resource
}
}
t.Fatal("expected node+agent snapshot to merge into one resource")
return nil, unifiedresources.Resource{}
}
newManager := func(adapter *unifiedresources.MonitorAdapter) *alerts.Manager {
manager := alerts.NewManagerWithDataDir(t.TempDir())
t.Cleanup(manager.Stop)
cfg := manager.GetConfig()
// Normalization restores absent keys to the 5s default, but an
// explicit zero survives, and zero legacy delay makes the assertion
// discriminating: no override means the alert fires on first check.
cfg.TimeThresholds = map[string]int{"agent": 0}
cfg.MetricTimeThresholds = nil
manager.UpdateConfig(cfg)
monitor := &Monitor{alertManager: manager}
monitor.installOperatorIntentResolver(adapter)
return manager
}
hostCPUAlert := func(manager *alerts.Manager) *alerts.Alert {
for _, alert := range manager.GetActiveAlerts() {
if alert.ResourceID == "agent:host-uuid-1" && alert.Type == "cpu" {
found := alert
return &found
}
}
return nil
}
// Control: without an override the CPU alert fires on the first check,
// proving the fixture actually triggers.
controlAdapter, _ := newAdapter()
control := newManager(controlAdapter)
control.CheckHost(host)
if hostCPUAlert(control) == nil {
t.Fatal("control manager without override should raise the CPU alert immediately")
}
adapter, merged := newAdapter()
manager := newManager(adapter)
grace := 600
document := alerts.NewAlertIntentPolicyDocument()
document.Resources = map[string]map[string]alerts.AlertIntentRule{
merged.ID: {"metric.cpu": {GraceSeconds: &grace}},
}
if err := manager.LoadIntentPolicies(document); err != nil {
t.Fatalf("LoadIntentPolicies: %v", err)
}
manager.CheckHost(host)
if alert := hostCPUAlert(manager); alert != nil {
t.Fatalf("CPU alert fired immediately despite 600s per-resource grace override on %q: %+v", merged.ID, alert)
}
}
func TestNodeThresholdOverrideStoredUnderRegistryIDApplies(t *testing.T) {
node := models.Node{
ID: "mock-cluster-pve1",
Name: "pve1",
Instance: "mock-cluster",
Status: "online",
Type: "node",
CPU: 0.10,
Memory: models.Memory{Total: 16 << 30, Used: 10 << 30, Free: 6 << 30, Usage: 60},
LoadAverage: []float64{},
}
newAdapter := func() (*unifiedresources.MonitorAdapter, string) {
adapter := unifiedresources.NewMonitorAdapter(unifiedresources.NewRegistry(nil))
adapter.PopulateFromSnapshot(models.StateSnapshot{Nodes: []models.Node{node}})
for _, resource := range adapter.GetAll() {
if resource.Proxmox != nil {
return adapter, resource.ID
}
}
t.Fatal("expected node snapshot to produce a registry resource with a Proxmox facet")
return nil, ""
}
overrideFor := func(registryID string) map[string]alerts.ThresholdConfig {
return map[string]alerts.ThresholdConfig{
registryID: {Memory: &alerts.HysteresisThreshold{Trigger: 50, Clear: 45}},
}
}
newManager := func(overrides map[string]alerts.ThresholdConfig) *alerts.Manager {
manager := alerts.NewManagerWithDataDir(t.TempDir())
t.Cleanup(manager.Stop)
cfg := manager.GetConfig()
cfg.TimeThresholds = map[string]int{"node": 0}
cfg.MetricTimeThresholds = nil
cfg.Overrides = overrides
manager.UpdateConfig(cfg)
return manager
}
memoryAlert := func(manager *alerts.Manager) *alerts.Alert {
for _, alert := range manager.GetActiveAlerts() {
if alert.ResourceID == node.ID && alert.Type == "memory" {
found := alert
return &found
}
}
return nil
}
// Control 1: without the override, the 60% sample stays below the node
// default, so the fixture cannot fire on defaults alone.
controlAdapter, _ := newAdapter()
control := newManager(nil)
monitorControl := &Monitor{alertManager: control}
monitorControl.installOperatorIntentResolver(controlAdapter)
control.CheckNode(node)
if alert := memoryAlert(control); alert != nil {
t.Fatalf("control without override fired a memory alert: %+v", alert)
}
// Control 2: the override stored under the registry ID with no registry
// resolver installed never resolves, which is the reported divergence
// (#1738): the UI shows Custom while the engine evaluates defaults.
_, registryID := newAdapter()
unresolved := newManager(overrideFor(registryID))
unresolved.CheckNode(node)
if alert := memoryAlert(unresolved); alert != nil {
t.Fatalf("manager without registry resolver applied a registry-keyed override: %+v", alert)
}
adapter, registryID2 := newAdapter()
manager := newManager(overrideFor(registryID2))
monitor := &Monitor{alertManager: manager}
monitor.installOperatorIntentResolver(adapter)
manager.CheckNode(node)
alert := memoryAlert(manager)
if alert == nil {
t.Fatalf("memory alert did not fire despite 50%% override stored under registry ID %q", registryID2)
}
if alert.Threshold != 50 {
t.Fatalf("alert threshold = %v, want 50", alert.Threshold)
}
}
func TestApplyStorageFallbackAndRecordNodeMetrics_PersistsTemperatureHistory(t *testing.T) {
monitor := &Monitor{
state: models.NewState(),
metricsHistory: NewMetricsHistory(16, time.Hour),
alertManager: alerts.NewManager(),
}
nodes := []models.Node{{
ID: "pve1-node1",
Name: "node1",
Instance: "pve1",
Status: "online",
CPU: 0.25,
Temperature: &models.Temperature{
Available: true,
CPUPackage: 61.5,
CPUMax: 64,
},
}}
monitor.applyStorageFallbackAndRecordNodeMetrics("pve1", nil, nodes, nil, nil)
points := monitor.metricsHistory.GetNodeMetrics("pve1-node1", "temperature", time.Hour)
if len(points) != 1 || points[0].Value != 64 {
t.Fatalf("temperature history = %+v, want one point at 64C", points)
}
}
// A node fed by a linked agent carries its package above CPUMax (CPUMax is
// built from cores there). Node history records the hottest reading, the same
// value the node's resource temperature and its agent's history carry.
func TestApplyStorageFallbackAndRecordNodeMetrics_RecordsPrimaryCPUTemperature(t *testing.T) {
monitor := &Monitor{
state: models.NewState(),
metricsHistory: NewMetricsHistory(16, time.Hour),
alertManager: alerts.NewManager(),
}
nodes := []models.Node{{
ID: "pve1-node2",
Name: "node2",
Instance: "pve1",
Status: "online",
Temperature: &models.Temperature{
Available: true,
HasCPU: true,
CPUPackage: 61,
CPUMax: 57,
Cores: []models.CoreTemp{{Core: 0, Temp: 55}, {Core: 1, Temp: 57}},
},
}}
monitor.applyStorageFallbackAndRecordNodeMetrics("pve1", nil, nodes, nil, nil)
points := monitor.metricsHistory.GetNodeMetrics("pve1-node2", "temperature", time.Hour)
if len(points) != 1 || points[0].Value != 61 {
t.Fatalf("temperature history = %+v, want one point at 61C", points)
}
}
// Exercise the registry's real manual merge, followed by the next guest poll's
// fallback lookup. A healthy platform row must not revive an offline agent.
func TestCorrelatedGuestMemoryNextPoll(t *testing.T) {
for _, tc := range []struct {
name, status string
age time.Duration
unavailable bool
want uint64
}{
{name: "live", status: "online", want: 2800},
{name: "offline", status: "offline", want: 8000},
{name: "stale", status: "online", age: time.Hour, want: 8000},
{name: "unavailable", status: "online", unavailable: true, want: 8000},
} {
t.Run(tc.name, func(t *testing.T) {
now := time.Now()
guestID := makeGuestID("pve-a", "node1", 111)
store := unifiedresources.NewMemoryStore()
if err := store.AddLink(unifiedresources.ResourceLink{ResourceA: "vm-test", ResourceB: "agent-test", PrimaryID: "agent-test"}); err != nil {
t.Fatal(err)
}
registry := unifiedresources.NewRegistry(store)
total, used := int64(8000), int64(8000)
resources := []unifiedresources.Resource{
{ID: "vm-test", Type: unifiedresources.ResourceTypeVM, Name: "firewall", Status: unifiedresources.StatusOnline, LastSeen: now,
Sources: []unifiedresources.DataSource{unifiedresources.SourceProxmox},
Proxmox: &unifiedresources.ProxmoxData{SourceID: guestID, Instance: "pve-a", NodeName: "node1", VMID: 111, RuntimeStatus: "running"},
Metrics: &unifiedresources.ResourceMetrics{Memory: &unifiedresources.MetricValue{Total: &total, Used: &used, Percent: 100, Source: unifiedresources.SourceProxmox}}},
{ID: "agent-test", Type: unifiedresources.ResourceTypeAgent, Name: "firewall-agent", Status: unifiedresources.ResourceStatus(tc.status), LastSeen: now.Add(-tc.age),
Sources: []unifiedresources.DataSource{unifiedresources.SourceAgent},
Agent: &unifiedresources.AgentData{AgentID: "agent-test", Stale: tc.status == "offline", Memory: &unifiedresources.AgentMemoryMeta{Total: 8000, Used: 2800, Free: 5200, UsageUnavailable: tc.unavailable}}},
}
registry.IngestResources(resources)
if len(registry.VMs()) != 1 || len(registry.Hosts()) != 0 {
t.Fatal("initial registry did not merge linked guest")
}
// Rebuild from source evidence and the retained link store, not a
// previously merged resource or the old in-memory matcher.
registry = unifiedresources.NewRegistry(store)
registry.IngestResources(resources)
if len(registry.VMs()) != 1 || len(registry.Hosts()) != 0 {
t.Fatalf("expected one merged VM and no standalone hosts")
}
// A simultaneous guest with the same node name and VMID must not
// inherit this instance's agent memory.
registry.IngestRecords(unifiedresources.SourceProxmox, []unifiedresources.IngestRecord{{
SourceID: makeGuestID("pve-b", "node1", 111),
Resource: unifiedresources.Resource{
Type: unifiedresources.ResourceTypeVM, Name: "other-firewall",
Status: unifiedresources.StatusOnline, LastSeen: now,
Proxmox: &unifiedresources.ProxmoxData{
SourceID: makeGuestID("pve-b", "node1", 111),
Instance: "pve-b", NodeName: "node1", VMID: 111, RuntimeStatus: "running",
},
},
}})
if len(registry.VMs()) != 2 {
t.Fatal("duplicate VMIDs from separate instances were not preserved")
}
mon := &Monitor{state: models.NewState(), rateTracker: NewRateTracker(), config: &config.Config{}, resourceStore: unifiedresources.NewMonitorAdapter(registry)}
prev := mon.previousGuestContextForInstance("pve-a")
raw := VMMemoryRaw{}
_, got, source, _ := mon.resolveGuestStatusMemory(context.Background(), &stubPVEClient{}, "pve-a", "firewall", "node1", 111, guestID, &proxmox.VMStatus{MaxMem: 8000, Mem: 8100}, prev.hostAgentsByVMID, 8000, "", &raw)
if got != tc.want {
t.Fatalf("used=%d source=%s want=%d", got, source, tc.want)
}
if tc.want == 2800 && (source != "agent" || raw.HostAgentUsed != 2800 || raw.HostAgentTotal != 8000) {
t.Fatalf("missing agent diagnostic evidence: %s %+v", source, raw)
}
if tc.want == 8000 && source != "status-mem" {
t.Fatalf("unsafe fallback source %s", source)
}
vm, buildRaw, buildSource, _, _, ok := mon.buildVMFromClusterResource(context.Background(), "pve-a",
proxmox.ClusterResource{Type: "qemu", Status: "running", Node: "node1", VMID: 111, Name: "firewall", MaxMem: 8000, Mem: 8100},
&correlatedMemoryClient{}, guestID, prev.hostAgentsByVMID, nil)
if !ok || vm.Memory.Used != int64(tc.want) || vm.Memory.Usage != float64(tc.want)/80 || buildSource != source || buildRaw.HostAgentUsed != raw.HostAgentUsed {
t.Fatalf("builder disagrees with resolver: %+v source=%s raw=%+v", vm.Memory, buildSource, buildRaw)
}
projection := unifiedresources.NewRegistry(nil)
projection.IngestSnapshot(models.StateSnapshot{VMs: []models.VM{vm}})
if got := projection.VMs()[0].MemoryPercent(); got != vm.Memory.Usage {
t.Fatalf("read-state card projection=%v want=%v", got, vm.Memory.Usage)
}
manager := alerts.NewManagerWithDataDir(t.TempDir())
defer manager.Stop()
cfg := manager.GetConfig()
cfg.Enabled = true
cfg.ActivationState = alerts.ActivationActive
cfg.GuestDefaults = alerts.ThresholdConfig{Memory: &alerts.HysteresisThreshold{Trigger: 85, Clear: 80}}
cfg.TimeThresholds = map[string]int{"guest": 0}
manager.UpdateConfig(cfg)
mon.alertManager = manager
for i := 0; i < 2; i++ {
if _, ok := mon.handleClusterVMResource(context.Background(), "pve-a", proxmox.ClusterResource{Type: "qemu", Status: "running", Node: "node1", VMID: 111, Name: "firewall", MaxMem: 8000, Mem: 8100}, guestID, &correlatedMemoryClient{}, nil, prev.hostAgentsByVMID); !ok {
t.Fatal("poll handler skipped VM")
}
}
snapshots := mon.GetDiagnosticSnapshots().Guests
if len(snapshots) != 1 || snapshots[0].Memory.Usage != vm.Memory.Usage || snapshots[0].MemorySource != source {
t.Fatalf("diagnostics disagree: %+v", snapshots)
}
active := manager.GetActiveAlerts()
if tc.want == 2800 && len(active) != 0 {
t.Fatalf("35 percent generated alerts: %+v", active)
}
if tc.want == 8000 && (len(active) != 1 || active[0].Value != 100) {
t.Fatalf("100 percent did not generate matching alert: %+v", active)
}
other := mon.previousGuestContextForInstance("pve-b")
if len(other.hostAgentsByVMID) != 0 {
t.Fatal("correlated agent crossed instance boundary")
}
otherID := makeGuestID("pve-b", "node1", 111)
_, otherUsed, otherSource, _ := mon.resolveGuestStatusMemory(context.Background(), &stubPVEClient{}, "pve-b", "other-firewall", "node1", 111, otherID, &proxmox.VMStatus{MaxMem: 8000, Mem: 8100}, other.hostAgentsByVMID, 8000, "", &VMMemoryRaw{})
if otherUsed != 8000 || otherSource != "status-mem" {
t.Fatalf("other instance inherited agent memory: used=%d source=%s", otherUsed, otherSource)
}
})
}
}
// Issue #2148: a correlated agent merged into a system-container resource must
// surface through the previous-guest context so the LXC builder prefers it over
// the cache-inclusive cluster/resources fallback.
func TestCorrelatedContainerMemoryNextPoll(t *testing.T) {
now := time.Now()
const gib = int64(1024 * 1024 * 1024)
guestID := makeGuestID("pve-a", "node1", 100)
store := unifiedresources.NewMemoryStore()
if err := store.AddLink(unifiedresources.ResourceLink{ResourceA: "ct-test", ResourceB: "agent-test", PrimaryID: "agent-test"}); err != nil {
t.Fatal(err)
}
registry := unifiedresources.NewRegistry(store)
total, used := int64(24)*gib, int64(11)*gib
resources := []unifiedresources.Resource{
{ID: "ct-test", Type: unifiedresources.ResourceTypeSystemContainer, Name: "npu-ct", Status: unifiedresources.StatusOnline, LastSeen: now,
Sources: []unifiedresources.DataSource{unifiedresources.SourceProxmox},
Proxmox: &unifiedresources.ProxmoxData{SourceID: guestID, Instance: "pve-a", NodeName: "node1", VMID: 100, RuntimeStatus: "running"},
Metrics: &unifiedresources.ResourceMetrics{Memory: &unifiedresources.MetricValue{Total: &total, Used: &used, Percent: 45, Source: unifiedresources.SourceProxmox}}},
{ID: "agent-test", Type: unifiedresources.ResourceTypeAgent, Name: "npu-ct-agent", Status: unifiedresources.StatusOnline, LastSeen: now,
Sources: []unifiedresources.DataSource{unifiedresources.SourceAgent},
Agent: &unifiedresources.AgentData{AgentID: "agent-test", Memory: &unifiedresources.AgentMemoryMeta{Total: total, Used: used, Free: total - used}}},
}
registry.IngestResources(resources)
if len(registry.Containers()) != 1 || len(registry.Hosts()) != 0 {
t.Fatalf("expected one merged container and no standalone hosts, got %d/%d", len(registry.Containers()), len(registry.Hosts()))
}
mon := &Monitor{state: models.NewState(), rateTracker: NewRateTracker(), config: &config.Config{}, resourceStore: unifiedresources.NewMonitorAdapter(registry)}
prev := mon.previousGuestContextForInstance("pve-a")
if _, ok := prev.hostAgentsByVMID[guestID]; !ok {
t.Fatalf("linked container agent missing from previous context: %+v", prev.hostAgentsByVMID)
}
container, _, source, _, ok := mon.buildContainerFromClusterResource(context.Background(), "pve-a",
proxmox.ClusterResource{Type: "lxc", Status: "running", Node: "node1", VMID: 100, Name: "npu-ct", MaxMem: uint64(total), Mem: 3459743744},
&stubPVEClient{}, map[int]bool{}, prev.hostAgentsByVMID)
if !ok || source != "agent" || container.Memory.Used != used {
t.Fatalf("container memory not agent-backed: ok=%v source=%s mem=%+v", ok, source, container.Memory)
}
}
type correlatedMemoryClient struct{ stubPVEClient }
func (*correlatedMemoryClient) GetVMStatus(context.Context, string, int) (*proxmox.VMStatus, error) {
return &proxmox.VMStatus{MaxMem: 8000, Mem: 8100}, nil
}
// Automatic VM correlation is a link hint, not a cross-type registry merge.
// Keep this separate from TestCorrelatedGuestMemoryNextPoll's retained links.
func TestAutomaticGuestMemoryLinkNextPoll(t *testing.T) {
now := time.Now()
guestID := makeGuestID("pve-a", "node1", 111)
otherID := makeGuestID("pve-b", "node1", 111)
snapshot := models.StateSnapshot{VMs: []models.VM{
{ID: guestID, Instance: "pve-a", Node: "node1", VMID: 111, Name: "firewall", Status: "running", LastSeen: now},
{ID: otherID, Instance: "pve-b", Node: "node1", VMID: 111, Name: "other-firewall", Status: "running", LastSeen: now},
}}
registry := unifiedresources.NewRegistry(nil)
registry.IngestSnapshot(snapshot)
mon := &Monitor{state: models.NewState(), resourceStore: unifiedresources.NewMonitorAdapter(registry)}
node, linkedVM, ct := mon.findLinkedProxmoxEntityWithHints("firewall.example.test", "", nil)
if node != "" || linkedVM != guestID || ct != "" {
t.Fatalf("automatic link = %q/%q/%q", node, linkedVM, ct)
}
snapshot.Hosts = []models.Host{{ID: "guest-agent", Hostname: "firewall.example.test", LinkedVMID: linkedVM, Status: "online", LastSeen: now, Memory: models.Memory{Total: 8000, Used: 2800, Free: 5200, Usage: 35}}}
// Reconstruct using source snapshots, without a manual link store.
registry = unifiedresources.NewRegistry(nil)
registry.IngestSnapshot(snapshot)
mon.resourceStore = unifiedresources.NewMonitorAdapter(registry)
if len(registry.VMs()) != 2 || len(registry.Hosts()) != 1 {
t.Fatal("automatic hint unexpectedly merged the agent into a VM")
}
for _, tc := range []struct {
instance, id string
used uint64
source string
}{
{"pve-a", guestID, 2800, "agent"}, {"pve-b", otherID, 8000, "status-mem"},
} {
prev := mon.previousGuestContextForInstance(tc.instance)
_, used, source, _ := mon.resolveGuestStatusMemory(context.Background(), &stubPVEClient{}, tc.instance, "firewall", "node1", 111, tc.id, &proxmox.VMStatus{MaxMem: 8000, Mem: 8100}, prev.hostAgentsByVMID, 8000, "", &VMMemoryRaw{})
if used != tc.used || source != tc.source {
t.Fatalf("%s used=%d source=%s", tc.instance, used, source)
}
}
// A matching name in another instance must prevent automatic selection.
snapshot.VMs[1].Name = "firewall"
registry = unifiedresources.NewRegistry(nil)
registry.IngestSnapshot(snapshot)
mon.resourceStore = unifiedresources.NewMonitorAdapter(registry)
node, linkedVM, ct = mon.findLinkedProxmoxEntityWithHints("firewall.example.test", "", nil)
if node != "" || linkedVM != "" || ct != "" {
t.Fatalf("ambiguous hostname selected %q/%q/%q", node, linkedVM, ct)
}
}
// shortenTenantShutdownBudgets makes the shutdown waits fast enough for tests
// while keeping deletion's budget longer than the plain one, as in production.
func shortenTenantShutdownBudgets(t *testing.T, plain, deletion time.Duration) {
t.Helper()
previousPlain, previousDeletion := tenantMonitorShutdownTimeout, tenantDeletionShutdownTimeout
tenantMonitorShutdownTimeout, tenantDeletionShutdownTimeout = plain, deletion
t.Cleanup(func() {
tenantMonitorShutdownTimeout, tenantDeletionShutdownTimeout = previousPlain, previousDeletion
})
}
// A tenant monitor that is still starting up or finishing a tick can take
// longer than the plain shutdown budget to see its cancelled context. In CI's
// mock mode, Start seeds mock metrics history synchronously before its select
// loop, so a just-used organization's loop took 8-10s to stop and deletion
// refused with tenant_shutdown_incomplete. Deletion waits on its own, longer
// budget instead.
func TestTenantDeletionWaitsForSlowMonitoringLoop(t *testing.T) {
shortenTenantShutdownBudgets(t, 50*time.Millisecond, 2*time.Second)
slowLoop := func(mtm *MultiTenantMonitor, orgID string) *Monitor {
ctx, cancel := context.WithCancel(context.Background())
done := make(chan struct{})
go func() {
<-ctx.Done()
// Exits well after the plain budget but inside deletion's.
time.Sleep(300 * time.Millisecond)
close(done)
}()
monitor := &Monitor{}
mtm.monitors[orgID] = monitor
mtm.tenantCancel[orgID] = cancel
mtm.tenantDone[orgID] = done
return monitor
}
mtm := NewMultiTenantMonitor(&config.Config{}, nil, nil)
t.Cleanup(mtm.Stop)
// RemoveTenant keeps the plain budget, gives up and retains the owner.
retained := slowLoop(mtm, "removal")
mtm.RemoveTenant("removal")
if got, ok := mtm.PeekMonitor("removal"); !ok || got != retained {
t.Fatal("plain removal discarded a monitor whose loop had not stopped")
}
slowLoop(mtm, "deletion")
if err := mtm.BeginTenantDeletion("deletion"); err != nil {
t.Fatalf("deletion refused a loop that stopped inside its budget: %v", err)
}
if _, ok := mtm.PeekMonitor("deletion"); ok {
t.Fatal("completed deletion left the monitor registered")
}
mtm.FinishTenantDeletion("deletion")
}
// The poller reads the previous poll's nodes back through the unified read
// state. Those lookups key on the Proxmox source ID, so the projection must
// return it (not the registry's unified resource ID) and carry the stored
// temperature; otherwise the node's CPU low/record values reset to the
// current reading every poll and a failed collection never carries over.
func TestPreviousNodeProjectionFeedsPollerTemperatureTracking(t *testing.T) {
instanceCfg := config.PVEInstance{Name: "pve1", Host: "https://10.0.0.1:8006"}
adapter := unifiedresources.NewMonitorAdapter(unifiedresources.NewRegistry(nil))
m := &Monitor{
config: &config.Config{TemperatureMonitoringEnabled: true, PVEInstances: []config.PVEInstance{instanceCfg}},
state: models.NewState(),
resourceStore: adapter,
}
base := models.Node{ID: "pve1-node1", NodeIdentity: "pve1-node1", Name: "node1", Instance: "pve1", Status: "online", Type: "node"}
// publish rebuilds the unified store from state the way the monitor does,
// alongside the host agents' latest reports.
var hosts []models.Host
publish := func() {
snapshot := m.state.GetSnapshot()
snapshot.Hosts = hosts
adapter.PopulateFromSnapshot(snapshot)
}
// poll delivers an agent report (none when agentReading is 0), runs one PVE
// poll's temperature step against the previous poll's nodes, and publishes
// the polled node.
poll := func(agentReading float64) *models.Temperature {
t.Helper()
hosts = nil
if agentReading > 0 {
hosts = []models.Host{{
ID: "agent-1", Hostname: "node1", Status: "online", LinkedNodeID: base.ID, IntervalSeconds: 30, LastSeen: time.Now(),
Sensors: models.HostSensorSummary{TemperatureCelsius: map[string]float64{"cpu_package": agentReading}},
}}
}
publish()
prevNodes := m.snapshotPrevNodes("pve1")
polled := base
polled.LastSeen = time.Now()
m.collectNodeTemperatureData(context.Background(), "pve1", &instanceCfg, proxmox.Node{Node: "node1"}, &polled, prevNodes, "online")
m.state.UpdateNodesForInstance("pve1", []models.Node{polled})
publish()
return polled.Temperature
}
if temp := poll(50); temp == nil || temp.CPUMin != 50 || temp.CPUMaxRecord != 50 {
t.Fatalf("first reading = %#v, want min and record initialised to 50", temp)
}
prevNodes := m.snapshotPrevNodes("pve1")
if len(prevNodes) != 1 || prevNodes[0].ID != base.ID || prevNodes[0].Temperature == nil {
t.Fatalf("previous nodes = %#v, want source ID %q with its temperature", prevNodes, base.ID)
}
if temp := poll(62); temp == nil || temp.CPUPackage != 62 || temp.CPUMin != 50 || temp.CPUMaxRecord != 62 {
t.Fatalf("second reading = %#v, want current 62, low 50, record 62", temp)
}
third := poll(45)
if third == nil || third.CPUPackage != 45 || third.CPUMin != 45 || third.CPUMaxRecord != 62 {
t.Fatalf("third reading = %#v, want current 45, low 45, record 62", third)
}
// No agent and no SSH collector: the last reading carries over with its
// original timestamp, inside the carry window.
carried := poll(0)
if carried == nil || carried.CPUPackage != 45 || !carried.LastUpdate.Equal(third.LastUpdate) {
t.Fatalf("carried reading = %#v, want the previous 45 with its original timestamp", carried)
}
}
// When a whole Proxmox instance stops answering, its last-known nodes are
// written back into state through the offline grace policy. That writeback
// must keep each node the same resource: the poller's source ID and the
// config-derived identity the registry derives a cluster member's canonical ID
// from. Past grace the node goes offline and stops presenting a temperature.
func TestPVEOutageWritebackKeepsNodeIdentity(t *testing.T) {
for _, tc := range []struct {
name string
instances []config.PVEInstance
wantID string
providerScoped bool
}{
{
name: "cluster member",
instances: []config.PVEInstance{{Name: "pve-a", Host: "https://10.0.0.1:8006", IsCluster: true, ClusterName: "lab"}},
wantID: "lab-node1",
},
{
name: "cluster name shared by two connections",
instances: []config.PVEInstance{
{Name: "pve-a", Host: "https://10.0.0.1:8006", IsCluster: true, ClusterName: "lab"},
{Name: "pve-b", Host: "https://10.0.1.1:8006", IsCluster: true, ClusterName: "lab"},
},
wantID: "pve-a-node1",
providerScoped: true,
},
} {
t.Run(tc.name, func(t *testing.T) {
adapter := unifiedresources.NewMonitorAdapter(unifiedresources.NewRegistry(nil))
m := &Monitor{
config: &config.Config{PVEInstances: tc.instances},
state: models.NewState(),
resourceStore: adapter,
nodeLastOnline: make(map[string]time.Time),
}
instanceCfg := m.getInstanceConfig("pve-a")
if got := m.pveNodeID("pve-a", instanceCfg, "node1"); got != tc.wantID {
t.Fatalf("pveNodeID = %q, want %q", got, tc.wantID)
}
now := time.Now()
polled := m.placeholderNodeForInstance("pve-a", instanceCfg, "node1")
polled.Status = "online"
polled.ConnectionHealth = "healthy"
polled.LastSeen = now
polled.Temperature = &models.Temperature{CPUPackage: 55, Available: true, HasCPU: true, LastUpdate: now}
if polled.ProviderScopedIdentity != tc.providerScoped {
t.Fatalf("ProviderScopedIdentity = %v, want %v", polled.ProviderScopedIdentity, tc.providerScoped)
}
m.nodeLastOnline[polled.ID] = now
m.state.UpdateNodesForInstance("pve-a", []models.Node{polled})
adapter.PopulateFromSnapshot(m.state.GetSnapshot())
unifiedID := adapter.Nodes()[0].ID()
for cycle := 0; cycle < 3; cycle++ {
m.markPVEInstanceNodesUnreachable("pve-a")
adapter.PopulateFromSnapshot(m.state.GetSnapshot())
stateNodes := m.state.GetSnapshot().Nodes
if len(stateNodes) != 1 || stateNodes[0].ID != tc.wantID || stateNodes[0].ClusterName != "lab" ||
stateNodes[0].ProviderScopedIdentity != tc.providerScoped {
t.Fatalf("cycle %d: state nodes = %#v, want %q kept with its cluster identity", cycle, stateNodes, tc.wantID)
}
if stateNodes[0].Status != "online" || stateNodes[0].Temperature == nil {
t.Fatalf("cycle %d: node within grace = %#v, want online with its recent temperature", cycle, stateNodes[0])
}
if nodes := adapter.Nodes(); len(nodes) != 1 || nodes[0].ID() != unifiedID {
t.Fatalf("cycle %d: unified nodes = %v, want the single resource %q", cycle, nodes, unifiedID)
}
}
// The process last saw the node online beyond the grace period.
m.nodeLastOnline[polled.ID] = now.Add(-2 * nodeOfflineGracePeriod)
polled.LastSeen = now.Add(-2 * nodeOfflineGracePeriod)
m.state.UpdateNodesForInstance("pve-a", []models.Node{polled})
adapter.PopulateFromSnapshot(m.state.GetSnapshot())
m.markPVEInstanceNodesUnreachable("pve-a")
adapter.PopulateFromSnapshot(m.state.GetSnapshot())
expired := m.state.GetSnapshot().Nodes
if len(expired) != 1 || expired[0].ID != tc.wantID || expired[0].Status != "offline" || expired[0].Temperature != nil {
t.Fatalf("expired nodes = %#v, want %q offline without a temperature", expired, tc.wantID)
}
if nodes := adapter.Nodes(); len(nodes) != 1 || nodes[0].ID() != unifiedID {
t.Fatalf("unified nodes after expiry = %v, want the single resource %q", nodes, unifiedID)
}
})
}
}
// Within grace a preserved node keeps its temperature only while the reading
// is inside the same carry window a failed collection gets.
func TestPreserveOrExpireNodesBoundsGraceTemperature(t *testing.T) {
m := &Monitor{config: &config.Config{}, state: models.NewState(), nodeLastOnline: make(map[string]time.Time)}
now := time.Now()
m.nodeLastOnline["pve-node1"] = now
stale := now.Add(-m.nodeTemperatureCarryWindow() - time.Minute)
preserved := m.preserveOrExpireNodes([]models.Node{{
ID: "pve-node1", Name: "node1", Status: "online", LastSeen: now,
Temperature: &models.Temperature{CPUPackage: 70, Available: true, LastUpdate: stale},
}})
if len(preserved) != 1 || preserved[0].Status != "online" || preserved[0].Temperature != nil {
t.Fatalf("preserved = %#v, want online within grace without the stale temperature", preserved)
}
}
// When detailed node status fails, the poller may carry its own last trusted
// memory reading for one cycle. On a node merged with a host agent, the read
// state holds the agent's memory, which is not the Proxmox value the snapshot
// vouches for, so the carry must use the snapshot and keep its used, cache
// and free split summing to the total.
func TestPollPVENodeCarriesOwnMemorySnapshot(t *testing.T) {
t.Setenv("PULSE_DATA_DIR", t.TempDir())
const gib = uint64(1024 * 1024 * 1024)
mon := newTestPVEMonitor("test")
defer mon.alertManager.Stop()
defer mon.notificationMgr.Stop()
adapter := unifiedresources.NewMonitorAdapter(unifiedresources.NewRegistry(nil))
mon.resourceStore = adapter
client := &stubPVEClient{nodeStatus: &proxmox.NodeStatus{
Memory: &proxmox.MemoryStatus{Total: 16 * gib, Used: 12 * gib, Free: 1 * gib, Available: 8 * gib},
}}
node := proxmox.Node{Node: "node1", Status: "online", MaxMem: 16 * gib, Mem: 15 * gib, MaxCPU: 8}
first, _, _, err := mon.pollPVENode(context.Background(), "test", &mon.config.PVEInstances[0], client, node, "healthy", nil)
if err != nil {
t.Fatalf("first pollPVENode() error = %v", err)
}
if !first.Memory.HasKnownUsage() || first.Memory.Used != int64(8*gib) || first.Memory.Cache == 0 {
t.Fatalf("first.Memory = %+v, want 8 GiB used with reclaimable cache split out", first.Memory)
}
mon.state.UpdateNodesForInstance("test", []models.Node{first})
snapshot := mon.state.GetSnapshot()
snapshot.Hosts = []models.Host{{
ID: "agent-1", Hostname: "node1", Status: "online", LinkedNodeID: first.ID, IntervalSeconds: 30, LastSeen: time.Now(),
Memory: models.Memory{Total: int64(16 * gib), Used: int64(3 * gib), Free: int64(13 * gib), Usage: 18.75},
}}
adapter.PopulateFromSnapshot(snapshot)
client.nodeStatus = nil
second, _, _, err := mon.pollPVENode(context.Background(), "test", &mon.config.PVEInstances[0], client, node, "healthy", mon.snapshotPrevNodes("test"))
if err != nil {
t.Fatalf("second pollPVENode() error = %v", err)
}
if second.Memory.Used != first.Memory.Used || second.Memory.Cache != first.Memory.Cache || second.Memory.Free != first.Memory.Free {
t.Fatalf("second.Memory = %+v, want the poller's own reading %+v", second.Memory, first.Memory)
}
if second.Memory.Used+second.Memory.Cache+second.Memory.Free != second.Memory.Total {
t.Fatalf("second.Memory = %+v, want used + cache + free = total", second.Memory)
}
if snap := mon.nodeSnapshots[makeNodeSnapshotKey("test", "node1")]; snap.MemorySource != "previous-snapshot" {
t.Fatalf("snapshot.MemorySource = %q, want previous-snapshot", snap.MemorySource)
}
}
// Authoritative cluster membership writes previous nodes back too. Read from
// the unified store, an offline member keeps its source ID and agent link but
// stops presenting a temperature, and a member missing from /nodes keeps a
// reading only inside the carry window.
func TestPVEMembershipReconcileKeepsPreviousNodeIdentity(t *testing.T) {
cfg := &config.Config{PVEInstances: []config.PVEInstance{{
Name: "cluster-api", Host: "https://pve-a:8006", IsCluster: true, ClusterName: "production",
}}}
m := newUnreachableTestMonitor(t, cfg)
adapter := unifiedresources.NewMonitorAdapter(unifiedresources.NewRegistry(nil))
m.resourceStore = adapter
instanceCfg := &cfg.PVEInstances[0]
now := time.Now()
polled := func(name string, tempAge time.Duration) models.Node {
node := m.placeholderNodeForInstance("cluster-api", instanceCfg, name)
node.Status = "online"
node.ConnectionHealth = "healthy"
node.LastSeen = now
node.Temperature = &models.Temperature{CPUPackage: 60, Available: true, HasCPU: true, LastUpdate: now.Add(-tempAge)}
return node
}
offlineMember := polled("pve-a", 0)
offlineMember.LinkedAgentID = "agent-pve-a"
m.state.UpdateNodesForInstance("cluster-api", []models.Node{
offlineMember, polled("pve-b", 0), polled("pve-c", m.nodeTemperatureCarryWindow()+time.Minute),
})
adapter.PopulateFromSnapshot(m.state.GetSnapshot())
client := &membershipPVEClient{statuses: []proxmox.ClusterStatus{
{Type: "cluster", Name: "production", Quorate: 1},
{Type: "node", Name: "pve-a", Online: 0},
{Type: "node", Name: "pve-b", Online: 1},
{Type: "node", Name: "pve-c", Online: 1},
}}
got := pveNodeByName(m.reconcilePVENodeInventory(
context.Background(), "cluster-api", instanceCfg, client, nil, m.snapshotPrevNodes("cluster-api"),
))
if node := got["pve-a"]; node.ID != "production-pve-a" || node.Status != "offline" || node.Temperature != nil || node.LinkedAgentID != "agent-pve-a" {
t.Fatalf("offline member = %#v, want production-pve-a offline with its agent link and no temperature", node)
}
if node := got["pve-b"]; node.ID != "production-pve-b" || node.Status != "unknown" || node.Temperature == nil {
t.Fatalf("unobserved member = %#v, want production-pve-b unknown with its recent temperature", node)
}
if node := got["pve-c"]; node.ID != "production-pve-c" || node.Status != "unknown" || node.Temperature != nil {
t.Fatalf("unobserved member = %#v, want production-pve-c unknown without its stale temperature", node)
}
// Writing the reconciled inventory back keeps every member the same
// unified resource.
unifiedIDs := func() map[string]string {
ids := make(map[string]string)
for _, node := range adapter.Nodes() {
ids[node.NodeName()] = node.ID()
}
return ids
}
before := unifiedIDs()
m.state.UpdateNodesForInstance("cluster-api", sortedPVENodeInventory(got))
adapter.PopulateFromSnapshot(m.state.GetSnapshot())
if after := unifiedIDs(); len(after) != 3 || after["pve-a"] != before["pve-a"] || after["pve-b"] != before["pve-b"] || after["pve-c"] != before["pve-c"] {
t.Fatalf("unified IDs after writeback = %v, want %v", after, before)
}
}
// A temperature carried over from an earlier poll keeps a lapsed host agent's
// lease: a reading the agent supplied (stamped no later than its last report)
// is dropped once the agent stops reporting, even when its latest report had
// no sensors, while a reading taken after that report keeps the carry window.
// The same rule bounds what an instance outage preserves within node grace.
func TestNodeTemperatureCarryRespectsAgentLease(t *testing.T) {
now := time.Now()
silentSince := now.Add(-hostAgentHealthWindow(30) - time.Minute)
instanceCfg := config.PVEInstance{Name: "pve1", Host: "https://10.0.0.1:8006"}
base := models.Node{ID: "pve1-node1", NodeIdentity: "pve1-node1", Name: "node1", Instance: "pve1", Status: "online", Type: "node", LastSeen: now}
for _, tc := range []struct {
name string
readingAt time.Time
agentSensors bool
wantCarried bool
}{
{name: "reading from the silent agent", readingAt: silentSince, agentSensors: true},
{name: "silent agent whose last report had no sensors", readingAt: silentSince.Add(-30 * time.Second)},
{name: "SSH reading taken after the agent went silent", readingAt: now.Add(-time.Minute), agentSensors: true, wantCarried: true},
} {
t.Run(tc.name, func(t *testing.T) {
adapter := unifiedresources.NewMonitorAdapter(unifiedresources.NewRegistry(nil))
m := &Monitor{
config: &config.Config{TemperatureMonitoringEnabled: true, PVEInstances: []config.PVEInstance{instanceCfg}},
state: models.NewState(),
resourceStore: adapter,
nodeLastOnline: map[string]time.Time{base.ID: now},
}
agent := models.Host{ID: "agent-1", Hostname: "node1", Status: "offline", LinkedNodeID: base.ID, IntervalSeconds: 30, LastSeen: silentSince}
if tc.agentSensors {
agent.Sensors = models.HostSensorSummary{TemperatureCelsius: map[string]float64{"cpu_package": 92}}
}
published := base
published.Temperature = &models.Temperature{CPUPackage: 92, CPUMin: 92, CPUMaxRecord: 92, Available: true, HasCPU: true, LastUpdate: tc.readingAt}
m.state.UpdateNodesForInstance("pve1", []models.Node{published})
snapshot := m.state.GetSnapshot()
snapshot.Hosts = []models.Host{agent}
adapter.PopulateFromSnapshot(snapshot)
polled := base
m.collectNodeTemperatureData(context.Background(), "pve1", &instanceCfg, proxmox.Node{Node: "node1"}, &polled, m.snapshotPrevNodes("pve1"), "online")
if carried := polled.Temperature != nil; carried != tc.wantCarried {
t.Fatalf("failed collection carried = %v (%#v), want %v", carried, polled.Temperature, tc.wantCarried)
}
preserved := m.preserveOrExpireNodes(m.snapshotPrevNodes("pve1"))
if len(preserved) != 1 || preserved[0].Status != "online" {
t.Fatalf("preserved = %#v, want the node online within grace", preserved)
}
if kept := preserved[0].Temperature != nil; kept != tc.wantCarried {
t.Fatalf("outage preservation kept temperature = %v, want %v", kept, tc.wantCarried)
}
})
}
}
// A carried node reading keeps the node's CPU temperature steady through a
// failed collection, but never its disk temperatures: those also stamp the
// node's physical disks, which have their own freshness and standby state.
// Here a current agent whose only sensor is a SMART disk reports that disk in
// standby, so collection returns nothing; the disk must not be re-stamped with
// its pre-sleep reading through the carry.
func TestCarriedNodeTemperatureLeavesDiskReadingsOut(t *testing.T) {
now := time.Now()
instanceCfg := config.PVEInstance{Name: "pve1", Host: "https://10.0.0.1:8006"}
base := models.Node{ID: "pve1-node1", NodeIdentity: "pve1-node1", Name: "node1", Instance: "pve1", Status: "online", Type: "node", LastSeen: now}
disk := models.PhysicalDisk{ID: "pve1-node1-sda", Node: "node1", Instance: "pve1", DevPath: "/dev/sda", Serial: "WD-STANDBY", Type: "hdd"}
for _, tc := range []struct {
name string
previous models.Temperature
wantCPU bool
}{
{
name: "CPU and disk reading",
previous: models.Temperature{
Available: true, HasCPU: true, CPUPackage: 50, HasSMART: true, HasNVMe: true,
SMART: []models.DiskTemp{{Device: "/dev/sda", Serial: "WD-STANDBY", Temperature: 38}},
NVMe: []models.NVMeTemp{{Device: "nvme0", Temp: 44}},
},
wantCPU: true,
},
{
name: "disk-only reading",
previous: models.Temperature{
Available: true, HasSMART: true,
SMART: []models.DiskTemp{{Device: "/dev/sda", Serial: "WD-STANDBY", Temperature: 38}},
},
},
} {
t.Run(tc.name, func(t *testing.T) {
adapter := unifiedresources.NewMonitorAdapter(unifiedresources.NewRegistry(nil))
m := &Monitor{
config: &config.Config{TemperatureMonitoringEnabled: true, PVEInstances: []config.PVEInstance{instanceCfg}},
state: models.NewState(),
resourceStore: adapter,
nodeLastOnline: map[string]time.Time{base.ID: now},
}
published := base
previous := tc.previous
previous.LastUpdate = now.Add(-time.Minute)
published.Temperature = &previous
m.state.UpdateNodesForInstance("pve1", []models.Node{published})
snapshot := m.state.GetSnapshot()
snapshot.Hosts = []models.Host{{
ID: "agent-1", Hostname: "node1", Status: "online", LinkedNodeID: base.ID, IntervalSeconds: 30, LastSeen: now,
Sensors: models.HostSensorSummary{SMART: []models.HostDiskSMART{{Device: "sda", Serial: "WD-STANDBY", Standby: true}}},
}}
adapter.PopulateFromSnapshot(snapshot)
polled := base
m.collectNodeTemperatureData(context.Background(), "pve1", &instanceCfg, proxmox.Node{Node: "node1"}, &polled, m.snapshotPrevNodes("pve1"), "online")
if tc.wantCPU {
if polled.Temperature == nil || polled.Temperature.CPUPackage != 50 || !polled.Temperature.LastUpdate.Equal(previous.LastUpdate) {
t.Fatalf("carried temperature = %#v, want CPU 50 with its original timestamp", polled.Temperature)
}
if len(polled.Temperature.SMART) != 0 || len(polled.Temperature.NVMe) != 0 || polled.Temperature.HasSMART || polled.Temperature.HasNVMe {
t.Fatalf("carried temperature kept disk readings: %#v", polled.Temperature)
}
} else if polled.Temperature != nil {
t.Fatalf("carried temperature = %#v, want none when only disk readings were left", polled.Temperature)
}
if stamped := mergeNVMeTempsIntoDisks([]models.PhysicalDisk{disk}, []models.Node{polled}); stamped[0].Temperature != 0 {
t.Fatalf("standby disk stamped with %d°C from a carried reading, want no reading", stamped[0].Temperature)
}
// Outage preservation follows the same rule.
preserved := m.preserveOrExpireNodes(m.snapshotPrevNodes("pve1"))
if len(preserved) != 1 {
t.Fatalf("preserved = %#v, want one node", preserved)
}
if got := preserved[0].Temperature; (got != nil) != tc.wantCPU || (got != nil && (len(got.SMART) != 0 || len(got.NVMe) != 0)) {
t.Fatalf("preserved temperature = %#v, want CPU only (%v)", got, tc.wantCPU)
}
})
}
}
// Two Proxmox connections can each have a node with the same name (one "px1"
// per site). The poller's temperature lookup must never hand one site's node the
// host agent, or the agent's cluster-sibling SSH readings, of the other.
func TestNodeTemperatureStaysWithinSameNamedNodesSite(t *testing.T) {
instanceCfg := config.PVEInstance{Name: "siteB"}
poll := func(m *Monitor, instance, nodeName string) *models.Temperature {
m.config = &config.Config{TemperatureMonitoringEnabled: true}
polled := readStateTestNode(instance, nodeName)
m.collectNodeTemperatureData(context.Background(), instance, &instanceCfg, proxmox.Node{Node: nodeName}, &polled, nil, "online")
return polled.Temperature
}
sites := func() []models.Node {
return []models.Node{readStateTestNode("siteA", "px1"), readStateTestNode("siteA", "px2"), readStateTestNode("siteB", "px1")}
}
t.Run("agent linked to the other site's node", func(t *testing.T) {
m := readStateMonitor(sites(), []models.Host{reportingTestHost("agent-a", "px1", "siteA-px1", 77)})
if temp := poll(m, "siteA", "px1"); temp == nil || temp.CPUPackage != 77 {
t.Fatalf("siteA px1 temperature = %#v, want its linked agent's 77", temp)
}
if temp := poll(m, "siteB", "px1"); temp != nil {
t.Fatalf("siteB px1 took site A's agent reading: %#v", temp)
}
})
t.Run("unlinked agent whose hostname several nodes share", func(t *testing.T) {
m := readStateMonitor(sites(), []models.Host{reportingTestHost("agent-x", "px1", "", 66)})
for _, instance := range []string{"siteA", "siteB"} {
if temp := poll(m, instance, "px1"); temp != nil {
t.Fatalf("%s px1 took an agent the linker could not place: %#v", instance, temp)
}
}
})
t.Run("unlinked agent whose hostname one node has", func(t *testing.T) {
m := readStateMonitor(sites(), []models.Host{reportingTestHost("agent-y", "px2", "", 61)})
if temp := poll(m, "siteA", "px2"); temp == nil || temp.CPUPackage != 61 {
t.Fatalf("siteA px2 temperature = %#v, want the unambiguous agent's 61", temp)
}
})
t.Run("two unlinked agents with the node's hostname", func(t *testing.T) {
m := readStateMonitor(sites(), []models.Host{reportingTestHost("agent-y1", "px2", "", 61), reportingTestHost("agent-y2", "px2", "", 62)})
if temp := poll(m, "siteA", "px2"); temp != nil {
t.Fatalf("siteA px2 took one of two candidate agents: %#v", temp)
}
})
t.Run("one cluster added through two connections", func(t *testing.T) {
// The state folds both views of pve01 into one slot, held under whichever
// view merged last, and the agent link follows the slot. Each view's poll
// must still take the agent's reading.
var instances []config.PVEInstance
for _, name := range []string{"enacon-a", "enacon-b"} {
instances = append(instances, config.PVEInstance{
Name: name, IsCluster: true, ClusterName: "enacon",
ClusterEndpoints: []config.ClusterEndpoint{{NodeName: "pve01", Host: "https://192.168.1.11:8006", Fingerprint: "AA:AA"}},
})
}
adapter := unifiedresources.NewMonitorAdapter(unifiedresources.NewRegistry(nil))
m := &Monitor{config: &config.Config{TemperatureMonitoringEnabled: true, PVEInstances: instances}, state: models.NewState(), resourceStore: adapter}
view := func(instanceCfg *config.PVEInstance) models.Node {
return models.Node{
ID: instanceCfg.Name + "-pve01", NodeIdentity: instanceCfg.Name + "-pve01", Name: "pve01", Instance: instanceCfg.Name,
ClusterName: "enacon", IsClusterMember: true, Host: "https://192.168.1.11:8006",
TLSFingerprint: pveNodeTLSFingerprint(instanceCfg, "pve01"), Status: "online", LastSeen: time.Now(),
}
}
m.state.UpdateNodesForInstance("enacon-a", []models.Node{view(&instances[0])})
m.state.UpsertHost(reportingTestHost("agent-pve01", "pve01", "", 64))
if err := m.state.LinkHostAgentToNode("agent-pve01", "enacon-a-pve01"); err != nil {
t.Fatalf("LinkHostAgentToNode: %v", err)
}
adapter.PopulateFromSnapshot(m.state.GetSnapshot())
for round := 0; round < 3; round++ {
for i := range instances {
polled := view(&instances[i])
m.collectNodeTemperatureData(context.Background(), instances[i].Name, &instances[i], proxmox.Node{Node: "pve01"}, &polled, nil, "online")
if polled.Temperature == nil || polled.Temperature.CPUPackage != 64 {
t.Fatalf("round %d %s temperature = %#v, want the folded node's agent reading 64", round, instances[i].Name, polled.Temperature)
}
m.state.UpdateNodesForInstance(instances[i].Name, []models.Node{polled})
adapter.PopulateFromSnapshot(m.state.GetSnapshot())
}
}
if nodes := m.state.GetSnapshot().Nodes; len(nodes) != 1 {
t.Fatalf("state holds %d nodes, want both views folded into one", len(nodes))
}
})
t.Run("unclustered node whose address two sites' namesakes share", func(t *testing.T) {
// Both site nodes would fold with the polled view by address alone; the
// state declines an ambiguous alias, so the lookup must take neither.
nodes := sites()
for i := range nodes {
nodes[i].ClusterName = map[string]string{"siteA": "a", "siteB": "b"}[nodes[i].Instance]
nodes[i].Host = "https://10.0.0.5:8006"
}
m := readStateMonitor(nodes, []models.Host{reportingTestHost("agent-a", "px1", "siteA-px1", 77)})
polled := readStateTestNode("siteC", "px1")
polled.Host = "https://10.0.0.5:8006"
m.config = &config.Config{TemperatureMonitoringEnabled: true}
m.collectNodeTemperatureData(context.Background(), "siteC", &config.PVEInstance{Name: "siteC"}, proxmox.Node{Node: "px1"}, &polled, nil, "online")
if polled.Temperature != nil {
t.Fatalf("siteC px1 took a namesake's agent reading: %#v", polled.Temperature)
}
})
t.Run("multi-homed host added through two standalone connections", func(t *testing.T) {
// The agent reports both connection addresses, so the state folds the two
// views into one slot through it. Each view's poll must take its reading.
adapter := unifiedresources.NewMonitorAdapter(unifiedresources.NewRegistry(nil))
m := &Monitor{config: &config.Config{TemperatureMonitoringEnabled: true}, state: models.NewState(), resourceStore: adapter}
views := map[string]string{"lan-a": "https://10.0.0.5:8006", "lan-b": "https://10.0.1.5:8006"}
view := func(instance string) models.Node {
polled := readStateTestNode(instance, "minipc")
polled.Host = views[instance]
return polled
}
m.state.UpdateNodesForInstance("lan-a", []models.Node{view("lan-a")})
agent := reportingTestHost("agent-minipc", "minipc", "", 64)
agent.ReportIP = "10.0.0.5"
agent.NetworkInterfaces = []models.HostNetworkInterface{{Name: "eth0", Addresses: []string{"10.0.0.5/24"}}, {Name: "eth1", Addresses: []string{"10.0.1.5/24"}}}
m.state.UpsertHost(agent)
if err := m.state.LinkHostAgentToNode("agent-minipc", "lan-a-minipc"); err != nil {
t.Fatalf("LinkHostAgentToNode: %v", err)
}
adapter.PopulateFromSnapshot(m.state.GetSnapshot())
for round := 0; round < 3; round++ {
for _, instance := range []string{"lan-a", "lan-b"} {
polled := view(instance)
m.collectNodeTemperatureData(context.Background(), instance, &config.PVEInstance{Name: instance}, proxmox.Node{Node: "minipc"}, &polled, nil, "online")
if polled.Temperature == nil || polled.Temperature.CPUPackage != 64 {
t.Fatalf("round %d %s temperature = %#v, want the folded host's agent reading 64", round, instance, polled.Temperature)
}
m.state.UpdateNodesForInstance(instance, []models.Node{polled})
adapter.PopulateFromSnapshot(m.state.GetSnapshot())
}
}
if nodes := m.state.GetSnapshot().Nodes; len(nodes) != 1 {
t.Fatalf("state holds %d nodes, want both views folded into one", len(nodes))
}
})
t.Run("namesake's agent reporting the polled node's address", func(t *testing.T) {
// Site A's px1 agent also reports 10.0.1.5, the address site B's px1
// uses. The fingerprints contradict, so site B's node is another machine.
siteA := readStateTestNode("siteA", "px1")
siteA.Host = "https://10.0.0.5:8006"
agent := reportingTestHost("agent-a", "px1", "siteA-px1", 77)
agent.ReportIP = "10.0.0.5"
agent.NetworkInterfaces = []models.HostNetworkInterface{{Name: "eth0", Addresses: []string{"10.0.0.5/24", "10.0.1.5/24"}}}
m := readStateMonitor([]models.Node{siteA}, []models.Host{agent})
m.config = &config.Config{TemperatureMonitoringEnabled: true, PVEInstances: []config.PVEInstance{
{Name: "siteA", Host: "https://10.0.0.5:8006", Fingerprint: "AA:AA"},
{Name: "siteB", Host: "https://10.0.1.5:8006", Fingerprint: "BB:BB"},
}}
polled := readStateTestNode("siteB", "px1")
polled.Host = "https://10.0.1.5:8006"
polled.TLSFingerprint = pveNodeTLSFingerprint(&m.config.PVEInstances[1], "px1")
m.collectNodeTemperatureData(context.Background(), "siteB", &m.config.PVEInstances[1], proxmox.Node{Node: "px1"}, &polled, nil, "online")
if polled.Temperature != nil {
t.Fatalf("siteB px1 took site A's agent reading: %#v", polled.Temperature)
}
})
t.Run("sibling reading across the two connections of one cluster", func(t *testing.T) {
// A cluster added twice can keep px1 under one connection's view and the
// reporting px2 under the other's; both connections list the same
// members with the same fingerprints, so the reading is px1's.
var instances []config.PVEInstance
for _, name := range []string{"enacon-a", "enacon-b"} {
instances = append(instances, config.PVEInstance{
Name: name, IsCluster: true, ClusterName: "enacon",
ClusterEndpoints: []config.ClusterEndpoint{{NodeName: "px1", Fingerprint: "11:11"}, {NodeName: "px2", Fingerprint: "22:22"}},
})
}
member := func(instanceCfg *config.PVEInstance, name string) models.Node {
node := readStateTestNode(instanceCfg.Name, name)
node.ClusterName, node.IsClusterMember = "enacon", true
node.TLSFingerprint = pveNodeTLSFingerprint(instanceCfg, name)
return node
}
m := readStateMonitor(
[]models.Node{member(&instances[0], "px1"), member(&instances[1], "px2")},
[]models.Host{reportingTestHost("agent-px2", "px2", "enacon-b-px2", 50)},
)
m.applyClusterSensors("agent-px2", clusterSensorReport("px1", 88), time.Now())
m.config = &config.Config{TemperatureMonitoringEnabled: true, PVEInstances: instances}
polled := member(&instances[0], "px1")
m.collectNodeTemperatureData(context.Background(), "enacon-a", &instances[0], proxmox.Node{Node: "px1"}, &polled, nil, "online")
if polled.Temperature == nil || polled.Temperature.CPUPackage != 88 {
t.Fatalf("px1 temperature = %#v, want the sibling reading 88", polled.Temperature)
}
})
t.Run("cluster sibling reading from the other site", func(t *testing.T) {
m := readStateMonitor(sites(), []models.Host{reportingTestHost("agent-a2", "px2", "siteA-px2", 50)})
m.applyClusterSensors("agent-a2", clusterSensorReport("px1", 88), time.Now())
if temp := poll(m, "siteA", "px1"); temp == nil || temp.CPUPackage != 88 {
t.Fatalf("siteA px1 temperature = %#v, want its cluster sibling's 88", temp)
}
if temp := poll(m, "siteB", "px1"); temp != nil {
t.Fatalf("siteB px1 took site A's cluster sibling reading: %#v", temp)
}
})
}
// Exercise the actual scheduler result, admission and health entry points.
// No provider request, backup operation or database is involved in this control.
func TestMonitorPersistentFailureBackoffAndRecovery(t *testing.T) {
m := &Monitor{
config: &config.Config{},
circuitBreakers: make(map[string]*circuitBreaker),
pollStatusMap: make(map[string]*pollStatus),
failureCounts: make(map[string]int),
lastOutcome: make(map[string]taskOutcome),
}
failed := ScheduledTask{InstanceType: InstanceTypePVE, InstanceName: "offline"}
healthy := ScheduledTask{InstanceType: InstanceTypePBS, InstanceName: "healthy"}
for failure := 1; failure <= 130; failure++ {
m.recordTaskResult(failed.InstanceType, failed.InstanceName, errors.New("connection timeout"))
if failure < 3 {
continue
}
cb := m.ensureBreaker(schedulerKey(failed.InstanceType, failed.InstanceName))
state, count, retryAt := cb.State()
if state != "open" || count != failure || !retryAt.After(time.Now()) || m.allowExecution(failed) {
t.Fatalf("failure %d lost scheduler containment: state=%s count=%d retry=%v", failure, state, count, retryAt)
}
if !m.allowExecution(healthy) {
t.Fatal("one failing connection blocked another instance")
}
health := m.SchedulerHealth()
if len(health.Breakers) != 1 || health.Breakers[0].Failures != failure || !health.Breakers[0].RetryAt.Equal(retryAt) || health.Breakers[0].State != "open" {
t.Fatalf("served scheduler health lost the owned retry fence: %+v", health.Breakers)
}
// Simulate reaching the scheduled retry without sleeping or polling a host.
if !cb.allow(retryAt) {
t.Fatal("due probe was not admitted")
}
}
m.recordTaskResult(failed.InstanceType, failed.InstanceName, nil)
if !m.allowExecution(failed) || len(m.SchedulerHealth().Breakers) != 0 {
t.Fatal("successful poll did not restore scheduler admission and health")
}
status := m.pollStatusMap[schedulerKey(failed.InstanceType, failed.InstanceName)]
if status.ConsecutiveFailures != 0 || !status.LastErrorAt.IsZero() || status.LastErrorMessage != "" || status.LastErrorCategory != "" || status.LastSuccess.IsZero() {
t.Fatalf("successful poll retained an outstanding failure: %+v", status)
}
for i := 0; i < 3; i++ {
m.recordTaskResult(failed.InstanceType, failed.InstanceName, errors.New("connection timeout"))
}
cb := m.ensureBreaker(schedulerKey(failed.InstanceType, failed.InstanceName))
if cb.retryInterval != 40*time.Second || m.allowExecution(failed) {
t.Fatal("new failure episode did not restart the original backoff")
}
}
func TestGuestConfigPlacement(t *testing.T) {
for _, kind := range []string{"vm", "container", "lxc"} {
for _, reverse := range []bool{false, true} {
for _, tc := range []struct {
name, instance, node, wantInstance, wantNode, failure string
}{
{name: "instance constraint", instance: "second", wantInstance: "second", wantNode: "node-b"},
{name: "node constraint", node: "node-b", wantInstance: "second", wantNode: "node-b"},
{name: "bare VMID ambiguous", failure: "ambiguous"},
{name: "unknown instance", instance: "absent", failure: "unable to resolve"},
{name: "unknown node", node: "absent", failure: "unable to resolve"},
} {
t.Run(kind+"/"+tc.name+"/reverse="+map[bool]string{true: "yes", false: "no"}[reverse], func(t *testing.T) {
state := models.NewState()
vms := []models.VM{{ID: "a", VMID: 105, Instance: "first", Node: "node-a"}, {ID: "b", VMID: 105, Instance: "second", Node: "node-b"}}
if reverse {
vms[0], vms[1] = vms[1], vms[0]
}
if kind == "vm" {
state.VMs = vms
} else {
for _, vm := range vms {
state.Containers = append(state.Containers, models.Container{ID: vm.ID, VMID: vm.VMID, Instance: vm.Instance, Node: vm.Node})
}
}
first := &guestConfigCoverageClient{vmConfig: map[string]interface{}{"origin": "first"}, containerConfig: map[string]interface{}{"origin": "first"}}
second := &guestConfigCoverageClient{vmConfig: map[string]interface{}{"origin": "second"}, containerConfig: map[string]interface{}{"origin": "second"}}
m := &Monitor{state: state, pveClients: map[string]PVEClientInterface{"first": first, "second": second}}
got, err := m.GetGuestConfig(context.Background(), kind, tc.instance, tc.node, 105)
if tc.failure != "" {
if err == nil || !strings.Contains(err.Error(), tc.failure) || got != nil || first.vmCalls+first.containerCalls+second.vmCalls+second.containerCalls != 0 {
t.Fatalf("unresolved identity reached a provider: result=%v err=%v", got, err)
}
return
}
if err != nil || got["origin"] != tc.wantInstance || second.lastNode != tc.wantNode || second.lastVMID != 105 || second.vmCalls+second.containerCalls != 1 || first.vmCalls+first.containerCalls != 0 {
t.Fatalf("placement constraint was not honoured: result=%v err=%v target=%s/%d", got, err, second.lastNode, second.lastVMID)
}
})
}
}
}
}
func TestGuestConfigPlacementCurrentInventory(t *testing.T) {
for _, kind := range []string{"vm", "container"} {
for _, empty := range []bool{false, true} {
t.Run(kind+"/empty="+map[bool]string{true: "yes", false: "no"}[empty], func(t *testing.T) {
legacy := models.NewState()
legacy.VMs = []models.VM{{ID: "stale-vm", VMID: 105, Instance: "old", Node: "old-node"}}
legacy.Containers = []models.Container{{ID: "stale-ct", VMID: 105, Instance: "old", Node: "old-node"}}
registry := unifiedresources.NewRegistry(nil)
if !empty {
registry.IngestSnapshot(models.StateSnapshot{
VMs: []models.VM{{ID: "current-vm", VMID: 105, Instance: "current", Node: "current-node"}},
Containers: []models.Container{{ID: "current-ct", VMID: 105, Instance: "current", Node: "current-node"}},
})
}
old, current := &guestConfigCoverageClient{}, &guestConfigCoverageClient{}
m := &Monitor{state: legacy, resourceStore: unifiedresources.NewMonitorAdapter(registry), pveClients: map[string]PVEClientInterface{"old": old, "current": current}}
_, err := m.GetGuestConfig(context.Background(), kind, "", "", 105)
if empty {
if err == nil || current.vmCalls+current.containerCalls != 0 {
t.Fatal("empty current inventory fell back to stale placement")
}
} else if err != nil || current.lastNode != "current-node" || current.vmCalls+current.containerCalls != 1 {
t.Fatalf("current inventory did not own placement: %v", err)
}
if old.vmCalls+old.containerCalls != 0 {
t.Fatal("stale connection was read")
}
})
}
}
}
func TestGuestConfigPlacementAdverseAndDirect(t *testing.T) {
for _, kind := range []string{"vm", "container"} {
t.Run(kind, func(t *testing.T) {
failure := errors.New("provider read failed")
client := &guestConfigCoverageClient{vmErr: failure, containerErr: failure}
m := &Monitor{pveClients: map[string]PVEClientInterface{"chosen": client}}
_, err := m.GetGuestConfig(context.Background(), kind, "chosen", "chosen-node", 105)
if !errors.Is(err, failure) || client.lastNode != "chosen-node" || client.vmCalls+client.containerCalls != 1 {
t.Fatal("explicit placement or original provider failure was changed")
}
state := models.NewState()
state.VMs = []models.VM{{ID: "one", VMID: 105, Instance: "chosen", Node: "same-node"}, {ID: "two", VMID: 105, Instance: "other", Node: "same-node"}}
state.Containers = []models.Container{{ID: "one", VMID: 105, Instance: "chosen", Node: "same-node"}, {ID: "two", VMID: 105, Instance: "other", Node: "same-node"}}
m.state = state
_, err = m.GetGuestConfig(context.Background(), kind, "", "same-node", 105)
if err == nil || !strings.Contains(err.Error(), "ambiguous") || client.vmCalls+client.containerCalls != 1 {
t.Fatal("two installations sharing a node name were guessed")
}
state.VMs, state.Containers = []models.VM{{VMID: 105, Instance: "chosen"}}, []models.Container{{VMID: 105, Instance: "chosen"}}
_, err = m.GetGuestConfig(context.Background(), kind, "chosen", "", 105)
if err == nil || client.vmCalls+client.containerCalls != 1 {
t.Fatal("incomplete placement reached a provider")
}
})
}
}
type concurrentGuestConfigClient struct {
stubPVEClient
calls atomic.Int32
}
func (c *concurrentGuestConfigClient) GetVMConfig(_ context.Context, node string, vmid int) (map[string]interface{}, error) {
c.calls.Add(1)
return map[string]interface{}{"node": node, "vmid": vmid}, nil
}
func TestGuestConfigPlacementConcurrentSnapshot(t *testing.T) {
state := models.NewState()
state.UpdateVMsForInstance("chosen", []models.VM{{ID: "vm", VMID: 105, Instance: "chosen", Node: "a"}})
client := &concurrentGuestConfigClient{}
m := &Monitor{state: state, pveClients: map[string]PVEClientInterface{"chosen": client}}
var wg sync.WaitGroup
wg.Add(1)
go func() {
defer wg.Done()
for i := 0; i < 100; i++ {
state.UpdateVMsForInstance("chosen", []models.VM{{ID: "vm", VMID: 105, Instance: "chosen", Node: "b"}})
state.UpdateVMsForInstance("chosen", []models.VM{{ID: "vm", VMID: 105, Instance: "chosen", Node: "a"}})
}
}()
for i := 0; i < 100; i++ {
got, err := m.GetGuestConfig(context.Background(), "vm", "chosen", "", 105)
if err != nil || got["vmid"] != 105 || got["node"] != "a" && got["node"] != "b" {
t.Errorf("invalid concurrent snapshot: %v %v", got, err)
}
}
wg.Wait()
if client.calls.Load() != 100 {
t.Fatal("concurrent reads were replayed or lost")
}
}