diff --git a/internal/ai/alert_threshold_adapter_test.go b/internal/ai/alert_threshold_adapter_test.go index a42695191..99a40bd80 100644 --- a/internal/ai/alert_threshold_adapter_test.go +++ b/internal/ai/alert_threshold_adapter_test.go @@ -53,3 +53,32 @@ func TestAlertThresholdAdapter_UsesAlertManagerConfig(t *testing.T) { } } +func TestAlertThresholdAdapter_Fallbacks(t *testing.T) { + mgr := alerts.NewManager() + cfg := mgr.GetConfig() + + cfg.NodeDefaults.CPU = nil + cfg.NodeDefaults.Memory = &alerts.HysteresisThreshold{Trigger: 0, Clear: 0} + cfg.GuestDefaults.Memory = nil + cfg.GuestDefaults.Disk = &alerts.HysteresisThreshold{Trigger: 0, Clear: 0} + cfg.StorageDefault.Trigger = 0 + + mgr.UpdateConfig(cfg) + + a := NewAlertThresholdAdapter(mgr) + 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()) + } +} diff --git a/internal/ai/baseline_adapter_test.go b/internal/ai/baseline_adapter_test.go index 87f8541e9..16870e734 100644 --- a/internal/ai/baseline_adapter_test.go +++ b/internal/ai/baseline_adapter_test.go @@ -54,3 +54,24 @@ func TestBaselineStoreAdapter_NilStore(t *testing.T) { } } +func TestBaselineStoreAdapter_NilFactory(t *testing.T) { + if NewBaselineStoreAdapter(nil) != nil { + t.Fatalf("expected nil adapter for nil store") + } +} + +func TestBaselineStoreAdapter_MissingBaseline(t *testing.T) { + store := baseline.NewStore(baseline.StoreConfig{MinSamples: 1}) + adapter := NewBaselineStoreAdapter(store) + if adapter == nil { + t.Fatalf("expected adapter") + } + + if _, _, _, ok := adapter.GetBaseline("missing", "cpu"); ok { + t.Fatalf("expected ok=false for missing baseline") + } + + if _, _, _, _, ok := adapter.CheckAnomaly("missing", "cpu", 42); ok { + t.Fatalf("expected ok=false for missing anomaly data") + } +} diff --git a/internal/ai/cost_persistence_test.go b/internal/ai/cost_persistence_test.go index fad175f22..075980c3a 100644 --- a/internal/ai/cost_persistence_test.go +++ b/internal/ai/cost_persistence_test.go @@ -1,6 +1,8 @@ package ai import ( + "os" + "path/filepath" "testing" "time" @@ -107,3 +109,18 @@ func TestCostPersistenceAdapter_SaveEmpty(t *testing.T) { t.Fatalf("failed to save empty usage history: %v", err) } } + +func TestCostPersistenceAdapter_LoadError(t *testing.T) { + tmp := t.TempDir() + persistence := config.NewConfigPersistence(tmp) + adapter := NewCostPersistenceAdapter(persistence) + + badPath := filepath.Join(tmp, "ai_usage_history.json") + if err := os.Mkdir(badPath, 0700); err != nil { + t.Fatalf("failed to create directory at %s: %v", badPath, err) + } + + if _, err := adapter.LoadUsageHistory(); err == nil { + t.Fatal("expected error when usage history path is a directory") + } +} diff --git a/internal/ai/findings_coverage_test.go b/internal/ai/findings_coverage_test.go new file mode 100644 index 000000000..e0bb6bbcd --- /dev/null +++ b/internal/ai/findings_coverage_test.go @@ -0,0 +1,390 @@ +package ai + +import ( + "errors" + "strings" + "testing" + "time" +) + +type recordingPersistence struct { + findings map[string]*Finding + loadErr error + saveErr error + saved chan map[string]*Finding +} + +func (p *recordingPersistence) SaveFindings(findings map[string]*Finding) error { + if p.saved != nil { + p.saved <- findings + } + return p.saveErr +} + +func (p *recordingPersistence) LoadFindings() (map[string]*Finding, error) { + if p.loadErr != nil { + return nil, p.loadErr + } + if p.findings == nil { + return make(map[string]*Finding), nil + } + return p.findings, nil +} + +func TestFindingsStore_SetPersistence_LoadError(t *testing.T) { + store := NewFindingsStore() + loadErr := errors.New("load error") + + err := store.SetPersistence(&recordingPersistence{loadErr: loadErr}) + if !errors.Is(err, loadErr) { + t.Fatalf("expected load error, got %v", err) + } +} + +func TestFindingsStore_SetPersistence_LoadsFindings(t *testing.T) { + store := NewFindingsStore() + now := time.Now() + resolvedAt := now.Add(-time.Hour) + + p := &recordingPersistence{ + findings: map[string]*Finding{ + "active": { + ID: "active", + Severity: FindingSeverityWarning, + ResourceID: "res-1", + Title: "Active", + LastSeenAt: now, + }, + "resolved": { + ID: "resolved", + Severity: FindingSeverityCritical, + ResourceID: "res-2", + Title: "Resolved", + LastSeenAt: now, + ResolvedAt: &resolvedAt, + }, + }, + } + + if err := store.SetPersistence(p); err != nil { + t.Fatalf("SetPersistence failed: %v", err) + } + + if store.activeCounts[FindingSeverityWarning] != 1 { + t.Errorf("expected active warning count 1, got %d", store.activeCounts[FindingSeverityWarning]) + } + if len(store.byResource["res-1"]) != 1 { + t.Errorf("expected resource index for res-1 to have 1 entry, got %d", len(store.byResource["res-1"])) + } +} + +func TestFindingsStore_scheduleSave_NoPersistence(t *testing.T) { + store := NewFindingsStore() + + store.scheduleSave() + + if store.saveTimer != nil { + t.Error("expected no save timer when persistence is nil") + } +} + +func TestFindingsStore_scheduleSave_SavePending(t *testing.T) { + store := NewFindingsStore() + store.persistence = &recordingPersistence{saved: make(chan map[string]*Finding, 1)} + store.savePending = true + + store.scheduleSave() + + select { + case <-store.persistence.(*recordingPersistence).saved: + t.Fatal("save should not run when already pending") + case <-time.After(20 * time.Millisecond): + } +} + +func TestFindingsStore_scheduleSave_SavesAndSkipsDemo(t *testing.T) { + store := NewFindingsStore() + store.saveDebounce = 5 * time.Millisecond + saved := make(chan map[string]*Finding, 1) + store.persistence = &recordingPersistence{saved: saved} + + store.findings["demo-1"] = &Finding{ID: "demo-1", Title: "Demo"} + store.findings["real-1"] = &Finding{ID: "real-1", Title: "Real"} + + store.scheduleSave() + + select { + case data := <-saved: + if _, exists := data["demo-1"]; exists { + t.Error("demo findings should not be persisted") + } + if _, exists := data["real-1"]; !exists { + t.Error("expected real finding to be persisted") + } + case <-time.After(200 * time.Millisecond): + t.Fatal("timed out waiting for SaveFindings") + } +} + +func TestFindingsStore_scheduleSave_SaveError(t *testing.T) { + store := NewFindingsStore() + store.saveDebounce = 5 * time.Millisecond + saved := make(chan map[string]*Finding, 1) + store.persistence = &recordingPersistence{ + saveErr: errors.New("save error"), + saved: saved, + } + + store.findings["real-1"] = &Finding{ID: "real-1", Title: "Real"} + store.scheduleSave() + + select { + case <-saved: + case <-time.After(200 * time.Millisecond): + t.Fatal("timed out waiting for SaveFindings") + } +} + +func TestFindingsStore_ForceSave_NoPersistenceStopsTimer(t *testing.T) { + store := NewFindingsStore() + store.saveTimer = time.AfterFunc(time.Hour, func() {}) + + if err := store.ForceSave(); err != nil { + t.Fatalf("ForceSave failed: %v", err) + } +} + +func TestFindingsStore_ForceSave_SaveError(t *testing.T) { + store := NewFindingsStore() + store.persistence = &recordingPersistence{saveErr: errors.New("save error")} + store.findings["real-1"] = &Finding{ID: "real-1", Title: "Real"} + + if err := store.ForceSave(); err == nil { + t.Fatal("expected ForceSave to return error") + } +} + +func TestFindingsStore_ForceSave_SkipsDemo(t *testing.T) { + store := NewFindingsStore() + saved := make(chan map[string]*Finding, 1) + store.persistence = &recordingPersistence{saved: saved} + store.findings["demo-1"] = &Finding{ID: "demo-1", Title: "Demo"} + store.findings["real-1"] = &Finding{ID: "real-1", Title: "Real"} + + if err := store.ForceSave(); err != nil { + t.Fatalf("ForceSave failed: %v", err) + } + + select { + case data := <-saved: + if _, exists := data["demo-1"]; exists { + t.Error("demo findings should not be persisted") + } + if _, exists := data["real-1"]; !exists { + t.Error("expected real finding to be persisted") + } + case <-time.After(200 * time.Millisecond): + t.Fatal("timed out waiting for SaveFindings") + } +} + +func TestFindingsStore_ClearAll(t *testing.T) { + store := NewFindingsStore() + store.Add(&Finding{ID: "f1", Severity: FindingSeverityWarning, ResourceID: "res-1", Title: "Test"}) + store.Add(&Finding{ID: "f2", Severity: FindingSeverityCritical, ResourceID: "res-2", Title: "Test"}) + + count := store.ClearAll() + + if count != 2 { + t.Errorf("expected 2 findings cleared, got %d", count) + } + if len(store.findings) != 0 { + t.Error("expected findings map to be empty") + } + if len(store.byResource) != 0 { + t.Error("expected resource index to be empty") + } + if len(store.activeCounts) != 0 { + t.Error("expected active counts to be reset") + } +} + +func TestFindingsStore_Undismiss(t *testing.T) { + t.Run("missing", func(t *testing.T) { + store := NewFindingsStore() + if store.Undismiss("missing") { + t.Error("expected false for missing finding") + } + }) + + t.Run("not dismissed", func(t *testing.T) { + store := NewFindingsStore() + store.findings["f1"] = &Finding{ID: "f1", Severity: FindingSeverityWarning} + if store.Undismiss("f1") { + t.Error("expected false for non-dismissed finding") + } + }) + + t.Run("resolved stays inactive", func(t *testing.T) { + store := NewFindingsStore() + resolvedAt := time.Now() + store.findings["f1"] = &Finding{ + ID: "f1", + Severity: FindingSeverityWarning, + DismissedReason: "expected_behavior", + ResolvedAt: &resolvedAt, + } + + if !store.Undismiss("f1") { + t.Fatal("expected Undismiss to succeed") + } + if store.activeCounts[FindingSeverityWarning] != 0 { + t.Errorf("expected active count to remain 0, got %d", store.activeCounts[FindingSeverityWarning]) + } + }) + + t.Run("reactivates", func(t *testing.T) { + store := NewFindingsStore() + store.findings["f1"] = &Finding{ + ID: "f1", + Severity: FindingSeverityWarning, + DismissedReason: "expected_behavior", + } + store.activeCounts[FindingSeverityWarning] = 0 + + if !store.Undismiss("f1") { + t.Fatal("expected Undismiss to succeed") + } + if store.activeCounts[FindingSeverityWarning] != 1 { + t.Errorf("expected active count to increment to 1, got %d", store.activeCounts[FindingSeverityWarning]) + } + }) +} + +func TestFindingsStore_GetDismissedForContext_SnoozedAndOld(t *testing.T) { + store := NewFindingsStore() + now := time.Now() + + store.findings["old"] = &Finding{ + ID: "old", + Title: "Old Finding", + ResourceName: "old", + DismissedReason: "not_an_issue", + LastSeenAt: now.Add(-31 * 24 * time.Hour), + } + store.findings["snoozed"] = &Finding{ + ID: "snoozed", + Title: "Snoozed Finding", + ResourceName: "host-1", + SnoozedUntil: timePtr(now.Add(2 * time.Hour)), + LastSeenAt: now, + } + + ctx := store.GetDismissedForContext() + if strings.Contains(ctx, "Old Finding") { + t.Error("old findings should be excluded from context") + } + if !strings.Contains(ctx, "Temporarily Snoozed") { + t.Error("expected snoozed section in context") + } + if !strings.Contains(ctx, "Snoozed Finding") { + t.Error("expected snoozed finding in context") + } +} + +func TestFindingsStore_GetDismissedForContext_SuppressedNoteAndDismissed(t *testing.T) { + store := NewFindingsStore() + now := time.Now() + + store.findings["suppressed"] = &Finding{ + ID: "suppressed", + Title: "Suppressed Finding", + ResourceName: "host-1", + Suppressed: true, + DismissedReason: "not_an_issue", + UserNote: "ignore this", + LastSeenAt: now, + } + store.findings["dismissed"] = &Finding{ + ID: "dismissed", + Title: "Dismissed Finding", + ResourceName: "host-2", + DismissedReason: "expected_behavior", + LastSeenAt: now, + } + store.findings["dismissed-note"] = &Finding{ + ID: "dismissed-note", + Title: "Dismissed With Note", + ResourceName: "host-3", + DismissedReason: "will_fix_later", + UserNote: "accepted risk", + LastSeenAt: now, + } + + ctx := store.GetDismissedForContext() + if !strings.Contains(ctx, "User note: ignore this") { + t.Error("expected suppressed user note in context") + } + if !strings.Contains(ctx, "Dismissed Finding") { + t.Error("expected dismissed finding in context") + } + if !strings.Contains(ctx, "User note: accepted risk") { + t.Error("expected dismissed user note in context") + } +} + +func TestFindingsStore_GetSuppressionRules_DismissedRule(t *testing.T) { + store := NewFindingsStore() + now := time.Now() + + store.findings["f1"] = &Finding{ + ID: "f1", + ResourceID: "res-1", + ResourceName: "Res 1", + Category: FindingCategoryCapacity, + DismissedReason: "expected_behavior", + LastSeenAt: now, + } + + rules := store.GetSuppressionRules() + if len(rules) != 1 { + t.Fatalf("expected 1 rule, got %d", len(rules)) + } + rule := rules[0] + if rule.CreatedFrom != "dismissed" { + t.Errorf("expected created_from dismissed, got %s", rule.CreatedFrom) + } + if !rule.CreatedAt.Equal(now) { + t.Error("expected CreatedAt to use LastSeenAt when AcknowledgedAt is nil") + } +} + +func TestFindingsStore_DeleteSuppressionRule_Explicit(t *testing.T) { + store := NewFindingsStore() + rule := store.AddSuppressionRule("res-1", "Res 1", FindingCategoryCapacity, "Manual") + + if !store.DeleteSuppressionRule(rule.ID) { + t.Fatal("expected DeleteSuppressionRule to succeed") + } + if store.DeleteSuppressionRule(rule.ID) { + t.Error("expected DeleteSuppressionRule to return false after removal") + } +} + +func TestFindingsStore_DeleteSuppressionRule_ReactivatesFinding(t *testing.T) { + store := NewFindingsStore() + store.findings["f1"] = &Finding{ + ID: "f1", + Severity: FindingSeverityWarning, + DismissedReason: "not_an_issue", + Suppressed: true, + } + store.activeCounts[FindingSeverityWarning] = 0 + + if !store.DeleteSuppressionRule("finding_f1") { + t.Fatal("expected DeleteSuppressionRule to succeed") + } + if store.activeCounts[FindingSeverityWarning] != 1 { + t.Errorf("expected active count to increment, got %d", store.activeCounts[FindingSeverityWarning]) + } +} diff --git a/internal/ai/metadata_provider_test.go b/internal/ai/metadata_provider_test.go new file mode 100644 index 000000000..a152bbe76 --- /dev/null +++ b/internal/ai/metadata_provider_test.go @@ -0,0 +1,77 @@ +package ai + +import ( + "errors" + "strings" + "testing" +) + +type failingMetadataProvider struct { + guestErr error + dockerErr error + hostErr error +} + +func (m *failingMetadataProvider) SetGuestURL(guestID, customURL string) error { + return m.guestErr +} + +func (m *failingMetadataProvider) SetDockerURL(resourceID, customURL string) error { + return m.dockerErr +} + +func (m *failingMetadataProvider) SetHostURL(hostID, customURL string) error { + return m.hostErr +} + +func TestService_SetMetadataProvider(t *testing.T) { + svc := &Service{} + mp := &mockMetadataProvider{} + + svc.SetMetadataProvider(mp) + + if svc.metadataProvider != mp { + t.Fatal("expected metadata provider to be set") + } +} + +func TestService_SetResourceURL_InvalidScheme(t *testing.T) { + svc := &Service{metadataProvider: &mockMetadataProvider{}} + + err := svc.SetResourceURL("guest", "id-1", "ftp://example.com") + if err == nil || !strings.Contains(err.Error(), "URL must use http:// or https:// scheme") { + t.Fatalf("expected scheme error, got %v", err) + } +} + +func TestService_SetResourceURL_MissingHost(t *testing.T) { + svc := &Service{metadataProvider: &mockMetadataProvider{}} + + err := svc.SetResourceURL("guest", "id-1", "http://") + if err == nil || !strings.Contains(err.Error(), "URL must include a host") { + t.Fatalf("expected host error, got %v", err) + } +} + +func TestService_SetResourceURL_ProviderErrors(t *testing.T) { + svc := &Service{metadataProvider: &failingMetadataProvider{ + guestErr: errors.New("guest error"), + dockerErr: errors.New("docker error"), + hostErr: errors.New("host error"), + }} + + err := svc.SetResourceURL("guest", "id-1", "https://example.com") + if err == nil || !strings.Contains(err.Error(), "failed to set guest URL") { + t.Fatalf("expected wrapped guest error, got %v", err) + } + + err = svc.SetResourceURL("docker", "id-2", "https://example.com") + if err == nil || !strings.Contains(err.Error(), "failed to set Docker URL") { + t.Fatalf("expected wrapped docker error, got %v", err) + } + + err = svc.SetResourceURL("host", "id-3", "https://example.com") + if err == nil || !strings.Contains(err.Error(), "failed to set host URL") { + t.Fatalf("expected wrapped host error, got %v", err) + } +} diff --git a/internal/ai/metrics_history_adapter_test.go b/internal/ai/metrics_history_adapter_test.go new file mode 100644 index 000000000..739af7d79 --- /dev/null +++ b/internal/ai/metrics_history_adapter_test.go @@ -0,0 +1,79 @@ +package ai + +import ( + "testing" + "time" + + "github.com/rcourtman/pulse-go-rewrite/internal/monitoring" +) + +func TestNewMetricsHistoryAdapter_Nil(t *testing.T) { + if NewMetricsHistoryAdapter(nil) != nil { + t.Fatal("expected nil adapter when history is nil") + } +} + +func TestMetricsHistoryAdapter_NilHistory(t *testing.T) { + adapter := &MetricsHistoryAdapter{} + + if got := adapter.GetNodeMetrics("node-1", "cpu", time.Hour); got != nil { + t.Fatalf("expected nil node metrics, got %v", got) + } + if got := adapter.GetGuestMetrics("guest-1", "cpu", time.Hour); got != nil { + t.Fatalf("expected nil guest metrics, got %v", got) + } + if got := adapter.GetAllGuestMetrics("guest-1", time.Hour); got != nil { + t.Fatalf("expected nil guest metrics map, got %v", got) + } + if got := adapter.GetAllStorageMetrics("storage-1", time.Hour); got != nil { + t.Fatalf("expected nil storage metrics map, got %v", got) + } +} + +func TestMetricsHistoryAdapter_Conversions(t *testing.T) { + history := monitoring.NewMetricsHistory(10, 24*time.Hour) + adapter := NewMetricsHistoryAdapter(history) + if adapter == nil { + t.Fatal("expected adapter to be created") + } + + now := time.Now() + history.AddNodeMetric("node-1", "cpu", 42.5, now) + history.AddGuestMetric("guest-1", "memory", 73.2, now) + history.AddStorageMetric("storage-1", "usage", 88.1, now) + + nodePoints := adapter.GetNodeMetrics("node-1", "cpu", time.Hour) + if len(nodePoints) != 1 { + t.Fatalf("expected 1 node point, got %d", len(nodePoints)) + } + if nodePoints[0].Value != 42.5 { + t.Fatalf("expected node value 42.5, got %v", nodePoints[0].Value) + } + + guestPoints := adapter.GetGuestMetrics("guest-1", "memory", time.Hour) + if len(guestPoints) != 1 { + t.Fatalf("expected 1 guest point, got %d", len(guestPoints)) + } + if guestPoints[0].Value != 73.2 { + t.Fatalf("expected guest value 73.2, got %v", guestPoints[0].Value) + } + + allGuest := adapter.GetAllGuestMetrics("guest-1", time.Hour) + if len(allGuest["memory"]) != 1 { + t.Fatalf("expected 1 guest memory point, got %d", len(allGuest["memory"])) + } + + allStorage := adapter.GetAllStorageMetrics("storage-1", time.Hour) + if len(allStorage["usage"]) != 1 { + t.Fatalf("expected 1 storage usage point, got %d", len(allStorage["usage"])) + } +} + +func TestMetricsHistoryAdapter_ConvertHelpers(t *testing.T) { + if convertMetricPoints(nil) != nil { + t.Fatal("expected nil when converting nil points") + } + if convertMetricsMap(nil) != nil { + t.Fatal("expected nil when converting nil map") + } +} diff --git a/internal/ai/patterns/detector.go b/internal/ai/patterns/detector.go index ec5027b6b..d57454de3 100644 --- a/internal/ai/patterns/detector.go +++ b/internal/ai/patterns/detector.go @@ -410,9 +410,6 @@ func (d *Detector) loadFromDisk() error { d.events = data.Events d.patterns = make(map[string]*Pattern, len(data.Patterns)) for k, v := range data.Patterns { - if v == nil { - continue - } d.patterns[k] = v } diff --git a/internal/ai/patterns/detector_coverage_test.go b/internal/ai/patterns/detector_coverage_test.go new file mode 100644 index 000000000..3fbe8597f --- /dev/null +++ b/internal/ai/patterns/detector_coverage_test.go @@ -0,0 +1,386 @@ +package patterns + +import ( + "encoding/json" + "math" + "os" + "path/filepath" + "testing" + "time" +) + +func TestNewDetector_LoadFromDiskSuccess(t *testing.T) { + tmpDir := t.TempDir() + now := time.Now() + data := struct { + Events []HistoricalEvent `json:"events"` + Patterns map[string]*Pattern `json:"patterns"` + }{ + Events: []HistoricalEvent{ + { + ID: "event-1", + ResourceID: "vm-1", + EventType: EventHighCPU, + Timestamp: now, + }, + }, + Patterns: map[string]*Pattern{ + "vm-1:high_cpu": { + ResourceID: "vm-1", + EventType: EventHighCPU, + Occurrences: 3, + LastOccurrence: now, + NextPredicted: now.Add(24 * time.Hour), + Confidence: 0.5, + }, + }, + } + blob, err := json.Marshal(data) + if err != nil { + t.Fatalf("marshal data: %v", err) + } + path := filepath.Join(tmpDir, "ai_patterns.json") + if err := os.WriteFile(path, blob, 0600); err != nil { + t.Fatalf("write data: %v", err) + } + + d := NewDetector(DetectorConfig{DataDir: tmpDir}) + if len(d.events) == 0 { + t.Fatal("expected events to be loaded") + } + if len(d.patterns) == 0 { + t.Fatal("expected patterns to be loaded") + } +} + +func TestNewDetector_LoadFromDiskError(t *testing.T) { + tmpDir := t.TempDir() + path := filepath.Join(tmpDir, "ai_patterns.json") + if err := os.WriteFile(path, []byte("{bad"), 0600); err != nil { + t.Fatalf("write invalid json: %v", err) + } + + d := NewDetector(DetectorConfig{DataDir: tmpDir}) + if len(d.events) != 0 { + t.Error("expected no events on load error") + } +} + +func TestRecordEvent_SaveError(t *testing.T) { + tmpFile, err := os.CreateTemp("", "patterns-dir") + if err != nil { + t.Fatalf("create temp file: %v", err) + } + path := tmpFile.Name() + if err := tmpFile.Close(); err != nil { + t.Fatalf("close temp file: %v", err) + } + defer os.Remove(path) + + d := NewDetector(DetectorConfig{DataDir: path, MinOccurrences: 1}) + d.RecordEvent(HistoricalEvent{ + ResourceID: "vm-1", + EventType: EventHighCPU, + Timestamp: time.Now(), + }) + + time.Sleep(20 * time.Millisecond) +} + +func TestGetPredictions_FiltersAndSorts(t *testing.T) { + d := NewDetector(DetectorConfig{MinOccurrences: 2, PredictionLimit: 48 * time.Hour}) + now := time.Now() + d.patterns["nil"] = nil + d.patterns["low-confidence"] = &Pattern{ + ResourceID: "vm-1", + EventType: EventHighCPU, + Occurrences: 2, + Confidence: 0.2, + NextPredicted: now.Add(2 * time.Hour), + } + d.patterns["low-occurrences"] = &Pattern{ + ResourceID: "vm-2", + EventType: EventHighMemory, + Occurrences: 1, + Confidence: 0.9, + NextPredicted: now.Add(2 * time.Hour), + } + d.patterns["past"] = &Pattern{ + ResourceID: "vm-3", + EventType: EventDiskFull, + Occurrences: 2, + Confidence: 0.9, + NextPredicted: now.Add(-2 * time.Hour), + } + d.patterns["future"] = &Pattern{ + ResourceID: "vm-4", + EventType: EventOOM, + Occurrences: 2, + Confidence: 0.9, + NextPredicted: now.Add(72 * time.Hour), + } + d.patterns["soon"] = &Pattern{ + ResourceID: "vm-5", + EventType: EventRestart, + Occurrences: 2, + Confidence: 0.9, + NextPredicted: now.Add(4 * time.Hour), + } + d.patterns["later"] = &Pattern{ + ResourceID: "vm-6", + EventType: EventUnresponsive, + Occurrences: 2, + Confidence: 0.9, + NextPredicted: now.Add(12 * time.Hour), + } + + predictions := d.GetPredictions() + if len(predictions) != 2 { + t.Fatalf("expected 2 predictions, got %d", len(predictions)) + } + if predictions[0].DaysUntil > predictions[1].DaysUntil { + t.Fatalf("expected predictions sorted by days until") + } +} + +func TestGetPatterns_SkipsNil(t *testing.T) { + d := NewDetector(DefaultConfig()) + d.patterns["nil"] = nil + d.patterns["ok"] = &Pattern{ResourceID: "vm-1", EventType: EventHighCPU} + + result := d.GetPatterns() + if len(result) != 1 { + t.Fatalf("expected 1 pattern, got %d", len(result)) + } + if result["ok"] == nil { + t.Fatal("expected non-nil pattern") + } +} + +func TestComputePattern_AverageDuration(t *testing.T) { + d := NewDetector(DetectorConfig{MinOccurrences: 2, PatternWindow: 24 * time.Hour}) + now := time.Now() + d.events = []HistoricalEvent{ + { + ResourceID: "vm-1", + EventType: EventHighCPU, + Timestamp: now.Add(-4 * time.Hour), + Duration: 10 * time.Minute, + }, + { + ResourceID: "vm-1", + EventType: EventHighCPU, + Timestamp: now.Add(-2 * time.Hour), + Duration: 20 * time.Minute, + }, + { + ResourceID: "vm-1", + EventType: EventHighCPU, + Timestamp: now, + }, + } + + pattern := d.computePattern("vm-1", EventHighCPU) + if pattern == nil { + t.Fatal("expected pattern") + } + if pattern.AverageDuration == 0 { + t.Fatal("expected average duration to be set") + } +} + +func TestSaveToDisk_Errors(t *testing.T) { + tmpDir := t.TempDir() + d := NewDetector(DetectorConfig{DataDir: tmpDir}) + d.patterns["bad"] = &Pattern{Confidence: math.NaN()} + if err := d.saveToDisk(); err == nil { + t.Fatal("expected json marshal error") + } + + tmpFile, err := os.CreateTemp("", "patterns-dir") + if err != nil { + t.Fatalf("create temp file: %v", err) + } + path := tmpFile.Name() + if err := tmpFile.Close(); err != nil { + t.Fatalf("close temp file: %v", err) + } + defer os.Remove(path) + + d = NewDetector(DetectorConfig{DataDir: path}) + if err := d.saveToDisk(); err == nil { + t.Fatal("expected write error") + } + + tmpDir = t.TempDir() + path = filepath.Join(tmpDir, "ai_patterns.json") + if err := os.Mkdir(path, 0700); err != nil { + t.Fatalf("create dir: %v", err) + } + d = NewDetector(DetectorConfig{DataDir: tmpDir}) + if err := d.saveToDisk(); err == nil { + t.Fatal("expected rename error") + } +} + +func TestLoadFromDisk_ErrorsAndPrune(t *testing.T) { + d := &Detector{} + if err := d.loadFromDisk(); err != nil { + t.Fatalf("expected empty dataDir to return nil, got %v", err) + } + + tmpDir := t.TempDir() + path := filepath.Join(tmpDir, "ai_patterns.json") + blob := make([]byte, (10<<20)+1) + if err := os.WriteFile(path, blob, 0600); err != nil { + t.Fatalf("write large file: %v", err) + } + d = &Detector{dataDir: tmpDir} + if err := d.loadFromDisk(); err == nil { + t.Fatal("expected size error") + } + + tmpDir = t.TempDir() + path = filepath.Join(tmpDir, "ai_patterns.json") + if err := os.Mkdir(path, 0700); err != nil { + t.Fatalf("create dir: %v", err) + } + d = &Detector{dataDir: tmpDir} + if err := d.loadFromDisk(); err == nil { + t.Fatal("expected read error") + } + + tmpDir = t.TempDir() + path = filepath.Join(tmpDir, "ai_patterns.json") + if err := os.WriteFile(path, []byte("{bad"), 0600); err != nil { + t.Fatalf("write invalid json: %v", err) + } + d = &Detector{dataDir: tmpDir} + if err := d.loadFromDisk(); err == nil { + t.Fatal("expected json error") + } + + tmpDir = t.TempDir() + now := time.Now() + data := struct { + Events []HistoricalEvent `json:"events"` + Patterns map[string]*Pattern `json:"patterns"` + }{ + Events: []HistoricalEvent{ + { + ID: "old", + ResourceID: "vm-1", + EventType: EventHighCPU, + Timestamp: now.Add(-2 * time.Hour), + }, + { + ID: "new", + ResourceID: "vm-1", + EventType: EventHighCPU, + Timestamp: now, + }, + }, + Patterns: map[string]*Pattern{ + "old": { + ResourceID: "vm-1", + EventType: EventHighCPU, + Occurrences: 1, + LastOccurrence: now.Add(-2 * time.Hour), + Confidence: 0.5, + }, + "nil": nil, + "new": { + ResourceID: "vm-1", + EventType: EventHighCPU, + Occurrences: 3, + LastOccurrence: now, + Confidence: 0.5, + }, + }, + } + blob, err := json.Marshal(data) + if err != nil { + t.Fatalf("marshal data: %v", err) + } + path = filepath.Join(tmpDir, "ai_patterns.json") + if err := os.WriteFile(path, blob, 0600); err != nil { + t.Fatalf("write data: %v", err) + } + d = &Detector{ + dataDir: tmpDir, + maxEvents: 10, + minOccurrences: 2, + patternWindow: time.Hour, + } + if err := d.loadFromDisk(); err != nil { + t.Fatalf("loadFromDisk failed: %v", err) + } + if _, ok := d.patterns["old"]; ok { + t.Fatal("expected old pattern to be pruned") + } + if _, ok := d.patterns["nil"]; ok { + t.Fatal("expected nil pattern to be pruned") + } + if _, ok := d.patterns["new"]; !ok { + t.Fatal("expected new pattern to remain") + } + if len(d.events) != 1 { + t.Fatalf("expected trimmed events, got %d", len(d.events)) + } +} + +func TestFormatForContext_Limits(t *testing.T) { + d := NewDetector(DetectorConfig{MinOccurrences: 1, PredictionLimit: 365 * 24 * time.Hour}) + now := time.Now() + for i := 0; i < 50; i++ { + key := "res-" + intToStr(i) + d.patterns[key] = &Pattern{ + ResourceID: key, + EventType: EventHighCPU, + Occurrences: 3, + Confidence: 0.9, + LastOccurrence: now.Add(-24 * time.Hour), + NextPredicted: now.Add(2 * time.Hour), + } + } + + result := d.FormatForContext("") + if result == "" { + t.Fatal("expected non-empty context") + } + if !contains(result, "... and more") { + t.Fatalf("expected truncation marker, got %q", result) + } +} + +func TestFormatPatternBasis_EventNames(t *testing.T) { + now := time.Now() + tests := []struct { + eventType EventType + expected string + overdue bool + }{ + {EventDiskFull, "disk space critical", true}, + {EventUnresponsive, "unresponsive periods", false}, + {EventBackupFailed, "backup failures", false}, + } + + for _, tt := range tests { + next := now.Add(2 * time.Hour) + if tt.overdue { + next = now.Add(-2 * time.Hour) + } + result := formatPatternBasis(&Pattern{ + EventType: tt.eventType, + AverageInterval: 24 * time.Hour, + LastOccurrence: now.Add(-48 * time.Hour), + NextPredicted: next, + }) + if !contains(result, tt.expected) { + t.Fatalf("expected %q in result, got %q", tt.expected, result) + } + if tt.overdue && !contains(result, "overdue") { + t.Fatalf("expected overdue wording, got %q", result) + } + } +} diff --git a/internal/dockeragent/agent.go b/internal/dockeragent/agent.go index 740ed95db..b80d09150 100644 --- a/internal/dockeragent/agent.go +++ b/internal/dockeragent/agent.go @@ -958,10 +958,14 @@ func (a *Agent) collectContainer(ctx context.Context, summary containertypes.Sum // Check for image updates if registry checker is enabled if a.registryChecker != nil && a.registryChecker.Enabled() { - // Use the container's current image digest for comparison - // The ImageDigest from summary.ImageID is the local image ID, which we use - // to compare with the registry's latest manifest digest - result := a.registryChecker.CheckImageUpdate(ctx, container.Image, container.ImageDigest) + // Get the actual manifest digest (RepoDigest) from the image for accurate comparison. + // The ImageID is a local content-addressable ID that differs from the registry manifest digest. + digestForComparison := a.getImageRepoDigest(containerCtx, summary.ImageID, summary.Image) + if digestForComparison == "" { + // Fall back to ImageID if we can't get RepoDigest (shouldn't compare as equal) + digestForComparison = summary.ImageID + } + result := a.registryChecker.CheckImageUpdate(ctx, container.Image, digestForComparison) if result != nil { container.UpdateStatus = &agentsdocker.UpdateStatus{ UpdateAvailable: result.UpdateAvailable, @@ -985,6 +989,84 @@ func (a *Agent) collectContainer(ctx context.Context, summary containertypes.Sum return container, nil } +// getImageRepoDigest retrieves the RepoDigest for an image, which is the actual +// manifest digest from the registry. This is necessary because Docker's ImageID +// is a local content-addressable hash that differs from the registry manifest digest. +// For multi-arch images, the registry returns a manifest list digest, while Docker +// stores the platform-specific image config digest locally. +func (a *Agent) getImageRepoDigest(ctx context.Context, imageID, imageName string) string { + imageInspect, _, err := a.docker.ImageInspectWithRaw(ctx, imageID) + if err != nil { + a.logger.Debug(). + Err(err). + Str("imageID", imageID). + Str("imageName", imageName). + Msg("Failed to inspect image for RepoDigest") + return "" + } + + if len(imageInspect.RepoDigests) == 0 { + // Locally built images won't have RepoDigests + return "" + } + + // Try to find a RepoDigest that matches the image reference + // RepoDigests format: "registry/repo@sha256:..." + for _, repoDigest := range imageInspect.RepoDigests { + // Extract just the digest part (after @) + if idx := strings.LastIndex(repoDigest, "@"); idx >= 0 { + repoRef := repoDigest[:idx] // e.g., "docker.io/library/nginx" + digest := repoDigest[idx+1:] // e.g., "sha256:abc..." + + // Check if this RepoDigest matches our image reference + // Normalize both for comparison + if matchesImageReference(imageName, repoRef) { + return digest + } + } + } + + // If no exact match, return the first RepoDigest's digest + // This handles cases where the image was pulled with a different tag + if idx := strings.LastIndex(imageInspect.RepoDigests[0], "@"); idx >= 0 { + return imageInspect.RepoDigests[0][idx+1:] + } + + return "" +} + +// matchesImageReference checks if a RepoDigest repository matches an image reference. +// It handles Docker Hub's various naming conventions. +func matchesImageReference(imageName, repoRef string) bool { + // Normalize image name by removing tag + if idx := strings.LastIndex(imageName, ":"); idx >= 0 { + // Make sure it's a tag, not a port (check if there's a / after it) + if !strings.Contains(imageName[idx:], "/") { + imageName = imageName[:idx] + } + } + + // Direct match + if imageName == repoRef { + return true + } + + // Docker Hub library images: "nginx" == "docker.io/library/nginx" + if repoRef == "docker.io/library/"+imageName { + return true + } + + // Docker Hub with namespace: "myuser/myapp" == "docker.io/myuser/myapp" + if repoRef == "docker.io/"+imageName { + return true + } + + // Registry prefix matching (e.g., "ghcr.io/user/repo" matches "ghcr.io/user/repo") + // Already handled by direct match above + + return false +} + func extractPodmanMetadata(labels map[string]string) *agentsdocker.PodmanContainer { if len(labels) == 0 { return nil diff --git a/internal/dockeragent/docker_client.go b/internal/dockeragent/docker_client.go index 66da44379..f78a0c626 100644 --- a/internal/dockeragent/docker_client.go +++ b/internal/dockeragent/docker_client.go @@ -28,5 +28,6 @@ type dockerClient interface { ContainerRemove(ctx context.Context, containerID string, options containertypes.RemoveOptions) error ServiceList(ctx context.Context, options swarmtypes.ServiceListOptions) ([]swarmtypes.Service, error) TaskList(ctx context.Context, options swarmtypes.TaskListOptions) ([]swarmtypes.Task, error) + ImageInspectWithRaw(ctx context.Context, imageID string) (image.InspectResponse, []byte, error) Close() error } diff --git a/internal/dockeragent/image_ref_test.go b/internal/dockeragent/image_ref_test.go new file mode 100644 index 000000000..404025cca --- /dev/null +++ b/internal/dockeragent/image_ref_test.go @@ -0,0 +1,94 @@ +package dockeragent + +import "testing" + +func TestMatchesImageReference(t *testing.T) { + tests := []struct { + name string + imageName string + repoRef string + want bool + }{ + { + name: "exact match", + imageName: "nginx", + repoRef: "nginx", + want: true, + }, + { + name: "docker hub library image", + imageName: "nginx", + repoRef: "docker.io/library/nginx", + want: true, + }, + { + name: "docker hub library image with tag", + imageName: "nginx:latest", + repoRef: "docker.io/library/nginx", + want: true, + }, + { + name: "docker hub library image with specific tag", + imageName: "nginx:1.25", + repoRef: "docker.io/library/nginx", + want: true, + }, + { + name: "docker hub with namespace", + imageName: "myuser/myapp", + repoRef: "docker.io/myuser/myapp", + want: true, + }, + { + name: "docker hub with namespace and tag", + imageName: "myuser/myapp:v1.0", + repoRef: "docker.io/myuser/myapp", + want: true, + }, + { + name: "ghcr.io registry", + imageName: "ghcr.io/user/repo", + repoRef: "ghcr.io/user/repo", + want: true, + }, + { + name: "ghcr.io registry with tag", + imageName: "ghcr.io/user/repo:latest", + repoRef: "ghcr.io/user/repo", + want: true, + }, + { + name: "quay.io registry", + imageName: "quay.io/org/image", + repoRef: "quay.io/org/image", + want: true, + }, + { + name: "different images should not match", + imageName: "nginx", + repoRef: "docker.io/library/redis", + want: false, + }, + { + name: "different namespaces should not match", + imageName: "user1/app", + repoRef: "docker.io/user2/app", + want: false, + }, + { + name: "image with port in registry should preserve port", + imageName: "localhost:5000/myimage:tag", + repoRef: "localhost:5000/myimage", + want: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := matchesImageReference(tt.imageName, tt.repoRef) + if got != tt.want { + t.Errorf("matchesImageReference(%q, %q) = %v, want %v", tt.imageName, tt.repoRef, got, tt.want) + } + }) + } +} diff --git a/internal/dockeragent/test_helpers_test.go b/internal/dockeragent/test_helpers_test.go index 6db707cf6..f6723618a 100644 --- a/internal/dockeragent/test_helpers_test.go +++ b/internal/dockeragent/test_helpers_test.go @@ -32,6 +32,7 @@ type fakeDockerClient struct { containerRemoveFn func(ctx context.Context, id string, opts containertypes.RemoveOptions) error serviceListFn func(ctx context.Context, opts swarmtypes.ServiceListOptions) ([]swarmtypes.Service, error) taskListFn func(ctx context.Context, opts swarmtypes.TaskListOptions) ([]swarmtypes.Task, error) + imageInspectWithRawFn func(ctx context.Context, imageID string) (image.InspectResponse, []byte, error) closeFn func() error } @@ -137,6 +138,14 @@ func (f *fakeDockerClient) TaskList(ctx context.Context, opts swarmtypes.TaskLis return f.taskListFn(ctx, opts) } +func (f *fakeDockerClient) ImageInspectWithRaw(ctx context.Context, imageID string) (image.InspectResponse, []byte, error) { + if f.imageInspectWithRawFn == nil { + // Return empty response with no RepoDigests by default (simulates locally built image) + return image.InspectResponse{}, nil, nil + } + return f.imageInspectWithRawFn(ctx, imageID) +} + func (f *fakeDockerClient) Close() error { if f.closeFn == nil { return nil