mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-04-28 11:30:15 +00:00
- Add persistent volume mounts for Go/npm caches (faster rebuilds) - Add shell config with helpful aliases and custom prompt - Add comprehensive devcontainer documentation - Add pre-commit hooks for Go formatting and linting - Use go-version-file in CI workflows instead of hardcoded versions - Simplify docker compose commands with --wait flag - Add gitignore entries for devcontainer auth files 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
290 lines
7.4 KiB
Go
290 lines
7.4 KiB
Go
package dockeragent
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"net/http"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/rs/zerolog"
|
|
)
|
|
|
|
func TestRegistryChecker_CheckImageUpdate_Behavior(t *testing.T) {
|
|
logger := zerolog.Nop()
|
|
|
|
t.Run("disabled checker returns nil", func(t *testing.T) {
|
|
checker := NewRegistryChecker(logger)
|
|
checker.SetEnabled(false)
|
|
result := checker.CheckImageUpdate(context.Background(), "nginx:latest", "sha256:current", "", "", "")
|
|
if result != nil {
|
|
t.Error("Expected nil result when checker is disabled")
|
|
}
|
|
})
|
|
|
|
t.Run("digest-pinned image skipped", func(t *testing.T) {
|
|
checker := NewRegistryChecker(logger)
|
|
result := checker.CheckImageUpdate(context.Background(), "nginx@sha256:abc123", "sha256:abc123", "", "", "")
|
|
if result == nil {
|
|
t.Fatal("Expected result for digest-pinned image")
|
|
}
|
|
if result.UpdateAvailable {
|
|
t.Error("Expected no update available for digest-pinned image")
|
|
}
|
|
if result.Error != "digest-pinned image" {
|
|
t.Errorf("Expected error 'digest-pinned image', got %q", result.Error)
|
|
}
|
|
})
|
|
|
|
t.Run("empty image name", func(t *testing.T) {
|
|
checker := NewRegistryChecker(logger)
|
|
checker.httpClient = &http.Client{
|
|
Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
|
|
switch req.URL.Host {
|
|
case "auth.docker.io":
|
|
return newStringResponse(http.StatusOK, nil, `{"token":"token"}`), nil
|
|
case "registry-1.docker.io":
|
|
return newStringResponse(http.StatusNotFound, nil, ""), nil
|
|
default:
|
|
return nil, fmt.Errorf("unexpected host %s", req.URL.Host)
|
|
}
|
|
}),
|
|
}
|
|
|
|
result := checker.CheckImageUpdate(context.Background(), "", "sha256:current", "", "", "")
|
|
if result == nil {
|
|
t.Fatal("Expected result for empty image")
|
|
}
|
|
if result.Error == "" {
|
|
t.Fatal("Expected error for empty image")
|
|
}
|
|
})
|
|
}
|
|
|
|
func TestRegistryChecker_Caching(t *testing.T) {
|
|
logger := zerolog.Nop()
|
|
checker := NewRegistryChecker(logger)
|
|
|
|
// Test caching behavior
|
|
cacheKey := "test-key"
|
|
|
|
t.Run("cache miss returns nil", func(t *testing.T) {
|
|
entry := checker.getCached(cacheKey)
|
|
if entry != nil {
|
|
t.Error("Expected nil for cache miss")
|
|
}
|
|
})
|
|
|
|
t.Run("cache hit for digest", func(t *testing.T) {
|
|
checker.cacheDigest(cacheKey, "sha256:testdigest")
|
|
entry := checker.getCached(cacheKey)
|
|
if entry == nil {
|
|
t.Fatal("Expected cache hit")
|
|
}
|
|
if entry.latestDigest != "sha256:testdigest" {
|
|
t.Errorf("Expected digest 'sha256:testdigest', got %q", entry.latestDigest)
|
|
}
|
|
})
|
|
|
|
t.Run("cache hit for error", func(t *testing.T) {
|
|
errorKey := "error-key"
|
|
checker.cacheError(errorKey, "test error")
|
|
entry := checker.getCached(errorKey)
|
|
if entry == nil {
|
|
t.Fatal("Expected cache hit for error")
|
|
}
|
|
if entry.err != "test error" {
|
|
t.Errorf("Expected error 'test error', got %q", entry.err)
|
|
}
|
|
})
|
|
|
|
t.Run("cleanup removes expired entries", func(t *testing.T) {
|
|
// Add an expired entry manually
|
|
checker.cache.mu.Lock()
|
|
checker.cache.entries["expired-key"] = cacheEntry{
|
|
latestDigest: "sha256:old",
|
|
expiresAt: time.Now().Add(-1 * time.Hour), // Already expired
|
|
}
|
|
checker.cache.mu.Unlock()
|
|
|
|
checker.CleanupCache()
|
|
|
|
entry := checker.getCached("expired-key")
|
|
if entry != nil {
|
|
t.Error("Expected expired entry to be removed")
|
|
}
|
|
})
|
|
}
|
|
|
|
func TestRegistryChecker_ShouldCheck(t *testing.T) {
|
|
logger := zerolog.Nop()
|
|
checker := NewRegistryChecker(logger)
|
|
|
|
t.Run("should check when never checked", func(t *testing.T) {
|
|
if !checker.ShouldCheck() {
|
|
t.Error("Expected ShouldCheck to return true when never checked")
|
|
}
|
|
})
|
|
|
|
t.Run("should not check immediately after mark", func(t *testing.T) {
|
|
checker.MarkChecked()
|
|
if checker.ShouldCheck() {
|
|
t.Error("Expected ShouldCheck to return false immediately after MarkChecked")
|
|
}
|
|
})
|
|
|
|
t.Run("should not check when disabled", func(t *testing.T) {
|
|
checker.SetEnabled(false)
|
|
if checker.ShouldCheck() {
|
|
t.Error("Expected ShouldCheck to return false when disabled")
|
|
}
|
|
checker.SetEnabled(true)
|
|
})
|
|
}
|
|
|
|
func TestParseImageReference_EdgeCases(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
image string
|
|
wantReg string
|
|
wantRepo string
|
|
wantTag string
|
|
}{
|
|
{
|
|
name: "quay.io image",
|
|
image: "quay.io/prometheus/prometheus:v2.45.0",
|
|
wantReg: "quay.io",
|
|
wantRepo: "prometheus/prometheus",
|
|
wantTag: "v2.45.0",
|
|
},
|
|
{
|
|
name: "ecr image",
|
|
image: "123456789.dkr.ecr.us-east-1.amazonaws.com/myapp:prod",
|
|
wantReg: "123456789.dkr.ecr.us-east-1.amazonaws.com",
|
|
wantRepo: "myapp",
|
|
wantTag: "prod",
|
|
},
|
|
{
|
|
name: "gcr.io image",
|
|
image: "gcr.io/google-containers/pause:3.2",
|
|
wantReg: "gcr.io",
|
|
wantRepo: "google-containers/pause",
|
|
wantTag: "3.2",
|
|
},
|
|
{
|
|
name: "multi-level path",
|
|
image: "registry.example.com/org/team/project/app:v1",
|
|
wantReg: "registry.example.com",
|
|
wantRepo: "org/team/project/app",
|
|
wantTag: "v1",
|
|
},
|
|
{
|
|
name: "image with sha256 in name (not pinned)",
|
|
image: "myimage-sha256:latest",
|
|
wantReg: "registry-1.docker.io",
|
|
wantRepo: "library/myimage-sha256",
|
|
wantTag: "latest",
|
|
},
|
|
{
|
|
name: "mcr.io image",
|
|
image: "mcr.microsoft.com/dotnet/sdk:7.0",
|
|
wantReg: "mcr.microsoft.com",
|
|
wantRepo: "dotnet/sdk",
|
|
wantTag: "7.0",
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
gotReg, gotRepo, gotTag := parseImageReference(tt.image)
|
|
if gotReg != tt.wantReg {
|
|
t.Errorf("registry = %q, want %q", gotReg, tt.wantReg)
|
|
}
|
|
if gotRepo != tt.wantRepo {
|
|
t.Errorf("repository = %q, want %q", gotRepo, tt.wantRepo)
|
|
}
|
|
if gotTag != tt.wantTag {
|
|
t.Errorf("tag = %q, want %q", gotTag, tt.wantTag)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestImageUpdateResult_Fields(t *testing.T) {
|
|
result := ImageUpdateResult{
|
|
Image: "nginx:latest",
|
|
CurrentDigest: "sha256:current",
|
|
LatestDigest: "sha256:latest",
|
|
UpdateAvailable: true,
|
|
CheckedAt: time.Now(),
|
|
Error: "",
|
|
}
|
|
|
|
if !result.UpdateAvailable {
|
|
t.Error("Expected UpdateAvailable to be true")
|
|
}
|
|
|
|
if result.Image != "nginx:latest" {
|
|
t.Errorf("Expected image 'nginx:latest', got %q", result.Image)
|
|
}
|
|
}
|
|
|
|
func BenchmarkParseImageReference(b *testing.B) {
|
|
images := []string{
|
|
"nginx",
|
|
"nginx:latest",
|
|
"myrepo/myapp:v1",
|
|
"ghcr.io/owner/repo:tag",
|
|
"registry.example.com:5000/app:v2",
|
|
"nginx@sha256:abc123def456",
|
|
}
|
|
|
|
for i := 0; i < b.N; i++ {
|
|
for _, img := range images {
|
|
parseImageReference(img)
|
|
}
|
|
}
|
|
}
|
|
|
|
func BenchmarkDigestsDiffer(b *testing.B) {
|
|
checker := &RegistryChecker{}
|
|
current := "sha256:a1b2c3d4e5f6789012345678901234567890123456789012345678901234abcd"
|
|
latest := "sha256:f6e5d4c3b2a1987654321098765432109876543210987654321098765432fedc"
|
|
|
|
for i := 0; i < b.N; i++ {
|
|
checker.digestsDiffer(current, latest)
|
|
}
|
|
}
|
|
|
|
// TestConcurrentCacheAccess verifies thread-safety of the cache
|
|
func TestConcurrentCacheAccess(t *testing.T) {
|
|
logger := zerolog.Nop()
|
|
checker := NewRegistryChecker(logger)
|
|
|
|
// Spawn multiple goroutines accessing the cache
|
|
done := make(chan bool)
|
|
for i := 0; i < 10; i++ {
|
|
go func(id int) {
|
|
for j := 0; j < 100; j++ {
|
|
key := fmt.Sprintf("key-%d-%d", id, j)
|
|
checker.cacheDigest(key, fmt.Sprintf("digest-%d", j))
|
|
checker.getCached(key)
|
|
}
|
|
done <- true
|
|
}(i)
|
|
}
|
|
|
|
// Also run cleanup concurrently
|
|
go func() {
|
|
for i := 0; i < 50; i++ {
|
|
checker.CleanupCache()
|
|
time.Sleep(time.Millisecond)
|
|
}
|
|
done <- true
|
|
}()
|
|
|
|
// Wait for all goroutines
|
|
for i := 0; i < 11; i++ {
|
|
<-done
|
|
}
|
|
}
|