mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-04-28 19:41:17 +00:00
- Refactored tool execution to handle tenant-scoped contexts - Added new tests for infrastructure, control, and kubernetes tools - Improved test coverage for agentic chat and approval store
517 lines
15 KiB
Go
517 lines
15 KiB
Go
package tools
|
|
|
|
import (
|
|
"errors"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/rcourtman/pulse-go-rewrite/internal/alerts"
|
|
"github.com/rcourtman/pulse-go-rewrite/internal/models"
|
|
)
|
|
|
|
type fakeStateGetter struct {
|
|
state models.StateSnapshot
|
|
}
|
|
|
|
func (f fakeStateGetter) GetState() models.StateSnapshot {
|
|
return f.state
|
|
}
|
|
|
|
type fakeAlertManager struct {
|
|
alerts []alerts.Alert
|
|
}
|
|
|
|
func (f fakeAlertManager) GetActiveAlerts() []alerts.Alert {
|
|
return f.alerts
|
|
}
|
|
|
|
type fakeMetricsSource struct {
|
|
allGuest map[string]map[string][]RawMetricPoint
|
|
guest map[string]map[string][]RawMetricPoint
|
|
node map[string]map[string][]RawMetricPoint
|
|
}
|
|
|
|
func (f *fakeMetricsSource) GetGuestMetrics(guestID string, metricType string, _ time.Duration) []RawMetricPoint {
|
|
if f.guest == nil {
|
|
return nil
|
|
}
|
|
if byMetric, ok := f.guest[guestID]; ok {
|
|
return byMetric[metricType]
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (f *fakeMetricsSource) GetNodeMetrics(nodeID string, metricType string, _ time.Duration) []RawMetricPoint {
|
|
if f.node == nil {
|
|
return nil
|
|
}
|
|
if byMetric, ok := f.node[nodeID]; ok {
|
|
return byMetric[metricType]
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (f *fakeMetricsSource) GetAllGuestMetrics(guestID string, _ time.Duration) map[string][]RawMetricPoint {
|
|
if f.allGuest == nil {
|
|
return nil
|
|
}
|
|
return f.allGuest[guestID]
|
|
}
|
|
|
|
type fakeBaselineSource struct {
|
|
mean float64
|
|
stddev float64
|
|
ok bool
|
|
all map[string]map[string]BaselineData
|
|
}
|
|
|
|
func (f *fakeBaselineSource) GetBaseline(_ string, _ string) (float64, float64, int, bool) {
|
|
return f.mean, f.stddev, 10, f.ok
|
|
}
|
|
|
|
func (f *fakeBaselineSource) GetAllBaselines() map[string]map[string]BaselineData {
|
|
return f.all
|
|
}
|
|
|
|
type fakePatternSource struct {
|
|
patterns []PatternData
|
|
predictions []PredictionData
|
|
}
|
|
|
|
func (f *fakePatternSource) GetPatterns() []PatternData {
|
|
return f.patterns
|
|
}
|
|
|
|
func (f *fakePatternSource) GetPredictions() []PredictionData {
|
|
return f.predictions
|
|
}
|
|
|
|
type fakeFindingsManager struct {
|
|
resolveArgs []string
|
|
dismissArgs []string
|
|
resolveErr error
|
|
dismissErr error
|
|
}
|
|
|
|
func (f *fakeFindingsManager) ResolveFinding(findingID, note string) error {
|
|
f.resolveArgs = []string{findingID, note}
|
|
return f.resolveErr
|
|
}
|
|
|
|
func (f *fakeFindingsManager) DismissFinding(findingID, reason, note string) error {
|
|
f.dismissArgs = []string{findingID, reason, note}
|
|
return f.dismissErr
|
|
}
|
|
|
|
type fakeMetadataUpdater struct {
|
|
resourceArgs []string
|
|
err error
|
|
}
|
|
|
|
func (f *fakeMetadataUpdater) SetResourceURL(resourceType, resourceID, url string) error {
|
|
f.resourceArgs = []string{resourceType, resourceID, url}
|
|
return f.err
|
|
}
|
|
|
|
type fakeUpdatesMonitor struct {
|
|
state models.StateSnapshot
|
|
checkStatus models.DockerHostCommandStatus
|
|
updateStatus models.DockerHostCommandStatus
|
|
checkErr error
|
|
updateErr error
|
|
}
|
|
|
|
func (f *fakeUpdatesMonitor) GetState() models.StateSnapshot {
|
|
return f.state
|
|
}
|
|
|
|
func (f *fakeUpdatesMonitor) QueueDockerCheckUpdatesCommand(_ string) (models.DockerHostCommandStatus, error) {
|
|
return f.checkStatus, f.checkErr
|
|
}
|
|
|
|
func (f *fakeUpdatesMonitor) QueueDockerContainerUpdateCommand(_ string, _ string, _ string) (models.DockerHostCommandStatus, error) {
|
|
return f.updateStatus, f.updateErr
|
|
}
|
|
|
|
type fakeUpdatesConfig struct {
|
|
enabled bool
|
|
}
|
|
|
|
func (f *fakeUpdatesConfig) IsDockerUpdateActionsEnabled() bool {
|
|
return f.enabled
|
|
}
|
|
|
|
func TestAlertManagerMCPAdapter(t *testing.T) {
|
|
if NewAlertManagerMCPAdapter(nil) != nil {
|
|
t.Fatal("expected nil adapter for nil manager")
|
|
}
|
|
|
|
ts := time.Now()
|
|
manager := fakeAlertManager{
|
|
alerts: []alerts.Alert{
|
|
{
|
|
ID: "a1",
|
|
ResourceID: "vm-1",
|
|
ResourceName: "vm1",
|
|
Type: "cpu",
|
|
Level: alerts.AlertLevelWarning,
|
|
Value: 80,
|
|
Threshold: 70,
|
|
StartTime: ts,
|
|
Message: "high cpu",
|
|
},
|
|
},
|
|
}
|
|
|
|
adapter := NewAlertManagerMCPAdapter(manager)
|
|
got := adapter.GetActiveAlerts()
|
|
if len(got) != 1 {
|
|
t.Fatalf("expected 1 alert, got %d", len(got))
|
|
}
|
|
if got[0].Severity != "warning" || got[0].ResourceName != "vm1" || got[0].Message != "high cpu" {
|
|
t.Fatalf("unexpected alert mapping: %+v", got[0])
|
|
}
|
|
}
|
|
|
|
func TestStorageBackupDiskAdapters(t *testing.T) {
|
|
state := models.StateSnapshot{
|
|
Storage: []models.Storage{{ID: "s1"}},
|
|
CephClusters: []models.CephCluster{{ID: "c1"}},
|
|
Backups: models.Backups{PVE: models.PVEBackups{
|
|
BackupTasks: []models.BackupTask{{ID: "task1"}},
|
|
}},
|
|
PBSInstances: []models.PBSInstance{{ID: "pbs1"}},
|
|
Hosts: []models.Host{{ID: "h1"}},
|
|
}
|
|
|
|
if NewStorageMCPAdapter(nil) != nil {
|
|
t.Fatal("expected nil storage adapter for nil state")
|
|
}
|
|
emptyStorage := (&StorageMCPAdapter{}).GetStorage()
|
|
if emptyStorage != nil {
|
|
t.Fatal("expected nil storage when state getter missing")
|
|
}
|
|
|
|
storageAdapter := NewStorageMCPAdapter(fakeStateGetter{state: state})
|
|
if len(storageAdapter.GetStorage()) != 1 {
|
|
t.Fatal("expected storage data")
|
|
}
|
|
if len(storageAdapter.GetCephClusters()) != 1 {
|
|
t.Fatal("expected ceph data")
|
|
}
|
|
|
|
if NewBackupMCPAdapter(nil) != nil {
|
|
t.Fatal("expected nil backup adapter for nil state")
|
|
}
|
|
backupAdapter := NewBackupMCPAdapter(fakeStateGetter{state: state})
|
|
if len(backupAdapter.GetBackups().PVE.BackupTasks) != 1 {
|
|
t.Fatal("expected backup tasks")
|
|
}
|
|
if len(backupAdapter.GetPBSInstances()) != 1 {
|
|
t.Fatal("expected pbs instances")
|
|
}
|
|
|
|
if NewDiskHealthMCPAdapter(nil) != nil {
|
|
t.Fatal("expected nil disk health adapter for nil state")
|
|
}
|
|
diskAdapter := NewDiskHealthMCPAdapter(fakeStateGetter{state: state})
|
|
if len(diskAdapter.GetHosts()) != 1 {
|
|
t.Fatal("expected hosts")
|
|
}
|
|
}
|
|
|
|
func TestMetricsHistoryMCPAdapter(t *testing.T) {
|
|
now := time.Now()
|
|
points := map[string][]RawMetricPoint{
|
|
"cpu": {{Value: 10, Timestamp: now}},
|
|
"memory": {{Value: 20, Timestamp: now}},
|
|
}
|
|
source := &fakeMetricsSource{
|
|
allGuest: map[string]map[string][]RawMetricPoint{
|
|
"100": points,
|
|
},
|
|
}
|
|
|
|
adapter := NewMetricsHistoryMCPAdapter(fakeStateGetter{}, source)
|
|
got, err := adapter.GetResourceMetrics("100", time.Hour)
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if len(got) != 1 || got[0].CPU != 10 || got[0].Memory != 20 {
|
|
t.Fatalf("unexpected merged metrics: %+v", got)
|
|
}
|
|
|
|
// Node fallback when guest metrics empty
|
|
source = &fakeMetricsSource{
|
|
allGuest: map[string]map[string][]RawMetricPoint{},
|
|
node: map[string]map[string][]RawMetricPoint{
|
|
"node1": {
|
|
"cpu": {{Value: 5, Timestamp: now}},
|
|
"memory": {{Value: 15, Timestamp: now}},
|
|
},
|
|
},
|
|
}
|
|
adapter = NewMetricsHistoryMCPAdapter(fakeStateGetter{}, source)
|
|
got, err = adapter.GetResourceMetrics("node1", time.Hour)
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if len(got) != 1 || got[0].CPU != 5 || got[0].Memory != 15 {
|
|
t.Fatalf("unexpected node metrics: %+v", got)
|
|
}
|
|
|
|
adapter = &MetricsHistoryMCPAdapter{}
|
|
empty, err := adapter.GetResourceMetrics("missing", time.Hour)
|
|
if err != nil || empty != nil {
|
|
t.Fatal("expected nil metrics when source missing")
|
|
}
|
|
}
|
|
|
|
func TestMetricsSummaryAndHelpers(t *testing.T) {
|
|
now := time.Now()
|
|
source := &fakeMetricsSource{
|
|
guest: map[string]map[string][]RawMetricPoint{
|
|
"100": {
|
|
"cpu": {{Value: 10, Timestamp: now}, {Value: 20, Timestamp: now.Add(time.Minute)}},
|
|
"memory": {{Value: 30, Timestamp: now}},
|
|
},
|
|
},
|
|
node: map[string]map[string][]RawMetricPoint{
|
|
"node1": {
|
|
"cpu": {{Value: 0, Timestamp: now}, {Value: 10, Timestamp: now.Add(time.Minute)}},
|
|
"memory": {{Value: 5, Timestamp: now}},
|
|
},
|
|
},
|
|
}
|
|
|
|
state := models.StateSnapshot{
|
|
VMs: []models.VM{{VMID: 100, Name: "vm1"}},
|
|
Containers: []models.Container{{VMID: 200, Name: "ct1"}},
|
|
Nodes: []models.Node{{ID: "node1", Name: "node-1"}},
|
|
}
|
|
|
|
adapter := NewMetricsHistoryMCPAdapter(fakeStateGetter{state: state}, source)
|
|
summary, err := adapter.GetAllMetricsSummary(time.Hour)
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if len(summary) != 2 {
|
|
t.Fatalf("expected summaries for vm and node, got %d", len(summary))
|
|
}
|
|
if summary["100"].ResourceName != "vm1" || summary["node1"].ResourceName != "node-1" {
|
|
t.Fatalf("unexpected summary names: %+v", summary)
|
|
}
|
|
|
|
merged := mergeMetricsByTimestamp(map[string][]RawMetricPoint{
|
|
"cpu": {{Value: 1, Timestamp: now}},
|
|
"memory": {{Value: 2, Timestamp: now}},
|
|
"disk": {{Value: 3, Timestamp: now.Add(time.Minute)}},
|
|
})
|
|
if len(merged) != 2 {
|
|
t.Fatalf("expected 2 merged points, got %d", len(merged))
|
|
}
|
|
|
|
avg, max := computeStats([]RawMetricPoint{{Value: 1}, {Value: 3}})
|
|
if avg != 2 || max != 3 {
|
|
t.Fatalf("unexpected stats avg=%v max=%v", avg, max)
|
|
}
|
|
|
|
if computeTrend([]RawMetricPoint{{Value: 1}}) != "stable" {
|
|
t.Fatal("expected stable trend for short series")
|
|
}
|
|
if computeTrend([]RawMetricPoint{{Value: 0}, {Value: 0}, {Value: 0}, {Value: 10}}) != "growing" {
|
|
t.Fatal("expected growing trend")
|
|
}
|
|
if computeTrend([]RawMetricPoint{{Value: 10}, {Value: 10}, {Value: 10}, {Value: 0}}) != "declining" {
|
|
t.Fatal("expected declining trend")
|
|
}
|
|
if computeTrend([]RawMetricPoint{{Value: 1}, {Value: 2}, {Value: 2}, {Value: 2}}) != "stable" {
|
|
t.Fatal("expected stable trend within threshold")
|
|
}
|
|
}
|
|
|
|
func TestBaselineMCPAdapter(t *testing.T) {
|
|
if NewBaselineMCPAdapter(nil) != nil {
|
|
t.Fatal("expected nil baseline adapter for nil source")
|
|
}
|
|
adapter := NewBaselineMCPAdapter(&fakeBaselineSource{mean: 10, stddev: 2, ok: true})
|
|
baseline := adapter.GetBaseline("vm1", "cpu")
|
|
if baseline == nil || baseline.Min != 6 || baseline.Max != 14 {
|
|
t.Fatalf("unexpected baseline: %+v", baseline)
|
|
}
|
|
|
|
adapter = &BaselineMCPAdapter{}
|
|
if adapter.GetBaseline("vm1", "cpu") != nil {
|
|
t.Fatal("expected nil baseline when source missing")
|
|
}
|
|
|
|
adapter = NewBaselineMCPAdapter(&fakeBaselineSource{ok: false})
|
|
if adapter.GetBaseline("vm1", "cpu") != nil {
|
|
t.Fatal("expected nil baseline when not found")
|
|
}
|
|
|
|
adapter = NewBaselineMCPAdapter(&fakeBaselineSource{all: nil})
|
|
if adapter.GetAllBaselines() != nil {
|
|
t.Fatal("expected nil baselines when source returns nil")
|
|
}
|
|
|
|
all := map[string]map[string]BaselineData{
|
|
"100": {"cpu": {Mean: 5, StdDev: 1}},
|
|
}
|
|
adapter = NewBaselineMCPAdapter(&fakeBaselineSource{all: all})
|
|
allBaselines := adapter.GetAllBaselines()
|
|
if allBaselines["100"]["cpu"].Min != 3 || allBaselines["100"]["cpu"].Max != 7 {
|
|
t.Fatalf("unexpected all baselines: %+v", allBaselines)
|
|
}
|
|
}
|
|
|
|
func TestPatternMCPAdapter(t *testing.T) {
|
|
state := models.StateSnapshot{
|
|
VMs: []models.VM{{VMID: 100, Name: "vm1"}},
|
|
Nodes: []models.Node{{ID: "node1", Name: "node-1"}},
|
|
Containers: []models.Container{{VMID: 200, Name: "ct1"}},
|
|
}
|
|
source := &fakePatternSource{
|
|
patterns: []PatternData{
|
|
{ResourceID: "100", PatternType: "cpu", Description: "spike"},
|
|
{ResourceID: "node1", PatternType: "disk", Description: "trend"},
|
|
},
|
|
predictions: []PredictionData{
|
|
{ResourceID: "200", IssueType: "memory", Recommendation: "scale"},
|
|
},
|
|
}
|
|
|
|
adapter := NewPatternMCPAdapter(source, fakeStateGetter{state: state})
|
|
patterns := adapter.GetPatterns()
|
|
if len(patterns) != 2 || patterns[0].ResourceName != "vm1" || patterns[1].ResourceName != "node-1" {
|
|
t.Fatalf("unexpected patterns: %+v", patterns)
|
|
}
|
|
predictions := adapter.GetPredictions()
|
|
if len(predictions) != 1 || predictions[0].ResourceName != "ct1" {
|
|
t.Fatalf("unexpected predictions: %+v", predictions)
|
|
}
|
|
|
|
adapter = NewPatternMCPAdapter(source, nil)
|
|
patterns = adapter.GetPatterns()
|
|
if patterns[0].ResourceName != "100" {
|
|
t.Fatal("expected resource ID when state missing")
|
|
}
|
|
}
|
|
|
|
func TestFindingsAndMetadataAdapters(t *testing.T) {
|
|
manager := &fakeFindingsManager{resolveErr: errors.New("resolve"), dismissErr: errors.New("dismiss")}
|
|
adapter := NewFindingsManagerMCPAdapter(manager)
|
|
if err := adapter.ResolveFinding("f1", "note"); err == nil {
|
|
t.Fatal("expected resolve error")
|
|
}
|
|
if err := adapter.DismissFinding("f1", "reason", "note"); err == nil {
|
|
t.Fatal("expected dismiss error")
|
|
}
|
|
if len(manager.resolveArgs) != 2 || len(manager.dismissArgs) != 3 {
|
|
t.Fatal("expected args to be captured")
|
|
}
|
|
|
|
adapter = &FindingsManagerMCPAdapter{}
|
|
if err := adapter.ResolveFinding("f1", "note"); err == nil {
|
|
t.Fatal("expected error when manager missing")
|
|
}
|
|
|
|
updater := &fakeMetadataUpdater{err: errors.New("update")}
|
|
meta := NewMetadataUpdaterMCPAdapter(updater)
|
|
if err := meta.SetResourceURL("vm", "1", "http://x"); err == nil {
|
|
t.Fatal("expected update error")
|
|
}
|
|
if len(updater.resourceArgs) != 3 {
|
|
t.Fatal("expected resource args captured")
|
|
}
|
|
meta = &MetadataUpdaterMCPAdapter{}
|
|
if err := meta.SetResourceURL("vm", "1", "http://x"); err == nil {
|
|
t.Fatal("expected error when metadata updater missing")
|
|
}
|
|
}
|
|
|
|
func TestUpdatesMCPAdapter(t *testing.T) {
|
|
if NewUpdatesMCPAdapter(nil, nil) != nil {
|
|
t.Fatal("expected nil updates adapter for nil monitor")
|
|
}
|
|
|
|
now := time.Now()
|
|
state := models.StateSnapshot{
|
|
DockerHosts: []models.DockerHost{
|
|
{
|
|
ID: "host1",
|
|
Hostname: "h1",
|
|
DisplayName: "Host 1",
|
|
Containers: []models.DockerContainer{
|
|
{
|
|
ID: "c1",
|
|
Name: "/nginx",
|
|
UpdateStatus: &models.DockerContainerUpdateStatus{
|
|
UpdateAvailable: true,
|
|
CurrentDigest: "old",
|
|
LatestDigest: "new",
|
|
LastChecked: now,
|
|
},
|
|
},
|
|
},
|
|
},
|
|
{
|
|
ID: "host2",
|
|
Hostname: "h2",
|
|
DisplayName: "Host 2",
|
|
Containers: []models.DockerContainer{
|
|
{
|
|
ID: "c2",
|
|
Name: "redis",
|
|
UpdateStatus: &models.DockerContainerUpdateStatus{
|
|
Error: "rate limited",
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
}
|
|
|
|
monitor := &fakeUpdatesMonitor{state: state}
|
|
adapter := NewUpdatesMCPAdapter(monitor, &fakeUpdatesConfig{enabled: false})
|
|
|
|
updates := adapter.GetPendingUpdates("host1")
|
|
if len(updates) != 1 || updates[0].ContainerName != "nginx" {
|
|
t.Fatalf("unexpected updates: %+v", updates)
|
|
}
|
|
|
|
if adapter.IsUpdateActionsEnabled() {
|
|
t.Fatal("expected updates disabled")
|
|
}
|
|
if (&UpdatesMCPAdapter{}).IsUpdateActionsEnabled() != true {
|
|
t.Fatal("expected updates enabled by default")
|
|
}
|
|
|
|
monitor.checkErr = errors.New("check")
|
|
if _, err := adapter.TriggerUpdateCheck("host1"); err == nil {
|
|
t.Fatal("expected check error")
|
|
}
|
|
|
|
monitor.checkErr = nil
|
|
monitor.checkStatus = models.DockerHostCommandStatus{ID: "cmd1", Type: "check", Status: "queued"}
|
|
status, err := adapter.TriggerUpdateCheck("host1")
|
|
if err != nil || status.ID != "cmd1" {
|
|
t.Fatalf("unexpected status: %+v err=%v", status, err)
|
|
}
|
|
|
|
monitor.updateErr = errors.New("update")
|
|
if _, err := adapter.UpdateContainer("host1", "c1", "nginx"); err == nil {
|
|
t.Fatal("expected update error")
|
|
}
|
|
|
|
monitor.updateErr = nil
|
|
monitor.updateStatus = models.DockerHostCommandStatus{ID: "cmd2", Type: "update", Status: "queued"}
|
|
status, err = adapter.UpdateContainer("host1", "c1", "nginx")
|
|
if err != nil || status.ID != "cmd2" {
|
|
t.Fatalf("unexpected update status: %+v err=%v", status, err)
|
|
}
|
|
|
|
if trimContainerName("/redis") != "redis" || trimContainerName("plain") != "plain" {
|
|
t.Fatal("unexpected trim result")
|
|
}
|
|
}
|