mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-09-10 02:25:56 +00:00
fix: Docker container update detection showing false positives
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
This commit is contained in:
@@ -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())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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])
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user