mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-04-28 11:30:15 +00:00
Fixed an issue where all Docker containers were showing 'click to update' even when they were up to date. The root cause was comparing the wrong digest types: - Previously: Compared ImageID (local config hash) vs registry manifest digest - Now: Uses RepoDigests from image inspect, which is the actual manifest digest that Docker received from the registry when pulling the image For multi-arch images, the registry returns a manifest list digest, while Docker stores the platform-specific image config digest locally. These will never match, causing false positives for all multi-arch images. Changes: - Added ImageInspectWithRaw to dockerClient interface - Added getImageRepoDigest method to extract RepoDigest from image - Added matchesImageReference helper for Docker Hub naming conventions - Added tests for matchesImageReference Fixes #955
386 lines
9.5 KiB
Go
386 lines
9.5 KiB
Go
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)
|
|
}
|
|
}
|
|
}
|