From 5fe467c4e59f60557b709ee52c9ff1e72aa6e985 Mon Sep 17 00:00:00 2001 From: rcourtman Date: Thu, 5 Feb 2026 11:36:54 +0000 Subject: [PATCH] Add report engine and cluster client coverage --- .../cluster_client_additional_api2_test.go | 95 +++++ .../cluster_client_additional_api3_test.go | 34 ++ .../cluster_client_additional_api4_test.go | 115 +++++ .../cluster_client_additional_api_test.go | 399 ++++++++++++++++++ pkg/proxmox/cluster_client_failover_test.go | 108 +++++ pkg/reporting/engine_additional_test.go | 186 ++++++++ 6 files changed, 937 insertions(+) create mode 100644 pkg/proxmox/cluster_client_additional_api2_test.go create mode 100644 pkg/proxmox/cluster_client_additional_api3_test.go create mode 100644 pkg/proxmox/cluster_client_additional_api4_test.go create mode 100644 pkg/proxmox/cluster_client_additional_api_test.go create mode 100644 pkg/proxmox/cluster_client_failover_test.go create mode 100644 pkg/reporting/engine_additional_test.go diff --git a/pkg/proxmox/cluster_client_additional_api2_test.go b/pkg/proxmox/cluster_client_additional_api2_test.go new file mode 100644 index 000000000..fb46d54d0 --- /dev/null +++ b/pkg/proxmox/cluster_client_additional_api2_test.go @@ -0,0 +1,95 @@ +package proxmox + +import ( + "context" + "fmt" + "net/http" + "net/http/httptest" + "testing" +) + +func TestClusterClient_GetCephDF(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + if r.URL.Path == "/api2/json/nodes" { + fmt.Fprint(w, `{"data":[{"node":"node1","status":"online"}]}`) + return + } + if r.URL.Path == "/api2/json/cluster/ceph/df" { + fmt.Fprint(w, `{"data":{"data":{"stats":{"total_bytes":100,"total_used_bytes":20}}}}`) + return + } + w.WriteHeader(http.StatusNotFound) + })) + defer server.Close() + + cfg := ClientConfig{Host: server.URL, TokenName: "u@p!t", TokenValue: "v"} + cc := NewClusterClient("test", cfg, []string{server.URL}, nil) + + df, err := cc.GetCephDF(context.Background()) + if err != nil { + t.Fatalf("GetCephDF failed: %v", err) + } + if df == nil || df.Data.Stats.TotalBytes != 100 { + t.Fatalf("unexpected ceph df: %+v", df) + } +} + +func TestClusterClient_GetReplicationStatus(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + switch r.URL.Path { + case "/api2/json/nodes": + fmt.Fprint(w, `{"data":[{"node":"node1","status":"online"}]}`) + case "/api2/json/cluster/replication": + fmt.Fprint(w, `{"data":[{"id":"job1","guest":"vm/100","source":"node1"}]}`) + case "/api2/json/nodes/node1/replication/job1/status": + fmt.Fprint(w, `{"data":{"last_sync":1700000000,"duration":10,"fail_count":0,"state":"ok"}}`) + default: + w.WriteHeader(http.StatusNotFound) + } + })) + defer server.Close() + + cfg := ClientConfig{Host: server.URL, TokenName: "u@p!t", TokenValue: "v"} + cc := NewClusterClient("test", cfg, []string{server.URL}, nil) + + jobs, err := cc.GetReplicationStatus(context.Background()) + if err != nil { + t.Fatalf("GetReplicationStatus failed: %v", err) + } + if len(jobs) != 1 || jobs[0].ID != "job1" { + t.Fatalf("unexpected jobs: %+v", jobs) + } + if jobs[0].LastSyncUnix == 0 || jobs[0].State == "" { + t.Fatalf("expected enriched status fields, got %+v", jobs[0]) + } +} + +func TestClusterClient_IsClusterMemberFalseOnError(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + if r.URL.Path == "/api2/json/nodes" { + fmt.Fprint(w, `{"data":[{"node":"node1","status":"online"}]}`) + return + } + if r.URL.Path == "/api2/json/cluster/status" { + w.WriteHeader(http.StatusInternalServerError) + fmt.Fprint(w, `{"data":null}`) + return + } + w.WriteHeader(http.StatusNotFound) + })) + defer server.Close() + + cfg := ClientConfig{Host: server.URL, TokenName: "u@p!t", TokenValue: "v"} + cc := NewClusterClient("test", cfg, []string{server.URL}, nil) + + member, err := cc.IsClusterMember(context.Background()) + if err != nil { + t.Fatalf("IsClusterMember returned error: %v", err) + } + if member { + t.Fatal("expected IsClusterMember to return false when status fetch fails") + } +} diff --git a/pkg/proxmox/cluster_client_additional_api3_test.go b/pkg/proxmox/cluster_client_additional_api3_test.go new file mode 100644 index 000000000..eea10dd4f --- /dev/null +++ b/pkg/proxmox/cluster_client_additional_api3_test.go @@ -0,0 +1,34 @@ +package proxmox + +import ( + "context" + "fmt" + "net/http" + "net/http/httptest" + "testing" +) + +func TestClusterClient_GetContainerInterfaces_ErrorStatus(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/api2/json/nodes" { + w.Header().Set("Content-Type", "application/json") + fmt.Fprint(w, `{"data":[{"node":"node1","status":"online"}]}`) + return + } + if r.URL.Path == "/api2/json/nodes/node1/lxc/101/interfaces" { + w.WriteHeader(http.StatusInternalServerError) + fmt.Fprint(w, "boom") + return + } + w.WriteHeader(http.StatusNotFound) + })) + defer server.Close() + + cfg := ClientConfig{Host: server.URL, TokenName: "u@p!t", TokenValue: "v"} + cc := NewClusterClient("test", cfg, []string{server.URL}, nil) + + _, err := cc.GetContainerInterfaces(context.Background(), "node1", 101) + if err == nil { + t.Fatal("expected error for non-200 response") + } +} diff --git a/pkg/proxmox/cluster_client_additional_api4_test.go b/pkg/proxmox/cluster_client_additional_api4_test.go new file mode 100644 index 000000000..a0e9f8e53 --- /dev/null +++ b/pkg/proxmox/cluster_client_additional_api4_test.go @@ -0,0 +1,115 @@ +package proxmox + +import ( + "context" + "fmt" + "net/http" + "net/http/httptest" + "testing" + "time" +) + +func TestClusterClient_GetZFSPoolsWithDetails(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + switch r.URL.Path { + case "/api2/json/nodes": + fmt.Fprint(w, `{"data":[{"node":"node1","status":"online"}]}`) + case "/api2/json/nodes/node1/disks/zfs": + fmt.Fprint(w, `{"data":[{"name":"rpool","health":"ONLINE"}]}`) + case "/api2/json/nodes/node1/disks/zfs/rpool": + fmt.Fprint(w, `{"data":{"name":"rpool","state":"ONLINE","status":"ok","scan":"none","errors":"0"}}`) + default: + w.WriteHeader(http.StatusNotFound) + } + })) + defer server.Close() + + cfg := ClientConfig{Host: server.URL, TokenName: "u@p!t", TokenValue: "v"} + cc := NewClusterClient("test", cfg, []string{server.URL}, nil) + + pools, err := cc.GetZFSPoolsWithDetails(context.Background(), "node1") + if err != nil { + t.Fatalf("GetZFSPoolsWithDetails failed: %v", err) + } + if len(pools) != 1 || pools[0].Name != "rpool" { + t.Fatalf("unexpected pools: %+v", pools) + } + if pools[0].State != "ONLINE" { + t.Fatalf("expected state ONLINE, got %q", pools[0].State) + } +} + +func TestClusterClient_IsQuorate(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + if r.URL.Path == "/api2/json/nodes" { + fmt.Fprint(w, `{"data":[{"node":"node1","status":"online"}]}`) + return + } + if r.URL.Path == "/api2/json/cluster/status" { + fmt.Fprint(w, `{"data":[{"type":"cluster","name":"pve","quorate":1}]}`) + return + } + w.WriteHeader(http.StatusNotFound) + })) + defer server.Close() + + cfg := ClientConfig{Host: server.URL, TokenName: "u@p!t", TokenValue: "v"} + cc := NewClusterClient("test", cfg, []string{server.URL}, nil) + + quorate, err := cc.IsQuorate(context.Background()) + if err != nil { + t.Fatalf("IsQuorate failed: %v", err) + } + if !quorate { + t.Fatal("expected quorate true") + } +} + +func TestClusterClient_IsQuorateStandalone(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + if r.URL.Path == "/api2/json/nodes" { + fmt.Fprint(w, `{"data":[{"node":"node1","status":"online"}]}`) + return + } + if r.URL.Path == "/api2/json/cluster/status" { + fmt.Fprint(w, `{"data":[{"type":"node","name":"node1"}]}`) + return + } + w.WriteHeader(http.StatusNotFound) + })) + defer server.Close() + + cfg := ClientConfig{Host: server.URL, TokenName: "u@p!t", TokenValue: "v"} + cc := NewClusterClient("test", cfg, []string{server.URL}, nil) + + quorate, err := cc.IsQuorate(context.Background()) + if err != nil { + t.Fatalf("IsQuorate failed: %v", err) + } + if !quorate { + t.Fatal("expected quorate true for standalone node") + } +} + +func TestClusterClient_GetDisks_NoHealthyNodes(t *testing.T) { + cc := &ClusterClient{ + name: "test", + endpoints: []string{"node1", "node2"}, + clients: make(map[string]*Client), + nodeHealth: map[string]bool{"node1": false, "node2": false}, + lastError: make(map[string]string), + lastHealthCheck: map[string]time.Time{"node1": time.Now(), "node2": time.Now()}, + rateLimitUntil: make(map[string]time.Time), + } + + disks, err := cc.GetDisks(context.Background(), "node1") + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + if len(disks) != 0 { + t.Fatalf("expected empty disks on no healthy nodes, got %+v", disks) + } +} diff --git a/pkg/proxmox/cluster_client_additional_api_test.go b/pkg/proxmox/cluster_client_additional_api_test.go new file mode 100644 index 000000000..30bdcc893 --- /dev/null +++ b/pkg/proxmox/cluster_client_additional_api_test.go @@ -0,0 +1,399 @@ +package proxmox + +import ( + "context" + "fmt" + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +func TestClusterClient_GetNodeStatus(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + if r.URL.Path == "/api2/json/nodes" { + fmt.Fprint(w, `{"data":[{"node":"node1","status":"online"}]}`) + return + } + if r.URL.Path == "/api2/json/nodes/node1/status" { + fmt.Fprint(w, `{"data":{"cpu":0.25,"uptime":123}}`) + return + } + w.WriteHeader(http.StatusNotFound) + })) + defer server.Close() + + cfg := ClientConfig{Host: server.URL, TokenName: "u@p!t", TokenValue: "v"} + cc := NewClusterClient("test", cfg, []string{server.URL}, nil) + + status, err := cc.GetNodeStatus(context.Background(), "node1") + if err != nil { + t.Fatalf("GetNodeStatus failed: %v", err) + } + if status == nil || status.Uptime != 123 { + t.Fatalf("unexpected status: %+v", status) + } +} + +func TestClusterClient_GetAllStorage(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + if r.URL.Path == "/api2/json/nodes" { + fmt.Fprint(w, `{"data":[{"node":"node1","status":"online"}]}`) + return + } + if r.URL.Path == "/api2/json/storage" { + fmt.Fprint(w, `{"data":[{"storage":"local"}]}`) + return + } + w.WriteHeader(http.StatusNotFound) + })) + defer server.Close() + + cfg := ClientConfig{Host: server.URL, TokenName: "u@p!t", TokenValue: "v"} + cc := NewClusterClient("test", cfg, []string{server.URL}, nil) + + storage, err := cc.GetAllStorage(context.Background()) + if err != nil { + t.Fatalf("GetAllStorage failed: %v", err) + } + if len(storage) != 1 || storage[0].Storage != "local" { + t.Fatalf("unexpected storage result: %+v", storage) + } +} + +func TestClusterClient_GetVMStatus(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + if r.URL.Path == "/api2/json/nodes" { + fmt.Fprint(w, `{"data":[{"node":"node1","status":"online"}]}`) + return + } + if r.URL.Path == "/api2/json/nodes/node1/qemu/100/status/current" { + fmt.Fprint(w, `{"data":{"status":"running","cpu":0.5,"uptime":10,"agent":1}}`) + return + } + w.WriteHeader(http.StatusNotFound) + })) + defer server.Close() + + cfg := ClientConfig{Host: server.URL, TokenName: "u@p!t", TokenValue: "v"} + cc := NewClusterClient("test", cfg, []string{server.URL}, nil) + + status, err := cc.GetVMStatus(context.Background(), "node1", 100) + if err != nil { + t.Fatalf("GetVMStatus failed: %v", err) + } + if status == nil || status.Status != "running" { + t.Fatalf("unexpected VM status: %+v", status) + } +} + +func TestClusterClient_GetContainerStatus(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + if r.URL.Path == "/api2/json/nodes" { + fmt.Fprint(w, `{"data":[{"node":"node1","status":"online"}]}`) + return + } + if r.URL.Path == "/api2/json/nodes/node1/lxc/101/status/current" { + fmt.Fprint(w, `{"data":{"status":"running"}}`) + return + } + w.WriteHeader(http.StatusNotFound) + })) + defer server.Close() + + cfg := ClientConfig{Host: server.URL, TokenName: "u@p!t", TokenValue: "v"} + cc := NewClusterClient("test", cfg, []string{server.URL}, nil) + + status, err := cc.GetContainerStatus(context.Background(), "node1", 101) + if err != nil { + t.Fatalf("GetContainerStatus failed: %v", err) + } + if status == nil || status.Status != "running" { + t.Fatalf("unexpected container status: %+v", status) + } +} + +func TestClusterClient_GetVMConfig(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + if r.URL.Path == "/api2/json/nodes" { + fmt.Fprint(w, `{"data":[{"node":"node1","status":"online"}]}`) + return + } + if r.URL.Path == "/api2/json/nodes/node1/qemu/100/config" { + fmt.Fprint(w, `{"data":{"name":"vm-100"}}`) + return + } + w.WriteHeader(http.StatusNotFound) + })) + defer server.Close() + + cfg := ClientConfig{Host: server.URL, TokenName: "u@p!t", TokenValue: "v"} + cc := NewClusterClient("test", cfg, []string{server.URL}, nil) + + config, err := cc.GetVMConfig(context.Background(), "node1", 100) + if err != nil { + t.Fatalf("GetVMConfig failed: %v", err) + } + if config["name"] != "vm-100" { + t.Fatalf("unexpected VM config: %+v", config) + } +} + +func TestClusterClient_GetVMAgentInfo(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + if r.URL.Path == "/api2/json/nodes" { + fmt.Fprint(w, `{"data":[{"node":"node1","status":"online"}]}`) + return + } + if r.URL.Path == "/api2/json/nodes/node1/qemu/100/agent/get-osinfo" { + fmt.Fprint(w, `{"data":{"name":"debian"}}`) + return + } + w.WriteHeader(http.StatusNotFound) + })) + defer server.Close() + + cfg := ClientConfig{Host: server.URL, TokenName: "u@p!t", TokenValue: "v"} + cc := NewClusterClient("test", cfg, []string{server.URL}, nil) + + info, err := cc.GetVMAgentInfo(context.Background(), "node1", 100) + if err != nil { + t.Fatalf("GetVMAgentInfo failed: %v", err) + } + if info["name"] != "debian" { + t.Fatalf("unexpected agent info: %+v", info) + } +} + +func TestClusterClient_GetVMAgentVersion(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + if r.URL.Path == "/api2/json/nodes" { + fmt.Fprint(w, `{"data":[{"node":"node1","status":"online"}]}`) + return + } + if r.URL.Path == "/api2/json/nodes/node1/qemu/100/agent/info" { + fmt.Fprint(w, `{"data":{"result":{"version":"1.2.3"}}}`) + return + } + w.WriteHeader(http.StatusNotFound) + })) + defer server.Close() + + cfg := ClientConfig{Host: server.URL, TokenName: "u@p!t", TokenValue: "v"} + cc := NewClusterClient("test", cfg, []string{server.URL}, nil) + + version, err := cc.GetVMAgentVersion(context.Background(), "node1", 100) + if err != nil { + t.Fatalf("GetVMAgentVersion failed: %v", err) + } + if version != "1.2.3" { + t.Fatalf("unexpected version: %s", version) + } +} + +func TestClusterClient_GetVMNetworkInterfaces(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + if r.URL.Path == "/api2/json/nodes" { + fmt.Fprint(w, `{"data":[{"node":"node1","status":"online"}]}`) + return + } + if r.URL.Path == "/api2/json/nodes/node1/qemu/100/agent/network-get-interfaces" { + fmt.Fprint(w, `{"data":{"result":[{"name":"eth0"}]}}`) + return + } + w.WriteHeader(http.StatusNotFound) + })) + defer server.Close() + + cfg := ClientConfig{Host: server.URL, TokenName: "u@p!t", TokenValue: "v"} + cc := NewClusterClient("test", cfg, []string{server.URL}, nil) + + ifaces, err := cc.GetVMNetworkInterfaces(context.Background(), "node1", 100) + if err != nil { + t.Fatalf("GetVMNetworkInterfaces failed: %v", err) + } + if len(ifaces) != 1 || ifaces[0].Name != "eth0" { + t.Fatalf("unexpected interfaces: %+v", ifaces) + } +} + +func TestClusterClient_GetContainerInterfaces(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + if r.URL.Path == "/api2/json/nodes" { + fmt.Fprint(w, `{"data":[{"node":"node1","status":"online"}]}`) + return + } + if r.URL.Path == "/api2/json/nodes/node1/lxc/101/interfaces" { + fmt.Fprint(w, `{"data":[{"name":"eth0","ip":"10.0.0.2"}]}`) + return + } + w.WriteHeader(http.StatusNotFound) + })) + defer server.Close() + + cfg := ClientConfig{Host: server.URL, TokenName: "u@p!t", TokenValue: "v"} + cc := NewClusterClient("test", cfg, []string{server.URL}, nil) + + ifaces, err := cc.GetContainerInterfaces(context.Background(), "node1", 101) + if err != nil { + t.Fatalf("GetContainerInterfaces failed: %v", err) + } + if len(ifaces) != 1 || ifaces[0].Name != "eth0" { + t.Fatalf("unexpected interfaces: %+v", ifaces) + } +} + +func TestClusterClient_GetClusterResources(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + if r.URL.Path == "/api2/json/nodes" { + fmt.Fprint(w, `{"data":[{"node":"node1","status":"online"}]}`) + return + } + if strings.HasPrefix(r.URL.Path, "/api2/json/cluster/resources") { + fmt.Fprint(w, `{"data":[{"id":"qemu/100","type":"qemu","node":"node1"}]}`) + return + } + w.WriteHeader(http.StatusNotFound) + })) + defer server.Close() + + cfg := ClientConfig{Host: server.URL, TokenName: "u@p!t", TokenValue: "v"} + cc := NewClusterClient("test", cfg, []string{server.URL}, nil) + + resources, err := cc.GetClusterResources(context.Background(), "qemu") + if err != nil { + t.Fatalf("GetClusterResources failed: %v", err) + } + if len(resources) != 1 || resources[0].ID != "qemu/100" { + t.Fatalf("unexpected resources: %+v", resources) + } +} + +func TestClusterClient_GetClusterStatusAndIsClusterMember(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + if r.URL.Path == "/api2/json/nodes" { + fmt.Fprint(w, `{"data":[{"node":"node1","status":"online"}]}`) + return + } + if r.URL.Path == "/api2/json/cluster/status" { + fmt.Fprint(w, `{"data":[{"type":"cluster","name":"pve"},{"type":"node","name":"node1"}]}`) + return + } + w.WriteHeader(http.StatusNotFound) + })) + defer server.Close() + + cfg := ClientConfig{Host: server.URL, TokenName: "u@p!t", TokenValue: "v"} + cc := NewClusterClient("test", cfg, []string{server.URL}, nil) + + status, err := cc.GetClusterStatus(context.Background()) + if err != nil { + t.Fatalf("GetClusterStatus failed: %v", err) + } + if len(status) != 2 { + t.Fatalf("unexpected cluster status: %+v", status) + } + + member, err := cc.IsClusterMember(context.Background()) + if err != nil { + t.Fatalf("IsClusterMember failed: %v", err) + } + if !member { + t.Fatal("expected cluster membership to be true") + } +} + +func TestClusterClient_GetZFSPoolStatus(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + if r.URL.Path == "/api2/json/nodes" { + fmt.Fprint(w, `{"data":[{"node":"node1","status":"online"}]}`) + return + } + if r.URL.Path == "/api2/json/nodes/node1/disks/zfs" { + fmt.Fprint(w, `{"data":[{"name":"rpool","health":"ONLINE"}]}`) + return + } + w.WriteHeader(http.StatusNotFound) + })) + defer server.Close() + + cfg := ClientConfig{Host: server.URL, TokenName: "u@p!t", TokenValue: "v"} + cc := NewClusterClient("test", cfg, []string{server.URL}, nil) + + pools, err := cc.GetZFSPoolStatus(context.Background(), "node1") + if err != nil { + t.Fatalf("GetZFSPoolStatus failed: %v", err) + } + if len(pools) != 1 || pools[0].Name != "rpool" { + t.Fatalf("unexpected pools: %+v", pools) + } +} + +func TestClusterClient_GetDisks(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + if r.URL.Path == "/api2/json/nodes" { + fmt.Fprint(w, `{"data":[{"node":"node1","status":"online"}]}`) + return + } + if r.URL.Path == "/api2/json/nodes/node1/disks/list" { + fmt.Fprint(w, `{"data":[{"devpath":"/dev/sda","model":"Samsung","wearout":"N/A"}]}`) + return + } + w.WriteHeader(http.StatusNotFound) + })) + defer server.Close() + + cfg := ClientConfig{Host: server.URL, TokenName: "u@p!t", TokenValue: "v"} + cc := NewClusterClient("test", cfg, []string{server.URL}, nil) + + disks, err := cc.GetDisks(context.Background(), "node1") + if err != nil { + t.Fatalf("GetDisks failed: %v", err) + } + if len(disks) != 1 || disks[0].DevPath != "/dev/sda" { + t.Fatalf("unexpected disks: %+v", disks) + } + if disks[0].Wearout != wearoutUnknown { + t.Fatalf("expected wearout unknown, got %d", disks[0].Wearout) + } +} + +func TestClusterClient_GetNodePendingUpdates(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + if r.URL.Path == "/api2/json/nodes" { + fmt.Fprint(w, `{"data":[{"node":"node1","status":"online"}]}`) + return + } + if r.URL.Path == "/api2/json/nodes/node1/apt/update" { + fmt.Fprint(w, `{"data":[{"Package":"vim","Version":"2"}]}`) + return + } + w.WriteHeader(http.StatusNotFound) + })) + defer server.Close() + + cfg := ClientConfig{Host: server.URL, TokenName: "u@p!t", TokenValue: "v"} + cc := NewClusterClient("test", cfg, []string{server.URL}, nil) + + updates, err := cc.GetNodePendingUpdates(context.Background(), "node1") + if err != nil { + t.Fatalf("GetNodePendingUpdates failed: %v", err) + } + if len(updates) != 1 || updates[0].Package != "vim" { + t.Fatalf("unexpected updates: %+v", updates) + } +} diff --git a/pkg/proxmox/cluster_client_failover_test.go b/pkg/proxmox/cluster_client_failover_test.go new file mode 100644 index 000000000..fb755a0ac --- /dev/null +++ b/pkg/proxmox/cluster_client_failover_test.go @@ -0,0 +1,108 @@ +package proxmox + +import ( + "context" + "fmt" + "net/http" + "net/http/httptest" + "testing" + "time" +) + +func TestGetHealthyClientSingleEndpointFallback(t *testing.T) { + endpoint := "http://example.invalid" + cc := &ClusterClient{ + name: "single", + endpoints: []string{endpoint}, + clients: map[string]*Client{endpoint: {}}, + nodeHealth: map[string]bool{endpoint: false}, + lastError: make(map[string]string), + lastHealthCheck: make(map[string]time.Time), + rateLimitUntil: make(map[string]time.Time), + } + + client, err := cc.getHealthyClient(context.Background()) + if err != nil { + t.Fatalf("getHealthyClient failed: %v", err) + } + if client == nil { + t.Fatal("expected client, got nil") + } + if !cc.nodeHealth[endpoint] { + t.Fatal("expected endpoint to be marked healthy after fallback") + } +} + +func TestExecuteWithFailoverUsesRecoveredEndpoint(t *testing.T) { + server1 := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + if r.URL.Path == "/api2/json/nodes" { + fmt.Fprint(w, `{"data":[{"node":"node1","status":"online"}]}`) + return + } + fmt.Fprint(w, `{"data":{}}`) + })) + defer server1.Close() + + server2 := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + if r.URL.Path == "/api2/json/nodes" { + fmt.Fprint(w, `{"data":[{"node":"node2","status":"online"}]}`) + return + } + fmt.Fprint(w, `{"data":{}}`) + })) + defer server2.Close() + + cfg := ClientConfig{ + Host: server1.URL, + TokenName: "u@p!t", + TokenValue: "v", + VerifySSL: false, + Timeout: 2 * time.Second, + } + + cc := NewClusterClient("test", cfg, []string{server1.URL, server2.URL}, nil) + + // Force deterministic selection: only server1 healthy, server2 unhealthy. + cc.mu.Lock() + cc.nodeHealth[server1.URL] = true + cc.nodeHealth[server2.URL] = false + cc.lastHealthCheck[server2.URL] = time.Now().Add(-11 * time.Second) + cc.mu.Unlock() + + usedEndpoints := make([]string, 0, 2) + err := cc.executeWithFailover(context.Background(), func(c *Client) error { + endpoint := "" + cc.mu.RLock() + for ep, client := range cc.clients { + if client == c { + endpoint = ep + break + } + } + cc.mu.RUnlock() + if endpoint == "" { + return fmt.Errorf("failed to resolve endpoint for client") + } + usedEndpoints = append(usedEndpoints, endpoint) + if endpoint == server1.URL { + return fmt.Errorf("boom") + } + return nil + }) + if err != nil { + t.Fatalf("executeWithFailover failed: %v", err) + } + if len(usedEndpoints) < 2 { + t.Fatalf("expected failover to second endpoint, used: %v", usedEndpoints) + } + + health := cc.GetHealthStatus() + if health[server1.URL] { + t.Fatal("expected server1 to be unhealthy after failure") + } + if !health[server2.URL] { + t.Fatal("expected server2 to be healthy after recovery") + } +} diff --git a/pkg/reporting/engine_additional_test.go b/pkg/reporting/engine_additional_test.go new file mode 100644 index 000000000..cfefd44ac --- /dev/null +++ b/pkg/reporting/engine_additional_test.go @@ -0,0 +1,186 @@ +package reporting + +import ( + "path/filepath" + "strings" + "testing" + "time" + + "github.com/rcourtman/pulse-go-rewrite/pkg/metrics" +) + +func TestReportEngineGenerateMissingStore(t *testing.T) { + engine := NewReportEngine(EngineConfig{}) + + _, _, err := engine.Generate(MetricReportRequest{ + ResourceType: "node", + ResourceID: "node-1", + Start: time.Now().Add(-1 * time.Hour), + End: time.Now(), + Format: FormatCSV, + }) + if err == nil { + t.Fatal("expected error when metrics store is nil") + } + if !strings.Contains(err.Error(), "metrics store not initialized") { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestReportEngineGenerateUnsupportedFormat(t *testing.T) { + store := newTestMetricsStore(t) + defer store.Close() + + engine := NewReportEngine(EngineConfig{MetricsStore: store}) + + _, _, err := engine.Generate(MetricReportRequest{ + ResourceType: "node", + ResourceID: "node-1", + Start: time.Now().Add(-1 * time.Hour), + End: time.Now(), + Format: ReportFormat("xls"), + }) + if err == nil { + t.Fatal("expected unsupported format error") + } + if !strings.Contains(err.Error(), "unsupported format") { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestReportEngineQueryMetricsSpecificMetric(t *testing.T) { + store := newTestMetricsStore(t) + defer store.Close() + + start := time.Now().Add(-30 * time.Minute) + points := []metrics.WriteMetric{ + {ResourceType: "node", ResourceID: "node-1", MetricType: "cpu", Value: 10, Timestamp: start, Tier: metrics.TierRaw}, + {ResourceType: "node", ResourceID: "node-1", MetricType: "cpu", Value: 30, Timestamp: start.Add(5 * time.Minute), Tier: metrics.TierRaw}, + {ResourceType: "node", ResourceID: "node-1", MetricType: "memory", Value: 50, Timestamp: start, Tier: metrics.TierRaw}, + } + store.WriteBatchSync(points) + + engine := NewReportEngine(EngineConfig{MetricsStore: store}) + + data, err := engine.queryMetrics(MetricReportRequest{ + ResourceType: "node", + ResourceID: "node-1", + MetricType: "cpu", + Start: start.Add(-1 * time.Minute), + End: start.Add(10 * time.Minute), + }) + if err != nil { + t.Fatalf("queryMetrics failed: %v", err) + } + + if data.Title != "node Report: node-1" { + t.Fatalf("expected default title, got %q", data.Title) + } + + if len(data.Metrics) != 1 { + t.Fatalf("expected 1 metric, got %d", len(data.Metrics)) + } + if _, ok := data.Metrics["cpu"]; !ok { + t.Fatal("expected cpu metric data to be present") + } + if _, ok := data.Metrics["memory"]; ok { + t.Fatal("expected memory metric to be filtered out") + } + + stats, ok := data.Summary.ByMetric["cpu"] + if !ok { + t.Fatal("expected cpu summary stats") + } + if stats.Count != 2 { + t.Fatalf("expected cpu count 2, got %d", stats.Count) + } + if stats.Min != 10 || stats.Max != 30 { + t.Fatalf("unexpected cpu min/max: %+v", stats) + } + if stats.Avg != 20 { + t.Fatalf("expected cpu avg 20, got %v", stats.Avg) + } + if stats.Current != 30 { + t.Fatalf("expected cpu current 30, got %v", stats.Current) + } +} + +func TestReportEngineQueryMetricsNoData(t *testing.T) { + store := newTestMetricsStore(t) + defer store.Close() + + engine := NewReportEngine(EngineConfig{MetricsStore: store}) + + start := time.Now().Add(-1 * time.Hour) + data, err := engine.queryMetrics(MetricReportRequest{ + ResourceType: "node", + ResourceID: "missing-node", + Start: start, + End: time.Now(), + }) + if err != nil { + t.Fatalf("queryMetrics failed: %v", err) + } + if data.TotalPoints != 0 { + t.Fatalf("expected zero points, got %d", data.TotalPoints) + } + if len(data.Metrics) != 0 { + t.Fatalf("expected no metrics, got %d", len(data.Metrics)) + } + if data.Title != "node Report: missing-node" { + t.Fatalf("expected default title, got %q", data.Title) + } +} + +func TestReportEngineGenerateMultiAggregatesData(t *testing.T) { + store := newTestMetricsStore(t) + defer store.Close() + + base := time.Now().Add(-15 * time.Minute) + store.WriteBatchSync([]metrics.WriteMetric{ + {ResourceType: "node", ResourceID: "node-1", MetricType: "cpu", Value: 10, Timestamp: base, Tier: metrics.TierRaw}, + {ResourceType: "node", ResourceID: "node-2", MetricType: "cpu", Value: 20, Timestamp: base.Add(2 * time.Minute), Tier: metrics.TierRaw}, + }) + + engine := NewReportEngine(EngineConfig{MetricsStore: store}) + + data, contentType, err := engine.GenerateMulti(MultiReportRequest{ + Resources: []MetricReportRequest{ + {ResourceType: "node", ResourceID: "node-1"}, + {ResourceType: "node", ResourceID: "node-2"}, + }, + MetricType: "cpu", + Start: base.Add(-1 * time.Minute), + End: base.Add(5 * time.Minute), + Format: FormatCSV, + }) + if err != nil { + t.Fatalf("GenerateMulti failed: %v", err) + } + if contentType != "text/csv" { + t.Fatalf("expected text/csv, got %s", contentType) + } + + csv := string(data) + if !strings.Contains(csv, "# Pulse Multi-Resource Metrics Report") { + t.Fatal("missing multi-resource header") + } + if !strings.Contains(csv, "node-1") || !strings.Contains(csv, "node-2") { + t.Fatal("expected both resource IDs in output") + } +} + +func newTestMetricsStore(t *testing.T) *metrics.Store { + t.Helper() + dir := t.TempDir() + cfg := metrics.DefaultConfig(dir) + cfg.DBPath = filepath.Join(dir, "metrics.db") + cfg.WriteBufferSize = 1 + cfg.FlushInterval = 50 * time.Millisecond + + store, err := metrics.NewStore(cfg) + if err != nil { + t.Fatalf("failed to create metrics store: %v", err) + } + return store +}