Files
Benjamin YangandBenjamin Yang 74811c669b Auto-collectors: foundational discovery, image metadata, CLI integrat… (#1845)
* Auto-collectors: foundational discovery, image metadata, CLI integration; reset PRD markers

* Address PR review feedback

- Implement missing namespace exclude patterns functionality
- Fix image facts collector to use empty Data field instead of static string
- Correct APIVersion to use troubleshoot.sh/v1beta2 consistently

* Fix bug bot issues: API parsing, EOF error, and API group corrections

- Fix RBAC API parsing errors in rbac_checker.go (getAPIGroup/getAPIVersion functions)
- Fix FakeReader EOF error to use standard io.EOF instead of custom error
- Fix incorrect API group from troubleshoot.sh to troubleshoot.replicated.com in run.go

These changes address the issues identified by the bug bot and ensure proper
interface compliance and consistent API group usage.

* Fix multiple bug bot issues

- Fix RBAC API parsing errors in rbac_checker.go (getAPIGroup/getAPIVersion functions)
- Fix FakeReader EOF error to use standard io.EOF instead of custom error
- Fix incorrect API group from troubleshoot.sh to troubleshoot.replicated.com in run.go
- Fix image facts collector Data field to contain structured JSON instead of static strings

These changes address all issues identified by the bug bot and ensure proper
interface compliance, consistent API usage, and meaningful data fields.

* Update auto_discovery.go

* Fix TODO comments in Auto-collector section

Fixed 3 of 4 TODOs as requested in PR review:

1. pkg/collect/images/registry_client.go (line 46):
   - Implement custom CA certificate loading
   - Add x509 import and certificate parsing logic
   - Enables image collection from private registries with custom CAs

2. cmd/troubleshoot/cli/diff.go (line 209):
   - Implement bundle file count functionality
   - Add tar/gzip imports and getFileCountFromBundle() function
   - Properly counts files in support bundle archives (.gz/.tgz)

3. cmd/troubleshoot/cli/run.go (line 338):
   - Replace TODO with clarifying comment about RemoteCollectors usage
   - Confirmed RemoteCollectors are still actively used in preflights

The 4th TODO (diff.go line 196) is left as-is since it's explicitly marked
as Phase 4 future work (Support Bundle Differencing implementation).

Addresses PR review feedback about unimplemented TODO comments.

---------

Co-authored-by: Benjamin Yang <benjaminyang@Benjamins-MacBook-Pro.local>
2025-09-12 13:44:26 -06:00

590 lines
12 KiB
Go

package images
import (
"fmt"
"testing"
"time"
)
func TestComputeDigest(t *testing.T) {
tests := []struct {
name string
data []byte
want string
}{
{
name: "hello world",
data: []byte("hello world"),
want: "sha256:b94d27b9934d3e08a52e52d7da7dabfac484efe37a5380ee9088f7ace2efcde9",
},
{
name: "empty data",
data: []byte(""),
want: "sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
},
{
name: "json data",
data: []byte(`{"test": "value"}`),
want: "sha256:71e1ec59dd990e14f06592c6146a79cbce0e1997810dd011923cc72a2ef1d1ae",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := ComputeDigest(tt.data)
if err != nil {
t.Errorf("ComputeDigest() error = %v", err)
return
}
if got != tt.want {
t.Errorf("ComputeDigest() = %v, want %v", got, tt.want)
}
})
}
}
func TestIsValidImageName(t *testing.T) {
tests := []struct {
name string
imageName string
want bool
}{
{
name: "valid simple name",
imageName: "nginx",
want: true,
},
{
name: "valid with namespace",
imageName: "library/nginx",
want: true,
},
{
name: "valid with registry",
imageName: "gcr.io/project/app",
want: true,
},
{
name: "empty name",
imageName: "",
want: false,
},
{
name: "with spaces",
imageName: "nginx with spaces",
want: false,
},
{
name: "with tabs",
imageName: "nginx\twith\ttabs",
want: false,
},
{
name: "starts with slash",
imageName: "/nginx",
want: false,
},
{
name: "ends with slash",
imageName: "nginx/",
want: false,
},
{
name: "double slash",
imageName: "nginx//latest",
want: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := IsValidImageName(tt.imageName)
if got != tt.want {
t.Errorf("IsValidImageName() = %v, want %v", got, tt.want)
}
})
}
}
func TestNormalizeRegistryURL(t *testing.T) {
tests := []struct {
name string
registryURL string
want string
}{
{
name: "add https",
registryURL: "gcr.io",
want: "https://gcr.io",
},
{
name: "remove trailing slash",
registryURL: "https://gcr.io/",
want: "https://gcr.io",
},
{
name: "docker hub special case",
registryURL: "docker.io",
want: "https://registry-1.docker.io",
},
{
name: "already normalized",
registryURL: "https://quay.io",
want: "https://quay.io",
},
{
name: "http preserved",
registryURL: "http://localhost:5000",
want: "http://localhost:5000",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := NormalizeRegistryURL(tt.registryURL)
if got != tt.want {
t.Errorf("NormalizeRegistryURL() = %v, want %v", got, tt.want)
}
})
}
}
func TestGetRegistryType(t *testing.T) {
tests := []struct {
name string
registryURL string
want string
}{
{
name: "docker hub",
registryURL: "https://registry-1.docker.io",
want: "dockerhub",
},
{
name: "google container registry",
registryURL: "https://gcr.io",
want: "gcr",
},
{
name: "aws ecr",
registryURL: "https://123456789.dkr.ecr.us-east-1.amazonaws.com",
want: "ecr",
},
{
name: "quay",
registryURL: "https://quay.io",
want: "quay",
},
{
name: "red hat registry",
registryURL: "https://registry.redhat.io",
want: "redhat",
},
{
name: "harbor",
registryURL: "https://harbor.example.com",
want: "harbor",
},
{
name: "generic registry",
registryURL: "https://my-registry.com",
want: "generic",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := GetRegistryType(tt.registryURL)
if got != tt.want {
t.Errorf("GetRegistryType() = %v, want %v", got, tt.want)
}
})
}
}
func TestValidateCredentials(t *testing.T) {
tests := []struct {
name string
creds RegistryCredentials
wantErr bool
}{
{
name: "empty credentials",
creds: RegistryCredentials{},
wantErr: false, // Valid for public registries
},
{
name: "valid basic auth",
creds: RegistryCredentials{
Username: "user",
Password: "pass",
},
wantErr: false,
},
{
name: "valid token auth",
creds: RegistryCredentials{
Token: "token123",
},
wantErr: false,
},
{
name: "valid identity token",
creds: RegistryCredentials{
IdentityToken: "identity123",
},
wantErr: false,
},
{
name: "username without password",
creds: RegistryCredentials{
Username: "user",
},
wantErr: true,
},
{
name: "password without username",
creds: RegistryCredentials{
Password: "pass",
},
wantErr: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := ValidateCredentials(tt.creds)
if (err != nil) != tt.wantErr {
t.Errorf("ValidateCredentials() error = %v, wantErr %v", err, tt.wantErr)
}
})
}
}
func TestFormatSize(t *testing.T) {
tests := []struct {
name string
bytes int64
want string
}{
{
name: "bytes",
bytes: 512,
want: "512 B",
},
{
name: "kilobytes",
bytes: 1536, // 1.5 KB
want: "1.5 KiB",
},
{
name: "megabytes",
bytes: 1572864, // 1.5 MB
want: "1.5 MiB",
},
{
name: "gigabytes",
bytes: 1610612736, // 1.5 GB
want: "1.5 GiB",
},
{
name: "zero bytes",
bytes: 0,
want: "0 B",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := FormatSize(tt.bytes)
if got != tt.want {
t.Errorf("FormatSize() = %v, want %v", got, tt.want)
}
})
}
}
func TestIsOfficialImage(t *testing.T) {
tests := []struct {
name string
registry string
repository string
want bool
}{
{
name: "official docker hub image",
registry: "docker.io",
repository: "library/nginx",
want: true,
},
{
name: "docker hub user image",
registry: "docker.io",
repository: "user/nginx",
want: false,
},
{
name: "gcr image",
registry: "gcr.io",
repository: "library/nginx",
want: false,
},
{
name: "default registry official",
registry: DefaultRegistry,
repository: "library/redis",
want: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := IsOfficialImage(tt.registry, tt.repository)
if got != tt.want {
t.Errorf("IsOfficialImage() = %v, want %v", got, tt.want)
}
})
}
}
func TestGetImageShortName(t *testing.T) {
tests := []struct {
name string
facts ImageFacts
want string
}{
{
name: "official docker hub image",
facts: ImageFacts{
Registry: "docker.io",
Repository: "library/nginx",
Tag: "1.20",
},
want: "nginx:1.20",
},
{
name: "official image with latest tag",
facts: ImageFacts{
Registry: DefaultRegistry,
Repository: "library/redis",
Tag: DefaultTag,
},
want: "redis",
},
{
name: "user image on docker hub",
facts: ImageFacts{
Registry: "docker.io",
Repository: "user/myapp",
Tag: "v1.0",
},
want: "user/myapp:v1.0",
},
{
name: "gcr image",
facts: ImageFacts{
Registry: "gcr.io",
Repository: "project/myapp",
Tag: "latest",
},
want: "gcr.io/project/myapp",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := GetImageShortName(tt.facts)
if got != tt.want {
t.Errorf("GetImageShortName() = %v, want %v", got, tt.want)
}
})
}
}
func TestImageCache(t *testing.T) {
cache := newImageCache(100 * time.Millisecond) // Short TTL for testing
facts := &ImageFacts{
Repository: "nginx",
Registry: "docker.io",
Tag: "latest",
}
key := "docker.io/nginx:latest"
// Test cache miss
result := cache.Get(key)
if result != nil {
t.Error("Cache should initially be empty")
}
// Test cache set and hit
cache.Set(key, facts)
result = cache.Get(key)
if result == nil {
t.Error("Cache should contain the set key")
}
if result.Repository != facts.Repository {
t.Error("Cache should return the correct facts")
}
// Test cache expiration
time.Sleep(150 * time.Millisecond) // Wait for expiration
result = cache.Get(key)
if result != nil {
t.Error("Cache entry should have expired")
}
// Test cache size
if cache.Size() != 1 {
t.Errorf("Cache size = %v, want 1", cache.Size())
}
// Test cache clear
cache.Clear()
if cache.Size() != 0 {
t.Errorf("Cache should be empty after Clear(), size = %v", cache.Size())
}
}
func TestImageCache_Cleanup(t *testing.T) {
cache := newImageCache(50 * time.Millisecond) // Very short TTL
cache.maxSize = 3 // Small max size for testing
// Add more entries than max size
for i := 0; i < 5; i++ {
key := fmt.Sprintf("image-%d", i)
facts := &ImageFacts{
Repository: fmt.Sprintf("app-%d", i),
Registry: "docker.io",
}
cache.Set(key, facts)
}
// Cache should have triggered cleanup
if cache.Size() > cache.maxSize {
t.Errorf("Cache size %d exceeds max size %d", cache.Size(), cache.maxSize)
}
// Wait for expiration
time.Sleep(60 * time.Millisecond)
// Add another entry to trigger cleanup of expired entries
cache.Set("new-entry", &ImageFacts{Repository: "new-app"})
// Should have fewer entries due to expiration cleanup
if cache.Size() > 2 {
t.Errorf("Cache should have cleaned up expired entries, size = %v", cache.Size())
}
}
func TestGetDefaultCollectionOptions(t *testing.T) {
options := GetDefaultCollectionOptions()
if !options.IncludeLayers {
t.Error("Default options should include layers")
}
if !options.IncludeConfig {
t.Error("Default options should include config")
}
if options.Timeout != DefaultTimeout {
t.Errorf("Default timeout = %v, want %v", options.Timeout, DefaultTimeout)
}
if options.MaxConcurrency != DefaultMaxConcurrency {
t.Errorf("Default max concurrency = %v, want %v", options.MaxConcurrency, DefaultMaxConcurrency)
}
if !options.ContinueOnError {
t.Error("Default options should continue on error")
}
if options.SkipTLSVerify {
t.Error("Default options should not skip TLS verify")
}
if !options.EnableCache {
t.Error("Default options should enable cache")
}
if options.CacheDuration != DefaultCacheDuration {
t.Errorf("Default cache duration = %v, want %v", options.CacheDuration, DefaultCacheDuration)
}
if options.Credentials == nil {
t.Error("Default options should have credentials map")
}
}
func BenchmarkComputeDigest(b *testing.B) {
data := make([]byte, 1024) // 1KB of data
for i := range data {
data[i] = byte(i % 256)
}
b.ResetTimer()
for i := 0; i < b.N; i++ {
_, err := ComputeDigest(data)
if err != nil {
b.Fatalf("ComputeDigest failed: %v", err)
}
}
}
func BenchmarkImageCache_SetGet(b *testing.B) {
cache := newImageCache(1 * time.Hour) // Long TTL for benchmarking
facts := &ImageFacts{
Repository: "nginx",
Registry: "docker.io",
Tag: "latest",
}
b.ResetTimer()
for i := 0; i < b.N; i++ {
key := fmt.Sprintf("image-%d", i%100) // Reuse some keys
cache.Set(key, facts)
cache.Get(key)
}
}
func TestExtractRepositoryHost(t *testing.T) {
tests := []struct {
name string
repository string
want string
}{
{
name: "simple repository",
repository: "nginx",
want: "nginx",
},
{
name: "namespace/repository",
repository: "library/nginx",
want: "library",
},
{
name: "registry/namespace/repository",
repository: "gcr.io/project/app",
want: "gcr.io",
},
{
name: "empty repository",
repository: "",
want: "",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := ExtractRepositoryHost(tt.repository)
if got != tt.want {
t.Errorf("ExtractRepositoryHost() = %v, want %v", got, tt.want)
}
})
}
}