mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-04-28 03:20:11 +00:00
432 lines
12 KiB
Go
432 lines
12 KiB
Go
package monitoring
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"net"
|
|
"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/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()}
|
|
|
|
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].LinkedHostAgentID != "host-1" {
|
|
t.Fatalf("LinkedHostAgentID = %q, want %q", monitor.state.Nodes[0].LinkedHostAgentID, "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 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, ""); 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, ""); 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, ""); 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, ""); got != "https://192.168.1.10:8006" {
|
|
t.Fatalf("override IP preference = %q, want %q", got, "https://192.168.1.10:8006")
|
|
}
|
|
|
|
endpoint.Fingerprint = "ep-fingerprint"
|
|
if got := clusterEndpointEffectiveURL(endpoint, true, ""); got != "https://192.168.1.10:8006" {
|
|
t.Fatalf("per-endpoint fingerprint should allow IP override, got %q", got)
|
|
}
|
|
|
|
endpoint.Fingerprint = ""
|
|
if got := clusterEndpointEffectiveURL(endpoint, true, "cluster-base-fingerprint"); got != "https://node.local:8006" {
|
|
t.Fatalf("base fingerprint must not force IP routing for other cluster nodes, got %q", got)
|
|
}
|
|
|
|
endpoint = config.ClusterEndpoint{}
|
|
if got := clusterEndpointEffectiveURL(endpoint, true, ""); got != "" {
|
|
t.Fatalf("empty endpoint = %q, want empty", got)
|
|
}
|
|
}
|
|
|
|
func TestBuildClusterClientEndpoints_PrefersOverrideWhenEndpointFingerprintPresent(t *testing.T) {
|
|
pve := config.PVEInstance{
|
|
Name: "cluster-a",
|
|
Host: "https://cluster-a.local:8006",
|
|
VerifySSL: true,
|
|
IsCluster: true,
|
|
ClusterName: "cluster-a",
|
|
ClusterEndpoints: []config.ClusterEndpoint{
|
|
{
|
|
NodeName: "node1",
|
|
Host: "https://node1.local:8006",
|
|
IP: "10.15.5.11",
|
|
IPOverride: "10.15.2.11",
|
|
Fingerprint: "node1-fp",
|
|
},
|
|
},
|
|
}
|
|
|
|
endpoints, fingerprints := buildClusterClientEndpoints(pve, config.DiscoveryConfig{})
|
|
|
|
if len(endpoints) != 2 {
|
|
t.Fatalf("expected endpoint plus main host fallback, got %d", len(endpoints))
|
|
}
|
|
if endpoints[0] != "https://10.15.2.11:8006" {
|
|
t.Fatalf("expected endpoint override URL first, got %q", endpoints[0])
|
|
}
|
|
if fingerprints["https://10.15.2.11:8006"] != "node1-fp" {
|
|
t.Fatalf("expected fingerprint to follow effective endpoint URL, got %q", fingerprints["https://10.15.2.11:8006"])
|
|
}
|
|
}
|
|
|
|
func TestProxmoxDiskMatchesExclude(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
disk proxmox.Disk
|
|
patterns []string
|
|
want bool
|
|
}{
|
|
{
|
|
name: "matches devpath directly",
|
|
disk: proxmox.Disk{DevPath: "/dev/sda"},
|
|
patterns: []string{"/dev/sda"},
|
|
want: true,
|
|
},
|
|
{
|
|
name: "matches basename from devpath",
|
|
disk: proxmox.Disk{DevPath: "/dev/sda"},
|
|
patterns: []string{"sda"},
|
|
want: true,
|
|
},
|
|
{
|
|
name: "does not match different device",
|
|
disk: proxmox.Disk{DevPath: "/dev/sdb"},
|
|
patterns: []string{"sda"},
|
|
want: false,
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
if got := proxmoxDiskMatchesExclude(tt.disk, tt.patterns); got != tt.want {
|
|
t.Fatalf("proxmoxDiskMatchesExclude(%+v, %v) = %t, want %t", tt.disk, tt.patterns, got, tt.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestBuildPhysicalDisksForNodeOmitsExcludedDisksAndClearsAlerts(t *testing.T) {
|
|
monitor := &Monitor{alertManager: alerts.NewManager()}
|
|
t.Cleanup(func() {
|
|
monitor.alertManager.Stop()
|
|
})
|
|
|
|
failingDisk := proxmox.Disk{
|
|
DevPath: "/dev/sda",
|
|
Model: "Samsung SSD",
|
|
Health: "FAILED",
|
|
Wearout: 1,
|
|
}
|
|
healthyDisk := proxmox.Disk{
|
|
DevPath: "/dev/sdb",
|
|
Model: "WD Blue",
|
|
Health: "PASSED",
|
|
Wearout: 100,
|
|
}
|
|
|
|
monitor.alertManager.CheckDiskHealth("inst", "node1", failingDisk)
|
|
if got := len(monitor.alertManager.GetActiveAlerts()); got == 0 {
|
|
t.Fatalf("expected a failing disk alert before exclusion handling")
|
|
}
|
|
|
|
disks := monitor.buildPhysicalDisksForNode(
|
|
"inst",
|
|
"node1",
|
|
[]proxmox.Disk{failingDisk, healthyDisk},
|
|
[]string{"sda"},
|
|
time.Date(2026, 3, 25, 12, 0, 0, 0, time.UTC),
|
|
)
|
|
|
|
if len(disks) != 1 {
|
|
t.Fatalf("expected 1 physical disk after exclusion, got %d", len(disks))
|
|
}
|
|
if disks[0].DevPath != "/dev/sdb" {
|
|
t.Fatalf("expected only /dev/sdb to remain, got %q", disks[0].DevPath)
|
|
}
|
|
|
|
for _, alert := range monitor.alertManager.GetActiveAlerts() {
|
|
if alert.ID == "disk-health-inst-node1-/dev/sda" || alert.ID == "disk-wearout-inst-node1-/dev/sda" {
|
|
t.Fatalf("expected excluded disk alerts to be cleared, still found %+v", alert)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestBuildClusterClientEndpoints_FallsBackToMainHostWhenOnlyBaseFingerprintExists(t *testing.T) {
|
|
pve := config.PVEInstance{
|
|
Name: "cluster-a",
|
|
Host: "https://cluster-a.example.com:8006",
|
|
Fingerprint: "cluster-base-fp",
|
|
VerifySSL: true,
|
|
IsCluster: true,
|
|
ClusterName: "cluster-a",
|
|
ClusterEndpoints: []config.ClusterEndpoint{
|
|
{
|
|
NodeName: "node1",
|
|
Host: "node1",
|
|
IP: "10.15.5.11",
|
|
},
|
|
{
|
|
NodeName: "node2",
|
|
Host: "node2",
|
|
IP: "10.15.5.12",
|
|
},
|
|
},
|
|
}
|
|
|
|
endpoints, fingerprints := buildClusterClientEndpoints(pve, config.DiscoveryConfig{})
|
|
|
|
if len(endpoints) != 1 {
|
|
t.Fatalf("expected only the main host fallback endpoint, got %d", len(endpoints))
|
|
}
|
|
if endpoints[0] != "https://cluster-a.example.com:8006" {
|
|
t.Fatalf("expected main host fallback, got %q", endpoints[0])
|
|
}
|
|
if len(fingerprints) != 0 {
|
|
t.Fatalf("expected no per-endpoint fingerprints, got %v", fingerprints)
|
|
}
|
|
}
|
|
|
|
func TestBuildClusterClientEndpoints_SkipsEndpointsOutsideDiscoverySubnetPolicy(t *testing.T) {
|
|
pve := config.PVEInstance{
|
|
Name: "cluster-a",
|
|
Host: "https://10.15.2.10:8006",
|
|
VerifySSL: false,
|
|
IsCluster: true,
|
|
ClusterName: "cluster-a",
|
|
ClusterEndpoints: []config.ClusterEndpoint{
|
|
{
|
|
NodeName: "node1",
|
|
IP: "10.15.5.11",
|
|
},
|
|
{
|
|
NodeName: "node2",
|
|
IP: "10.15.2.12",
|
|
},
|
|
},
|
|
}
|
|
|
|
discoveryCfg := config.DiscoveryConfig{
|
|
SubnetAllowlist: []string{"10.15.2.0/24"},
|
|
SubnetBlocklist: []string{"10.15.5.0/24"},
|
|
}
|
|
|
|
endpoints, fingerprints := buildClusterClientEndpoints(pve, discoveryCfg)
|
|
|
|
if len(endpoints) != 2 {
|
|
t.Fatalf("expected allowed endpoint plus main host fallback, got %d", len(endpoints))
|
|
}
|
|
if endpoints[0] != "https://10.15.2.12:8006" {
|
|
t.Fatalf("expected allowed management endpoint first, got %q", endpoints[0])
|
|
}
|
|
if endpoints[1] != "https://10.15.2.10:8006" {
|
|
t.Fatalf("expected main host fallback, got %q", endpoints[1])
|
|
}
|
|
if len(fingerprints) != 0 {
|
|
t.Fatalf("expected no fingerprints, got %v", fingerprints)
|
|
}
|
|
}
|
|
|
|
func TestBuildClusterClientEndpoints_SkipsHostnameResolvingOutsideDiscoverySubnetPolicy(t *testing.T) {
|
|
originalLookupIP := lookupIPFunc
|
|
lookupIPFunc = func(host string) ([]net.IP, error) {
|
|
switch host {
|
|
case "node1.local":
|
|
return []net.IP{net.ParseIP("10.15.5.11")}, nil
|
|
case "node2.local":
|
|
return []net.IP{net.ParseIP("10.15.2.12")}, nil
|
|
default:
|
|
return nil, fmt.Errorf("unexpected host %q", host)
|
|
}
|
|
}
|
|
defer func() {
|
|
lookupIPFunc = originalLookupIP
|
|
}()
|
|
|
|
pve := config.PVEInstance{
|
|
Name: "cluster-a",
|
|
Host: "https://10.15.2.10:8006",
|
|
Fingerprint: "cluster-base-fp",
|
|
VerifySSL: true,
|
|
IsCluster: true,
|
|
ClusterName: "cluster-a",
|
|
ClusterEndpoints: []config.ClusterEndpoint{
|
|
{
|
|
NodeName: "node1",
|
|
Host: "https://node1.local:8006",
|
|
IP: "10.15.5.11",
|
|
},
|
|
{
|
|
NodeName: "node2",
|
|
Host: "https://node2.local:8006",
|
|
IP: "10.15.2.12",
|
|
},
|
|
},
|
|
}
|
|
|
|
discoveryCfg := config.DiscoveryConfig{
|
|
SubnetAllowlist: []string{"10.15.2.0/24"},
|
|
SubnetBlocklist: []string{"10.15.5.0/24"},
|
|
}
|
|
|
|
endpoints, _ := buildClusterClientEndpoints(pve, discoveryCfg)
|
|
|
|
if len(endpoints) != 2 {
|
|
t.Fatalf("expected allowed hostname endpoint plus main host fallback, got %d", len(endpoints))
|
|
}
|
|
if endpoints[0] != "https://node2.local:8006" {
|
|
t.Fatalf("expected allowed hostname endpoint first, got %q", endpoints[0])
|
|
}
|
|
if endpoints[1] != "https://10.15.2.10:8006" {
|
|
t.Fatalf("expected main host fallback, got %q", endpoints[1])
|
|
}
|
|
}
|