test: add unit tests for AI, Kubernetes agent, and clients
This commit is contained in:
parent
ad59c13b95
commit
941e9ed098
12 changed files with 1455 additions and 3 deletions
|
|
@ -10,11 +10,16 @@ import (
|
|||
|
||||
// AlertManagerAdapter adapts the alerts.Manager to the AI's AlertProvider interface
|
||||
type AlertManagerAdapter struct {
|
||||
manager *alerts.Manager
|
||||
manager alertManager
|
||||
}
|
||||
|
||||
type alertManager interface {
|
||||
GetActiveAlerts() []alerts.Alert
|
||||
GetRecentlyResolved() []models.ResolvedAlert
|
||||
}
|
||||
|
||||
// NewAlertManagerAdapter creates a new adapter for the alert manager
|
||||
func NewAlertManagerAdapter(manager *alerts.Manager) *AlertManagerAdapter {
|
||||
func NewAlertManagerAdapter(manager alertManager) *AlertManagerAdapter {
|
||||
return &AlertManagerAdapter{manager: manager}
|
||||
}
|
||||
|
||||
|
|
|
|||
109
internal/ai/alert_adapter_test.go
Normal file
109
internal/ai/alert_adapter_test.go
Normal file
|
|
@ -0,0 +1,109 @@
|
|||
package ai
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/rcourtman/pulse-go-rewrite/internal/alerts"
|
||||
"github.com/rcourtman/pulse-go-rewrite/internal/models"
|
||||
)
|
||||
|
||||
type stubAlertManager struct {
|
||||
active []alerts.Alert
|
||||
resolved []models.ResolvedAlert
|
||||
}
|
||||
|
||||
func (s *stubAlertManager) GetActiveAlerts() []alerts.Alert { return s.active }
|
||||
func (s *stubAlertManager) GetRecentlyResolved() []models.ResolvedAlert {
|
||||
return s.resolved
|
||||
}
|
||||
|
||||
func TestAlertManagerAdapter_NilManager(t *testing.T) {
|
||||
a := NewAlertManagerAdapter(nil)
|
||||
if got := a.GetActiveAlerts(); got != nil {
|
||||
t.Fatalf("GetActiveAlerts = %+v, want nil", got)
|
||||
}
|
||||
if got := a.GetRecentlyResolved(30); got != nil {
|
||||
t.Fatalf("GetRecentlyResolved = %+v, want nil", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAlertManagerAdapter_ConvertsAndFilters(t *testing.T) {
|
||||
now := time.Now()
|
||||
active := []alerts.Alert{
|
||||
{
|
||||
ID: "a1",
|
||||
Type: "node_cpu",
|
||||
Level: alerts.AlertLevelCritical,
|
||||
ResourceID: "node:pve1",
|
||||
ResourceName: "pve1",
|
||||
Value: 95,
|
||||
Threshold: 80,
|
||||
StartTime: now.Add(-2*time.Minute - 10*time.Second),
|
||||
Metadata: map[string]any{"resourceType": "node"},
|
||||
},
|
||||
{
|
||||
ID: "a2",
|
||||
Type: "guest_memory",
|
||||
Level: alerts.AlertLevelWarning,
|
||||
ResourceID: "guest:100",
|
||||
ResourceName: "vm-100",
|
||||
Value: 80,
|
||||
Threshold: 75,
|
||||
StartTime: now.Add(-30 * time.Second),
|
||||
},
|
||||
}
|
||||
resolved := []models.ResolvedAlert{
|
||||
{
|
||||
Alert: models.Alert{
|
||||
ID: "r1",
|
||||
Type: "storage_usage",
|
||||
Level: "critical",
|
||||
ResourceID: "storage:local",
|
||||
ResourceName: "local",
|
||||
StartTime: now.Add(-10 * time.Minute),
|
||||
},
|
||||
ResolvedTime: now.Add(-2 * time.Minute),
|
||||
},
|
||||
{
|
||||
Alert: models.Alert{
|
||||
ID: "r2",
|
||||
Type: "host_offline",
|
||||
Level: "warning",
|
||||
ResourceID: "host:h1",
|
||||
ResourceName: "h1",
|
||||
StartTime: now.Add(-2 * time.Hour),
|
||||
},
|
||||
ResolvedTime: now.Add(-2 * time.Hour),
|
||||
},
|
||||
}
|
||||
|
||||
a := NewAlertManagerAdapter(&stubAlertManager{active: active, resolved: resolved})
|
||||
|
||||
gotActive := a.GetActiveAlerts()
|
||||
if len(gotActive) != 2 {
|
||||
t.Fatalf("GetActiveAlerts = %d, want 2", len(gotActive))
|
||||
}
|
||||
if gotActive[0].ResourceType != "node" {
|
||||
t.Fatalf("ResourceType = %q, want node", gotActive[0].ResourceType)
|
||||
}
|
||||
if gotActive[1].Duration == "" {
|
||||
t.Fatalf("expected Duration to be populated")
|
||||
}
|
||||
|
||||
gotByResource := a.GetAlertsByResource("node:pve1")
|
||||
if len(gotByResource) != 1 || gotByResource[0].ID != "a1" {
|
||||
t.Fatalf("GetAlertsByResource = %+v", gotByResource)
|
||||
}
|
||||
|
||||
gotRecent := a.GetRecentlyResolved(30)
|
||||
if len(gotRecent) != 1 || gotRecent[0].ID != "r1" {
|
||||
t.Fatalf("GetRecentlyResolved = %+v", gotRecent)
|
||||
}
|
||||
|
||||
gotHistory := a.GetAlertHistory("storage:local", 1)
|
||||
if len(gotHistory) != 1 || gotHistory[0].ID != "r1" {
|
||||
t.Fatalf("GetAlertHistory = %+v", gotHistory)
|
||||
}
|
||||
}
|
||||
|
||||
167
internal/ai/alert_provider_test.go
Normal file
167
internal/ai/alert_provider_test.go
Normal file
|
|
@ -0,0 +1,167 @@
|
|||
package ai
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
type stubAlertProvider struct {
|
||||
active []AlertInfo
|
||||
resolved []ResolvedAlertInfo
|
||||
}
|
||||
|
||||
func (s *stubAlertProvider) GetActiveAlerts() []AlertInfo { return s.active }
|
||||
func (s *stubAlertProvider) GetRecentlyResolved(minutes int) []ResolvedAlertInfo { return s.resolved }
|
||||
func (s *stubAlertProvider) GetAlertsByResource(resourceID string) []AlertInfo {
|
||||
out := make([]AlertInfo, 0)
|
||||
for _, a := range s.active {
|
||||
if a.ResourceID == resourceID {
|
||||
out = append(out, a)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
func (s *stubAlertProvider) GetAlertHistory(resourceID string, limit int) []ResolvedAlertInfo {
|
||||
out := make([]ResolvedAlertInfo, 0)
|
||||
for _, a := range s.resolved {
|
||||
if a.ResourceID == resourceID {
|
||||
out = append(out, a)
|
||||
if len(out) >= limit {
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func TestService_buildAlertContext(t *testing.T) {
|
||||
now := time.Now()
|
||||
|
||||
s := &Service{}
|
||||
s.SetAlertProvider(&stubAlertProvider{
|
||||
active: []AlertInfo{
|
||||
{
|
||||
ID: "a1",
|
||||
Type: "cpu",
|
||||
Level: "critical",
|
||||
ResourceID: "node:pve1",
|
||||
ResourceName: "pve1",
|
||||
ResourceType: "node",
|
||||
Node: "pve1",
|
||||
Message: "cpu high",
|
||||
Value: 95,
|
||||
Threshold: 80,
|
||||
StartTime: now.Add(-5 * time.Minute),
|
||||
Duration: "5 mins",
|
||||
Acknowledged: true,
|
||||
},
|
||||
{
|
||||
ID: "a2",
|
||||
Type: "memory",
|
||||
Level: "warning",
|
||||
ResourceID: "guest:100",
|
||||
ResourceName: "vm-100",
|
||||
ResourceType: "guest",
|
||||
Message: "mem high",
|
||||
Value: 80,
|
||||
Threshold: 75,
|
||||
StartTime: now.Add(-2 * time.Minute),
|
||||
Duration: "2 mins",
|
||||
},
|
||||
},
|
||||
resolved: []ResolvedAlertInfo{
|
||||
{
|
||||
AlertInfo: AlertInfo{
|
||||
ID: "r1",
|
||||
Type: "disk",
|
||||
Level: "warning",
|
||||
ResourceID: "storage:local",
|
||||
ResourceName: "local",
|
||||
Message: "disk ok",
|
||||
Duration: "10 mins",
|
||||
},
|
||||
ResolvedTime: now.Add(-2 * time.Minute),
|
||||
Duration: "10 mins",
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
ctx := s.buildAlertContext()
|
||||
if !strings.Contains(ctx, "## Alert Status") {
|
||||
t.Fatalf("expected alert status header, got: %s", ctx)
|
||||
}
|
||||
if !strings.Contains(ctx, "### Active Alerts") || !strings.Contains(ctx, "**Critical:**") || !strings.Contains(ctx, "**Warning:**") {
|
||||
t.Fatalf("expected active alert sections, got: %s", ctx)
|
||||
}
|
||||
if !strings.Contains(ctx, "[ACKNOWLEDGED]") || !strings.Contains(ctx, "on node pve1") {
|
||||
t.Fatalf("expected acknowledged/node formatting, got: %s", ctx)
|
||||
}
|
||||
if !strings.Contains(ctx, "### Recently Resolved") {
|
||||
t.Fatalf("expected recently resolved section, got: %s", ctx)
|
||||
}
|
||||
}
|
||||
|
||||
func TestService_buildAlertContext_Empty(t *testing.T) {
|
||||
s := &Service{}
|
||||
if got := s.buildAlertContext(); got != "" {
|
||||
t.Fatalf("expected empty string, got: %q", got)
|
||||
}
|
||||
s.SetAlertProvider(&stubAlertProvider{})
|
||||
if got := s.buildAlertContext(); got != "" {
|
||||
t.Fatalf("expected empty string when no alerts, got: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestService_buildTargetAlertContext(t *testing.T) {
|
||||
s := &Service{}
|
||||
s.SetAlertProvider(&stubAlertProvider{
|
||||
active: []AlertInfo{
|
||||
{ID: "a1", Level: "critical", Type: "cpu", ResourceID: "node:pve1", ResourceName: "pve1", Duration: "1 min", Value: 90, Threshold: 80},
|
||||
{ID: "a2", Level: "warning", Type: "memory", ResourceID: "node:pve2", ResourceName: "pve2", Duration: "1 min", Value: 80, Threshold: 70},
|
||||
},
|
||||
})
|
||||
|
||||
got := s.buildTargetAlertContext("node:pve1")
|
||||
if !strings.Contains(got, "Active Alerts for This Resource") || !strings.Contains(got, "pve1") {
|
||||
t.Fatalf("unexpected context: %s", got)
|
||||
}
|
||||
if strings.Contains(got, "pve2") {
|
||||
t.Fatalf("unexpected extra resource: %s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFormatTimeAgo(t *testing.T) {
|
||||
now := time.Now()
|
||||
if got := formatTimeAgo(now.Add(-10 * time.Second)); got != "just now" {
|
||||
t.Fatalf("formatTimeAgo(<1m) = %q", got)
|
||||
}
|
||||
if got := formatTimeAgo(now.Add(-2 * time.Minute)); got != "2 minutes" {
|
||||
t.Fatalf("formatTimeAgo(2m) = %q", got)
|
||||
}
|
||||
if got := formatTimeAgo(now.Add(-2 * time.Hour)); got != "2 hours" {
|
||||
t.Fatalf("formatTimeAgo(2h) = %q", got)
|
||||
}
|
||||
if got := formatTimeAgo(now.Add(-48 * time.Hour)); got != "2 days" {
|
||||
t.Fatalf("formatTimeAgo(2d) = %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateAlertInvestigationPrompt(t *testing.T) {
|
||||
out := GenerateAlertInvestigationPrompt(AlertInvestigationRequest{
|
||||
Level: "critical",
|
||||
ResourceName: "pve1",
|
||||
ResourceType: "node",
|
||||
AlertType: "cpu",
|
||||
Value: 95,
|
||||
Threshold: 80,
|
||||
Duration: "5 mins",
|
||||
Node: "pve1",
|
||||
})
|
||||
if !strings.Contains(out, "Investigate this CRITICAL alert") ||
|
||||
!strings.Contains(out, "**Resource:** pve1 (node)") ||
|
||||
!strings.Contains(out, "**Node:** pve1") {
|
||||
t.Fatalf("unexpected prompt: %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
55
internal/ai/alert_threshold_adapter_test.go
Normal file
55
internal/ai/alert_threshold_adapter_test.go
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
package ai
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/rcourtman/pulse-go-rewrite/internal/alerts"
|
||||
)
|
||||
|
||||
func TestAlertThresholdAdapter_Defaults(t *testing.T) {
|
||||
a := NewAlertThresholdAdapter(nil)
|
||||
if a.GetNodeCPUThreshold() != 80 {
|
||||
t.Fatalf("GetNodeCPUThreshold default = %v", a.GetNodeCPUThreshold())
|
||||
}
|
||||
if a.GetNodeMemoryThreshold() != 85 {
|
||||
t.Fatalf("GetNodeMemoryThreshold default = %v", a.GetNodeMemoryThreshold())
|
||||
}
|
||||
if a.GetGuestMemoryThreshold() != 85 {
|
||||
t.Fatalf("GetGuestMemoryThreshold default = %v", a.GetGuestMemoryThreshold())
|
||||
}
|
||||
if a.GetGuestDiskThreshold() != 90 {
|
||||
t.Fatalf("GetGuestDiskThreshold default = %v", a.GetGuestDiskThreshold())
|
||||
}
|
||||
if a.GetStorageThreshold() != 85 {
|
||||
t.Fatalf("GetStorageThreshold default = %v", a.GetStorageThreshold())
|
||||
}
|
||||
}
|
||||
|
||||
func TestAlertThresholdAdapter_UsesAlertManagerConfig(t *testing.T) {
|
||||
mgr := alerts.NewManager()
|
||||
cfg := mgr.GetConfig()
|
||||
cfg.NodeDefaults.CPU = &alerts.HysteresisThreshold{Trigger: 70, Clear: 65}
|
||||
cfg.NodeDefaults.Memory = &alerts.HysteresisThreshold{Trigger: 75, Clear: 70}
|
||||
cfg.GuestDefaults.Memory = &alerts.HysteresisThreshold{Trigger: 72, Clear: 70}
|
||||
cfg.GuestDefaults.Disk = &alerts.HysteresisThreshold{Trigger: 91, Clear: 90}
|
||||
cfg.StorageDefault.Trigger = 77
|
||||
mgr.UpdateConfig(cfg)
|
||||
|
||||
a := NewAlertThresholdAdapter(mgr)
|
||||
if a.GetNodeCPUThreshold() != 70 {
|
||||
t.Fatalf("GetNodeCPUThreshold = %v", a.GetNodeCPUThreshold())
|
||||
}
|
||||
if a.GetNodeMemoryThreshold() != 75 {
|
||||
t.Fatalf("GetNodeMemoryThreshold = %v", a.GetNodeMemoryThreshold())
|
||||
}
|
||||
if a.GetGuestMemoryThreshold() != 72 {
|
||||
t.Fatalf("GetGuestMemoryThreshold = %v", a.GetGuestMemoryThreshold())
|
||||
}
|
||||
if a.GetGuestDiskThreshold() != 91 {
|
||||
t.Fatalf("GetGuestDiskThreshold = %v", a.GetGuestDiskThreshold())
|
||||
}
|
||||
if a.GetStorageThreshold() != 77 {
|
||||
t.Fatalf("GetStorageThreshold = %v", a.GetStorageThreshold())
|
||||
}
|
||||
}
|
||||
|
||||
54
internal/ai/baseline_adapter_test.go
Normal file
54
internal/ai/baseline_adapter_test.go
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
package ai
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/rcourtman/pulse-go-rewrite/internal/ai/baseline"
|
||||
)
|
||||
|
||||
func TestBaselineStoreAdapter(t *testing.T) {
|
||||
store := baseline.NewStore(baseline.StoreConfig{
|
||||
MinSamples: 1,
|
||||
})
|
||||
|
||||
err := store.Learn("node:pve1", "node", "cpu", []baseline.MetricPoint{
|
||||
{Value: 10, Timestamp: time.Now().Add(-time.Minute)},
|
||||
{Value: 10, Timestamp: time.Now()},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Learn: %v", err)
|
||||
}
|
||||
|
||||
adapter := NewBaselineStoreAdapter(store)
|
||||
if adapter == nil {
|
||||
t.Fatalf("expected adapter")
|
||||
}
|
||||
|
||||
mean, stddev, samples, ok := adapter.GetBaseline("node:pve1", "cpu")
|
||||
if !ok {
|
||||
t.Fatalf("expected baseline to exist")
|
||||
}
|
||||
if mean != 10 || stddev != 0 || samples != 2 {
|
||||
t.Fatalf("unexpected baseline: mean=%v stddev=%v samples=%d", mean, stddev, samples)
|
||||
}
|
||||
|
||||
severity, z, gotMean, gotStd, ok := adapter.CheckAnomaly("node:pve1", "cpu", 11)
|
||||
if !ok {
|
||||
t.Fatalf("expected anomaly check ok")
|
||||
}
|
||||
if severity != "critical" || z <= 0 || gotMean != 10 || gotStd != 0 {
|
||||
t.Fatalf("unexpected anomaly: severity=%q z=%v mean=%v stddev=%v", severity, z, gotMean, gotStd)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBaselineStoreAdapter_NilStore(t *testing.T) {
|
||||
adapter := &BaselineStoreAdapter{}
|
||||
if _, _, _, ok := adapter.GetBaseline("r", "m"); ok {
|
||||
t.Fatalf("expected ok=false")
|
||||
}
|
||||
if _, _, _, _, ok := adapter.CheckAnomaly("r", "m", 1); ok {
|
||||
t.Fatalf("expected ok=false")
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -5,6 +5,7 @@ import (
|
|||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
|
@ -360,3 +361,145 @@ func TestOllamaClient_ListModels_Failure(t *testing.T) {
|
|||
t.Error("Expected error for failed list models")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewOllamaClient_NormalizesBaseURL(t *testing.T) {
|
||||
tests := []struct {
|
||||
in string
|
||||
expected string
|
||||
}{
|
||||
{"", "http://localhost:11434"},
|
||||
{"http://example:11434", "http://example:11434"},
|
||||
{"http://example:11434/", "http://example:11434"},
|
||||
{"http://example:11434/api", "http://example:11434"},
|
||||
{"http://example:11434/api/", "http://example:11434"},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.in, func(t *testing.T) {
|
||||
client := NewOllamaClient("llama3", tc.in)
|
||||
if client.baseURL != tc.expected {
|
||||
t.Fatalf("baseURL = %q, want %q", client.baseURL, tc.expected)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestOllamaClient_Chat_ToolCallsResponse(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
resp := ollamaResponse{
|
||||
Model: "llama3",
|
||||
Message: ollamaMessageResp{
|
||||
Role: "assistant",
|
||||
Content: "",
|
||||
ToolCalls: []ollamaToolCall{
|
||||
{
|
||||
ID: "call_1",
|
||||
Function: ollamaFunctionCall{
|
||||
Name: "get_time",
|
||||
Arguments: map[string]interface{}{"tz": "UTC"},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
Done: true,
|
||||
DoneReason: "stop",
|
||||
}
|
||||
_ = json.NewEncoder(w).Encode(resp)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := NewOllamaClient("llama3", server.URL)
|
||||
out, err := client.Chat(context.Background(), ChatRequest{
|
||||
Messages: []Message{{Role: "user", Content: "What time is it?"}},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Chat: %v", err)
|
||||
}
|
||||
if out.StopReason != "tool_use" {
|
||||
t.Fatalf("StopReason = %q, want tool_use", out.StopReason)
|
||||
}
|
||||
if len(out.ToolCalls) != 1 {
|
||||
t.Fatalf("ToolCalls = %d, want 1", len(out.ToolCalls))
|
||||
}
|
||||
if out.ToolCalls[0].ID != "call_1" || out.ToolCalls[0].Name != "get_time" {
|
||||
t.Fatalf("unexpected tool call: %+v", out.ToolCalls[0])
|
||||
}
|
||||
if out.ToolCalls[0].Input["tz"] != "UTC" {
|
||||
t.Fatalf("unexpected tool call input: %+v", out.ToolCalls[0].Input)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOllamaClient_Chat_ToolCallsAndToolResultsInRequest(t *testing.T) {
|
||||
var got ollamaRequest
|
||||
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
_ = json.NewDecoder(r.Body).Decode(&got)
|
||||
_ = json.NewEncoder(w).Encode(ollamaResponse{
|
||||
Model: got.Model,
|
||||
Message: ollamaMessageResp{Role: "assistant", Content: "ok"},
|
||||
Done: true,
|
||||
})
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := NewOllamaClient("llama3", server.URL)
|
||||
_, err := client.Chat(context.Background(), ChatRequest{
|
||||
System: "system prompt",
|
||||
Messages: []Message{
|
||||
{
|
||||
Role: "assistant",
|
||||
Content: "calling tool",
|
||||
ToolCalls: []ToolCall{
|
||||
{Name: "get_time", Input: map[string]any{"tz": "UTC"}},
|
||||
},
|
||||
},
|
||||
{
|
||||
Role: "assistant",
|
||||
ToolResult: &ToolResult{Content: "{\"time\":\"00:00\"}"},
|
||||
},
|
||||
},
|
||||
Tools: []Tool{
|
||||
{
|
||||
Type: "function",
|
||||
Name: "get_time",
|
||||
Description: "get time",
|
||||
InputSchema: map[string]any{"type": "object"},
|
||||
},
|
||||
{
|
||||
Type: "web_search",
|
||||
Name: "search",
|
||||
},
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Chat: %v", err)
|
||||
}
|
||||
|
||||
if got.Messages[0].Role != "system" || got.Messages[0].Content != "system prompt" {
|
||||
t.Fatalf("expected system message first, got: %+v", got.Messages[0])
|
||||
}
|
||||
|
||||
var sawAssistantToolCall bool
|
||||
var sawToolResult bool
|
||||
for _, m := range got.Messages {
|
||||
if m.Role == "assistant" && len(m.ToolCalls) == 1 && m.ToolCalls[0].Function.Name == "get_time" {
|
||||
if m.ToolCalls[0].Function.Arguments["tz"] != "UTC" {
|
||||
t.Fatalf("unexpected tool call args: %+v", m.ToolCalls[0].Function.Arguments)
|
||||
}
|
||||
sawAssistantToolCall = true
|
||||
}
|
||||
if m.Role == "tool" && strings.Contains(m.Content, "00:00") {
|
||||
sawToolResult = true
|
||||
}
|
||||
}
|
||||
if !sawAssistantToolCall {
|
||||
t.Fatalf("expected assistant tool call message in request, got: %+v", got.Messages)
|
||||
}
|
||||
if !sawToolResult {
|
||||
t.Fatalf("expected tool result message in request, got: %+v", got.Messages)
|
||||
}
|
||||
|
||||
if len(got.Tools) != 1 || got.Tools[0].Function.Name != "get_time" {
|
||||
t.Fatalf("expected only function tools to be included, got: %+v", got.Tools)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
302
internal/api/ai_handlers_test.go
Normal file
302
internal/api/ai_handlers_test.go
Normal file
|
|
@ -0,0 +1,302 @@
|
|||
package api
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/rcourtman/pulse-go-rewrite/internal/config"
|
||||
)
|
||||
|
||||
func TestAISettingsHandler_GetAndUpdateSettings_RoundTrip(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tmp := t.TempDir()
|
||||
cfg := &config.Config{DataPath: tmp}
|
||||
persistence := config.NewConfigPersistence(tmp)
|
||||
|
||||
handler := NewAISettingsHandler(cfg, persistence, nil)
|
||||
|
||||
// GET should return defaults if no config has been saved yet.
|
||||
{
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/settings/ai", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
handler.HandleGetAISettings(rec, req)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("GET status = %d, body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
|
||||
var resp AISettingsResponse
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("decode: %v", err)
|
||||
}
|
||||
if resp.Enabled {
|
||||
t.Fatalf("expected default Enabled=false, got %+v", resp)
|
||||
}
|
||||
}
|
||||
|
||||
// Update settings to enable AI via Ollama.
|
||||
{
|
||||
body, _ := json.Marshal(AISettingsUpdateRequest{
|
||||
Enabled: ptr(true),
|
||||
Provider: ptr("ollama"),
|
||||
Model: ptr("ollama:llama3"),
|
||||
OllamaBaseURL: ptr("http://localhost:11434"),
|
||||
})
|
||||
req := httptest.NewRequest(http.MethodPut, "/api/settings/ai", bytes.NewReader(body))
|
||||
rec := httptest.NewRecorder()
|
||||
handler.HandleUpdateAISettings(rec, req)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("PUT status = %d, body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
|
||||
var resp AISettingsResponse
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("decode: %v", err)
|
||||
}
|
||||
if !resp.Enabled || !resp.OllamaConfigured {
|
||||
t.Fatalf("expected enabled + ollama configured, got %+v", resp)
|
||||
}
|
||||
if resp.OllamaBaseURL != "http://localhost:11434" {
|
||||
t.Fatalf("unexpected ollama base url: %+v", resp)
|
||||
}
|
||||
}
|
||||
|
||||
// GET again should reflect persisted updates.
|
||||
{
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/settings/ai", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
handler.HandleGetAISettings(rec, req)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("GET status = %d, body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
|
||||
var resp AISettingsResponse
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("decode: %v", err)
|
||||
}
|
||||
if !resp.Enabled || !resp.OllamaConfigured {
|
||||
t.Fatalf("expected enabled + ollama configured, got %+v", resp)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAISettingsHandler_ListModels_Ollama(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
ollama := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.URL.Path {
|
||||
case "/api/tags":
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||
"models": []map[string]any{
|
||||
{"name": "llama3:latest"},
|
||||
{"name": "tinyllama:latest"},
|
||||
},
|
||||
})
|
||||
default:
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
}))
|
||||
defer ollama.Close()
|
||||
|
||||
tmp := t.TempDir()
|
||||
cfg := &config.Config{DataPath: tmp}
|
||||
persistence := config.NewConfigPersistence(tmp)
|
||||
|
||||
aiCfg := config.NewDefaultAIConfig()
|
||||
aiCfg.Enabled = true
|
||||
aiCfg.Model = "ollama:llama3"
|
||||
aiCfg.OllamaBaseURL = ollama.URL
|
||||
if err := persistence.SaveAIConfig(*aiCfg); err != nil {
|
||||
t.Fatalf("SaveAIConfig: %v", err)
|
||||
}
|
||||
|
||||
handler := NewAISettingsHandler(cfg, persistence, nil)
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/ai/models", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
handler.HandleListModels(rec, req)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
|
||||
var resp struct {
|
||||
Models []struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
} `json:"models"`
|
||||
Error string `json:"error"`
|
||||
}
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("decode: %v", err)
|
||||
}
|
||||
if resp.Error != "" {
|
||||
t.Fatalf("unexpected error: %s", resp.Error)
|
||||
}
|
||||
if len(resp.Models) != 2 {
|
||||
t.Fatalf("expected 2 models, got %+v", resp.Models)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAISettingsHandler_Execute_Ollama(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
ollama := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.URL.Path {
|
||||
case "/api/chat":
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||
"model": "llama3",
|
||||
"created_at": time.Now().Format(time.RFC3339),
|
||||
"message": map[string]any{
|
||||
"role": "assistant",
|
||||
"content": "hello from ollama",
|
||||
},
|
||||
"done": true,
|
||||
"done_reason": "stop",
|
||||
"prompt_eval_count": 3,
|
||||
"eval_count": 5,
|
||||
})
|
||||
case "/api/version":
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{"version": "0.1.0"})
|
||||
case "/api/tags":
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{"models": []map[string]any{{"name": "llama3"}}})
|
||||
default:
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
}))
|
||||
defer ollama.Close()
|
||||
|
||||
tmp := t.TempDir()
|
||||
cfg := &config.Config{DataPath: tmp}
|
||||
persistence := config.NewConfigPersistence(tmp)
|
||||
|
||||
aiCfg := config.NewDefaultAIConfig()
|
||||
aiCfg.Enabled = true
|
||||
aiCfg.Model = "ollama:llama3"
|
||||
aiCfg.OllamaBaseURL = ollama.URL
|
||||
if err := persistence.SaveAIConfig(*aiCfg); err != nil {
|
||||
t.Fatalf("SaveAIConfig: %v", err)
|
||||
}
|
||||
|
||||
handler := NewAISettingsHandler(cfg, persistence, nil)
|
||||
|
||||
body, _ := json.Marshal(AIExecuteRequest{Prompt: "hi"})
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/ai/execute", bytes.NewReader(body))
|
||||
req = req.WithContext(context.Background())
|
||||
rec := httptest.NewRecorder()
|
||||
handler.HandleExecute(rec, req)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
|
||||
var resp AIExecuteResponse
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("decode: %v", err)
|
||||
}
|
||||
if resp.Content != "hello from ollama" || resp.Model == "" {
|
||||
t.Fatalf("unexpected response: %+v", resp)
|
||||
}
|
||||
}
|
||||
|
||||
func ptr[T any](v T) *T { return &v }
|
||||
|
||||
func TestAISettingsHandler_TestConnection_Ollama(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
ollama := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/api/version" {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{"version": "0.1.0"})
|
||||
}))
|
||||
defer ollama.Close()
|
||||
|
||||
tmp := t.TempDir()
|
||||
cfg := &config.Config{DataPath: tmp}
|
||||
persistence := config.NewConfigPersistence(tmp)
|
||||
|
||||
aiCfg := config.NewDefaultAIConfig()
|
||||
aiCfg.Enabled = true
|
||||
aiCfg.Model = "ollama:llama3"
|
||||
aiCfg.OllamaBaseURL = ollama.URL
|
||||
if err := persistence.SaveAIConfig(*aiCfg); err != nil {
|
||||
t.Fatalf("SaveAIConfig: %v", err)
|
||||
}
|
||||
|
||||
handler := NewAISettingsHandler(cfg, persistence, nil)
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/ai/test", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
handler.HandleTestAIConnection(rec, req)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
|
||||
var resp struct {
|
||||
Success bool `json:"success"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("decode: %v", err)
|
||||
}
|
||||
if !resp.Success {
|
||||
t.Fatalf("expected success, got %+v", resp)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAISettingsHandler_TestProvider_Ollama(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
ollama := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/api/version" {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{"version": "0.1.0"})
|
||||
}))
|
||||
defer ollama.Close()
|
||||
|
||||
tmp := t.TempDir()
|
||||
cfg := &config.Config{DataPath: tmp}
|
||||
persistence := config.NewConfigPersistence(tmp)
|
||||
|
||||
aiCfg := config.NewDefaultAIConfig()
|
||||
aiCfg.Enabled = true
|
||||
aiCfg.Model = "ollama:llama3"
|
||||
aiCfg.OllamaBaseURL = ollama.URL
|
||||
if err := persistence.SaveAIConfig(*aiCfg); err != nil {
|
||||
t.Fatalf("SaveAIConfig: %v", err)
|
||||
}
|
||||
|
||||
handler := NewAISettingsHandler(cfg, persistence, nil)
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/ai/test/ollama", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
handler.HandleTestProvider(rec, req)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
|
||||
var resp struct {
|
||||
Success bool `json:"success"`
|
||||
Provider string `json:"provider"`
|
||||
}
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("decode: %v", err)
|
||||
}
|
||||
if !resp.Success || resp.Provider != "ollama" {
|
||||
t.Fatalf("unexpected response: %+v", resp)
|
||||
}
|
||||
}
|
||||
|
|
@ -55,7 +55,7 @@ type Agent struct {
|
|||
logger zerolog.Logger
|
||||
httpClient *http.Client
|
||||
|
||||
kubeClient *kubernetes.Clientset
|
||||
kubeClient kubernetes.Interface
|
||||
restCfg *rest.Config
|
||||
|
||||
agentID string
|
||||
|
|
|
|||
276
internal/kubernetesagent/agent_test.go
Normal file
276
internal/kubernetesagent/agent_test.go
Normal file
|
|
@ -0,0 +1,276 @@
|
|||
package kubernetesagent
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
agentsk8s "github.com/rcourtman/pulse-go-rewrite/pkg/agents/kubernetes"
|
||||
appsv1 "k8s.io/api/apps/v1"
|
||||
corev1 "k8s.io/api/core/v1"
|
||||
"k8s.io/apimachinery/pkg/api/resource"
|
||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||
"k8s.io/client-go/kubernetes/fake"
|
||||
)
|
||||
|
||||
func TestBuildRESTConfig_ExplicitKubeconfig(t *testing.T) {
|
||||
tmp := t.TempDir()
|
||||
kubeconfigPath := filepath.Join(tmp, "config")
|
||||
|
||||
kubeconfig := `
|
||||
apiVersion: v1
|
||||
kind: Config
|
||||
clusters:
|
||||
- name: c1
|
||||
cluster:
|
||||
server: https://k8s.example.invalid
|
||||
contexts:
|
||||
- name: ctx1
|
||||
context:
|
||||
cluster: c1
|
||||
user: u1
|
||||
- name: ctx2
|
||||
context:
|
||||
cluster: c1
|
||||
user: u1
|
||||
current-context: ctx1
|
||||
users:
|
||||
- name: u1
|
||||
user:
|
||||
token: test
|
||||
`
|
||||
if err := os.WriteFile(kubeconfigPath, []byte(strings.TrimSpace(kubeconfig)), 0o600); err != nil {
|
||||
t.Fatalf("write kubeconfig: %v", err)
|
||||
}
|
||||
|
||||
restCfg, ctxName, err := buildRESTConfig(kubeconfigPath, "ctx2")
|
||||
if err != nil {
|
||||
t.Fatalf("buildRESTConfig: %v", err)
|
||||
}
|
||||
if restCfg.Host != "https://k8s.example.invalid" {
|
||||
t.Fatalf("restCfg.Host = %q", restCfg.Host)
|
||||
}
|
||||
if ctxName != "ctx2" {
|
||||
t.Fatalf("contextName = %q", ctxName)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNamespaceAllowed_IncludeExclude(t *testing.T) {
|
||||
a := &Agent{
|
||||
includeNamespaces: makeNamespaceSet([]string{"a", "b"}),
|
||||
excludeNamespaces: makeNamespaceSet([]string{"b"}),
|
||||
}
|
||||
|
||||
if !a.namespaceAllowed("a") {
|
||||
t.Fatalf("expected namespace a allowed")
|
||||
}
|
||||
if a.namespaceAllowed("b") {
|
||||
t.Fatalf("expected namespace b excluded")
|
||||
}
|
||||
if a.namespaceAllowed("") {
|
||||
t.Fatalf("expected empty namespace disallowed")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRolesFromNodeLabels(t *testing.T) {
|
||||
roles := rolesFromNodeLabels(map[string]string{
|
||||
"node-role.kubernetes.io/master": "",
|
||||
"kubernetes.io/role": "worker",
|
||||
})
|
||||
if len(roles) != 2 || roles[0] != "master" || roles[1] != "worker" {
|
||||
t.Fatalf("unexpected roles: %+v", roles)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCollectPods_FiltersProblemsAndSorts(t *testing.T) {
|
||||
clientset := fake.NewSimpleClientset(
|
||||
&corev1.Pod{
|
||||
ObjectMeta: metav1.ObjectMeta{Namespace: "a", Name: "ok"},
|
||||
Status: corev1.PodStatus{
|
||||
Phase: corev1.PodRunning,
|
||||
ContainerStatuses: []corev1.ContainerStatus{
|
||||
{Name: "c", Ready: true, State: corev1.ContainerState{Running: &corev1.ContainerStateRunning{}}},
|
||||
},
|
||||
},
|
||||
},
|
||||
&corev1.Pod{
|
||||
ObjectMeta: metav1.ObjectMeta{Namespace: "a", Name: "pending"},
|
||||
Status: corev1.PodStatus{Phase: corev1.PodPending},
|
||||
},
|
||||
&corev1.Pod{
|
||||
ObjectMeta: metav1.ObjectMeta{Namespace: "b", Name: "not-ready"},
|
||||
Status: corev1.PodStatus{
|
||||
Phase: corev1.PodRunning,
|
||||
ContainerStatuses: []corev1.ContainerStatus{
|
||||
{Name: "c", Ready: false, State: corev1.ContainerState{Running: &corev1.ContainerStateRunning{}}},
|
||||
},
|
||||
},
|
||||
},
|
||||
&corev1.Pod{
|
||||
ObjectMeta: metav1.ObjectMeta{Namespace: "b", Name: "failed"},
|
||||
Status: corev1.PodStatus{Phase: corev1.PodFailed, Reason: "CrashLoopBackOff"},
|
||||
},
|
||||
)
|
||||
|
||||
a := &Agent{
|
||||
cfg: Config{
|
||||
MaxPods: 2,
|
||||
IncludeAllPods: false,
|
||||
},
|
||||
kubeClient: clientset,
|
||||
includeNamespaces: makeNamespaceSet(nil),
|
||||
excludeNamespaces: makeNamespaceSet(nil),
|
||||
}
|
||||
|
||||
pods, err := a.collectPods(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("collectPods: %v", err)
|
||||
}
|
||||
if len(pods) != 2 {
|
||||
t.Fatalf("expected MaxPods=2, got %d (%+v)", len(pods), pods)
|
||||
}
|
||||
if pods[0].Namespace != "a" || pods[0].Name != "pending" {
|
||||
t.Fatalf("unexpected first pod: %+v", pods[0])
|
||||
}
|
||||
if pods[1].Namespace != "b" {
|
||||
t.Fatalf("unexpected second pod: %+v", pods[1])
|
||||
}
|
||||
}
|
||||
|
||||
func TestCollectDeployments_FiltersProblems(t *testing.T) {
|
||||
replicas := int32(3)
|
||||
okReplicas := int32(2)
|
||||
|
||||
clientset := fake.NewSimpleClientset(
|
||||
&appsv1.Deployment{
|
||||
ObjectMeta: metav1.ObjectMeta{Namespace: "a", Name: "bad"},
|
||||
Spec: appsv1.DeploymentSpec{Replicas: &replicas},
|
||||
Status: appsv1.DeploymentStatus{AvailableReplicas: 2, ReadyReplicas: 2, UpdatedReplicas: 2},
|
||||
},
|
||||
&appsv1.Deployment{
|
||||
ObjectMeta: metav1.ObjectMeta{Namespace: "a", Name: "ok"},
|
||||
Spec: appsv1.DeploymentSpec{Replicas: &okReplicas},
|
||||
Status: appsv1.DeploymentStatus{AvailableReplicas: 2, ReadyReplicas: 2, UpdatedReplicas: 2},
|
||||
},
|
||||
&appsv1.Deployment{
|
||||
ObjectMeta: metav1.ObjectMeta{Namespace: "a", Name: "disabled"},
|
||||
Spec: appsv1.DeploymentSpec{Replicas: nil},
|
||||
Status: appsv1.DeploymentStatus{AvailableReplicas: 0, ReadyReplicas: 0, UpdatedReplicas: 0},
|
||||
},
|
||||
)
|
||||
|
||||
a := &Agent{
|
||||
cfg: Config{IncludeAllPods: false},
|
||||
kubeClient: clientset,
|
||||
includeNamespaces: makeNamespaceSet(nil),
|
||||
excludeNamespaces: makeNamespaceSet(nil),
|
||||
}
|
||||
|
||||
deps, err := a.collectDeployments(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("collectDeployments: %v", err)
|
||||
}
|
||||
if len(deps) != 1 {
|
||||
t.Fatalf("expected 1 deployment, got %d (%+v)", len(deps), deps)
|
||||
}
|
||||
if deps[0].Name != "bad" {
|
||||
t.Fatalf("unexpected deployment: %+v", deps[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestCollectNodes_MapsReadyRolesAndResources(t *testing.T) {
|
||||
clientset := fake.NewSimpleClientset(
|
||||
&corev1.Node{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: "n1",
|
||||
UID: "uid1",
|
||||
Labels: map[string]string{
|
||||
"node-role.kubernetes.io/master": "",
|
||||
},
|
||||
},
|
||||
Spec: corev1.NodeSpec{Unschedulable: true},
|
||||
Status: corev1.NodeStatus{
|
||||
NodeInfo: corev1.NodeSystemInfo{
|
||||
KubeletVersion: "v1.30.0",
|
||||
ContainerRuntimeVersion: "containerd://1.7.0",
|
||||
OSImage: "linux",
|
||||
KernelVersion: "6.0",
|
||||
Architecture: "amd64",
|
||||
},
|
||||
Conditions: []corev1.NodeCondition{
|
||||
{Type: corev1.NodeReady, Status: corev1.ConditionTrue},
|
||||
},
|
||||
Capacity: corev1.ResourceList{
|
||||
corev1.ResourceCPU: resource.MustParse("4"),
|
||||
corev1.ResourceMemory: resource.MustParse("8Gi"),
|
||||
corev1.ResourcePods: resource.MustParse("110"),
|
||||
},
|
||||
Allocatable: corev1.ResourceList{
|
||||
corev1.ResourceCPU: resource.MustParse("3"),
|
||||
corev1.ResourceMemory: resource.MustParse("7Gi"),
|
||||
corev1.ResourcePods: resource.MustParse("100"),
|
||||
},
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
a := &Agent{kubeClient: clientset}
|
||||
nodes, err := a.collectNodes(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("collectNodes: %v", err)
|
||||
}
|
||||
if len(nodes) != 1 {
|
||||
t.Fatalf("expected 1 node, got %d", len(nodes))
|
||||
}
|
||||
n := nodes[0]
|
||||
if !n.Ready || !n.Unschedulable {
|
||||
t.Fatalf("unexpected ready/unschedulable: %+v", n)
|
||||
}
|
||||
if n.Capacity.CPUCores != 4 || n.Allocatable.CPUCores != 3 {
|
||||
t.Fatalf("unexpected cpu: %+v", n)
|
||||
}
|
||||
if len(n.Roles) != 1 || n.Roles[0] != "master" {
|
||||
t.Fatalf("unexpected roles: %+v", n.Roles)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSendReport_SetsHeadersAndHandlesStatus(t *testing.T) {
|
||||
var sawAuth bool
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/api/agents/kubernetes/report" {
|
||||
t.Fatalf("unexpected path: %s", r.URL.Path)
|
||||
}
|
||||
if !strings.HasPrefix(r.Header.Get("Authorization"), "Bearer ") {
|
||||
t.Fatalf("Authorization = %q", r.Header.Get("Authorization"))
|
||||
}
|
||||
if r.Header.Get("X-API-Token") != "token" {
|
||||
t.Fatalf("X-API-Token = %q", r.Header.Get("X-API-Token"))
|
||||
}
|
||||
if r.Header.Get("User-Agent") != reportUserAgent+"1.2.3" {
|
||||
t.Fatalf("User-Agent = %q", r.Header.Get("User-Agent"))
|
||||
}
|
||||
sawAuth = true
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
a := &Agent{
|
||||
cfg: Config{APIToken: "token"},
|
||||
httpClient: server.Client(),
|
||||
pulseURL: server.URL,
|
||||
agentVersion: "1.2.3",
|
||||
}
|
||||
|
||||
if err := a.sendReport(context.Background(), agentsk8s.Report{Timestamp: time.Now().UTC()}); err != nil {
|
||||
t.Fatalf("sendReport: %v", err)
|
||||
}
|
||||
if !sawAuth {
|
||||
t.Fatalf("expected server to receive request")
|
||||
}
|
||||
}
|
||||
|
||||
183
pkg/pbs/client_http_test.go
Normal file
183
pkg/pbs/client_http_test.go
Normal file
|
|
@ -0,0 +1,183 @@
|
|||
package pbs
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestNewClient_TokenAuth_SetsAuthorizationHeader(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/api2/json/version" {
|
||||
t.Fatalf("unexpected path: %s", r.URL.Path)
|
||||
}
|
||||
if got := r.Header.Get("Authorization"); got != "PBSAPIToken=root@pam!pulse-token:secret" {
|
||||
t.Fatalf("Authorization = %q", got)
|
||||
}
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||
"data": Version{Version: "3.0", Release: "1", Repoid: "abc"},
|
||||
})
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client, err := NewClient(ClientConfig{
|
||||
Host: server.URL,
|
||||
TokenName: "root@pam!pulse-token",
|
||||
TokenValue: "secret",
|
||||
Timeout: 2 * time.Second,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("NewClient: %v", err)
|
||||
}
|
||||
|
||||
v, err := client.GetVersion(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("GetVersion: %v", err)
|
||||
}
|
||||
if v.Version != "3.0" {
|
||||
t.Fatalf("unexpected version: %+v", v)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewClient_PasswordAuth_FallsBackToFormOnUnsupportedMediaType(t *testing.T) {
|
||||
var calls int32
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/api2/json/access/ticket" {
|
||||
t.Fatalf("unexpected path: %s", r.URL.Path)
|
||||
}
|
||||
|
||||
switch atomic.AddInt32(&calls, 1) {
|
||||
case 1:
|
||||
if ct := r.Header.Get("Content-Type"); !strings.Contains(ct, "application/json") {
|
||||
t.Fatalf("expected json content-type, got %q", ct)
|
||||
}
|
||||
w.WriteHeader(http.StatusUnsupportedMediaType)
|
||||
_, _ = w.Write([]byte("unsupported"))
|
||||
case 2:
|
||||
if ct := r.Header.Get("Content-Type"); !strings.Contains(ct, "application/x-www-form-urlencoded") {
|
||||
t.Fatalf("expected form content-type, got %q", ct)
|
||||
}
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||
"data": map[string]any{
|
||||
"ticket": "ticket123",
|
||||
"CSRFPreventionToken": "csrf456",
|
||||
},
|
||||
})
|
||||
default:
|
||||
t.Fatalf("unexpected call count")
|
||||
}
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client, err := NewClient(ClientConfig{
|
||||
Host: server.URL,
|
||||
User: "root@pam",
|
||||
Password: "password",
|
||||
Timeout: 2 * time.Second,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("NewClient: %v", err)
|
||||
}
|
||||
if client.auth.ticket != "ticket123" || client.auth.csrfToken != "csrf456" {
|
||||
t.Fatalf("expected auth fields to be set, got ticket=%q csrf=%q", client.auth.ticket, client.auth.csrfToken)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClient_request_SendsTicketAndCSRFToken(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/api2/json/test" {
|
||||
t.Fatalf("unexpected path: %s", r.URL.Path)
|
||||
}
|
||||
if got := r.Header.Get("Cookie"); got != "PBSAuthCookie=ticket123" {
|
||||
t.Fatalf("Cookie = %q", got)
|
||||
}
|
||||
if got := r.Header.Get("CSRFPreventionToken"); got != "csrf456" {
|
||||
t.Fatalf("CSRFPreventionToken = %q", got)
|
||||
}
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client, err := NewClient(ClientConfig{
|
||||
Host: server.URL,
|
||||
User: "root@pam",
|
||||
Timeout: 2 * time.Second,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("NewClient: %v", err)
|
||||
}
|
||||
|
||||
client.auth.ticket = "ticket123"
|
||||
client.auth.csrfToken = "csrf456"
|
||||
client.auth.expiresAt = time.Now().Add(time.Hour)
|
||||
|
||||
resp, err := client.request(context.Background(), http.MethodPost, "/test", url.Values{"a": {"b"}})
|
||||
if err != nil {
|
||||
t.Fatalf("request: %v", err)
|
||||
}
|
||||
_ = resp.Body.Close()
|
||||
}
|
||||
|
||||
func TestClient_GetNodeStatus_PermissionDeniedReturnsNil(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/api2/json/nodes/localhost/status" {
|
||||
t.Fatalf("unexpected path: %s", r.URL.Path)
|
||||
}
|
||||
w.WriteHeader(http.StatusForbidden)
|
||||
_, _ = w.Write([]byte("permission denied"))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client, err := NewClient(ClientConfig{
|
||||
Host: server.URL,
|
||||
TokenName: "root@pam!pulse-token",
|
||||
TokenValue: "secret",
|
||||
Timeout: 2 * time.Second,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("NewClient: %v", err)
|
||||
}
|
||||
|
||||
status, err := client.GetNodeStatus(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("GetNodeStatus: %v", err)
|
||||
}
|
||||
if status != nil {
|
||||
t.Fatalf("expected nil status on permission error, got: %+v", status)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClient_GetDatastores_HTMLResponseOnHTTP(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/api2/json/admin/datastore" {
|
||||
t.Fatalf("unexpected path: %s", r.URL.Path)
|
||||
}
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, _ = w.Write([]byte("<html><body>error</body></html>"))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client, err := NewClient(ClientConfig{
|
||||
Host: server.URL, // http://...
|
||||
TokenName: "root@pam!pulse-token",
|
||||
TokenValue: "secret",
|
||||
Timeout: 2 * time.Second,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("NewClient: %v", err)
|
||||
}
|
||||
|
||||
_, err = client.GetDatastores(context.Background())
|
||||
if err == nil {
|
||||
t.Fatalf("expected error")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "Try changing your URL") {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
97
pkg/proxmox/zfs_details_test.go
Normal file
97
pkg/proxmox/zfs_details_test.go
Normal file
|
|
@ -0,0 +1,97 @@
|
|||
package proxmox
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestClient_GetZFSPoolsWithDetails_MergesDetailWhenAvailable(t *testing.T) {
|
||||
t.Helper()
|
||||
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Header.Get("Authorization") == "" {
|
||||
t.Fatalf("expected Authorization header to be set")
|
||||
}
|
||||
|
||||
switch r.URL.Path {
|
||||
case "/api2/json/nodes/pve1/disks/zfs":
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||
"data": []ZFSPoolStatus{
|
||||
{Name: "rpool", Health: "ONLINE", Size: 100, Alloc: 60, Free: 40, Frag: 1, Dedup: 1.0},
|
||||
{Name: "data", Health: "DEGRADED", Size: 200, Alloc: 150, Free: 50, Frag: 10, Dedup: 1.2},
|
||||
},
|
||||
})
|
||||
return
|
||||
case "/api2/json/nodes/pve1/disks/zfs/rpool":
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||
"data": ZFSPoolDetail{
|
||||
Name: "rpool",
|
||||
State: "ONLINE",
|
||||
Status: "ok",
|
||||
Scan: "none requested",
|
||||
Errors: "No known data errors",
|
||||
Children: []ZFSPoolDevice{
|
||||
{
|
||||
Name: "mirror-0",
|
||||
State: "DEGRADED",
|
||||
Leaf: 0,
|
||||
Children: []ZFSPoolDevice{
|
||||
{Name: "sda", State: "ONLINE", Leaf: 1, Read: 1},
|
||||
{Name: "sdb", State: "ONLINE", Leaf: 1},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
return
|
||||
case "/api2/json/nodes/pve1/disks/zfs/data":
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
_, _ = w.Write([]byte("boom"))
|
||||
return
|
||||
default:
|
||||
t.Fatalf("unexpected path: %s", r.URL.Path)
|
||||
}
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client, err := NewClient(ClientConfig{
|
||||
Host: server.URL,
|
||||
TokenName: "root@pam!pulse-token",
|
||||
TokenValue: "secret",
|
||||
Timeout: 2 * time.Second,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("NewClient: %v", err)
|
||||
}
|
||||
|
||||
pools, err := client.GetZFSPoolsWithDetails(context.Background(), "pve1")
|
||||
if err != nil {
|
||||
t.Fatalf("GetZFSPoolsWithDetails: %v", err)
|
||||
}
|
||||
if len(pools) != 2 {
|
||||
t.Fatalf("expected 2 pools, got %d", len(pools))
|
||||
}
|
||||
|
||||
var rpool ZFSPoolInfo
|
||||
var dataPool ZFSPoolInfo
|
||||
for _, p := range pools {
|
||||
switch p.Name {
|
||||
case "rpool":
|
||||
rpool = p
|
||||
case "data":
|
||||
dataPool = p
|
||||
}
|
||||
}
|
||||
|
||||
if rpool.State != "ONLINE" || rpool.Status != "ok" || rpool.Errors == "" || len(rpool.Devices) == 0 {
|
||||
t.Fatalf("expected details to be applied to rpool, got: %+v", rpool)
|
||||
}
|
||||
if dataPool.State != "" || dataPool.Status != "" || dataPool.Errors != "" || len(dataPool.Devices) != 0 {
|
||||
t.Fatalf("expected data pool to fall back to list-only info, got: %+v", dataPool)
|
||||
}
|
||||
}
|
||||
|
||||
61
pkg/tlsutil/dnscache_test.go
Normal file
61
pkg/tlsutil/dnscache_test.go
Normal file
|
|
@ -0,0 +1,61 @@
|
|||
package tlsutil
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestDialContextWithCache_Success(t *testing.T) {
|
||||
ln, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
t.Fatalf("listen: %v", err)
|
||||
}
|
||||
defer ln.Close()
|
||||
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
defer close(done)
|
||||
conn, err := ln.Accept()
|
||||
if err == nil {
|
||||
_ = conn.Close()
|
||||
}
|
||||
}()
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
|
||||
defer cancel()
|
||||
|
||||
conn, err := DialContextWithCache(ctx, "tcp", ln.Addr().String())
|
||||
if err != nil {
|
||||
t.Fatalf("DialContextWithCache: %v", err)
|
||||
}
|
||||
_ = conn.Close()
|
||||
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Fatalf("server did not accept connection")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDialContextWithCache_BadAddress(t *testing.T) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
|
||||
defer cancel()
|
||||
|
||||
_, err := DialContextWithCache(ctx, "tcp", "missing-port")
|
||||
if err == nil {
|
||||
t.Fatalf("expected error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDialContextWithCache_UnresolvableHost(t *testing.T) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
|
||||
defer cancel()
|
||||
|
||||
_, err := DialContextWithCache(ctx, "tcp", "this-domain-should-not-exist.invalid:80")
|
||||
if err == nil {
|
||||
t.Fatalf("expected error")
|
||||
}
|
||||
}
|
||||
|
||||
Loading…
Reference in a new issue