mirror of
https://github.com/lukaszraczylo/kubernetes-images-sync-operator.git
synced 2026-06-08 23:09:23 +00:00
3880af56a7
* Bring operator to the brand new world of build and deployments. * Clean up the code and basic improvements. * More fixes, moving from python to golang worker. * fixup! More fixes, moving from python to golang worker. * fixup! fixup! More fixes, moving from python to golang worker. * fixup! fixup! fixup! More fixes, moving from python to golang worker. * fixup! fixup! fixup! fixup! More fixes, moving from python to golang worker. * fixup! fixup! fixup! fixup! fixup! More fixes, moving from python to golang worker. * fixup! fixup! fixup! fixup! fixup! fixup! More fixes, moving from python to golang worker.
1220 lines
35 KiB
Go
1220 lines
35 KiB
Go
package shared
|
|
|
|
import (
|
|
"context"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/require"
|
|
"github.com/stretchr/testify/suite"
|
|
appsv1 "k8s.io/api/apps/v1"
|
|
batchv1 "k8s.io/api/batch/v1"
|
|
corev1 "k8s.io/api/core/v1"
|
|
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
|
"k8s.io/apimachinery/pkg/runtime"
|
|
"sigs.k8s.io/controller-runtime/pkg/client"
|
|
"sigs.k8s.io/controller-runtime/pkg/client/fake"
|
|
)
|
|
|
|
// K8sTestSuite tests Kubernetes-related utility functions
|
|
type K8sTestSuite struct {
|
|
suite.Suite
|
|
}
|
|
|
|
func TestK8sTestSuite(t *testing.T) {
|
|
suite.Run(t, new(K8sTestSuite))
|
|
}
|
|
|
|
// ProcessContainerNameTestCase represents a test case for ProcessContainerName
|
|
type ProcessContainerNameTestCase struct {
|
|
name string
|
|
scenario TestScenario
|
|
input string
|
|
expectedImage string
|
|
expectedTag string
|
|
expectedSha string
|
|
expectError bool
|
|
errorContains string
|
|
}
|
|
|
|
// TestProcessContainerName tests the ProcessContainerName function with comprehensive scenarios
|
|
func (s *K8sTestSuite) TestProcessContainerName() {
|
|
testCases := []ProcessContainerNameTestCase{
|
|
// ========== GOOD SCENARIOS ==========
|
|
// Standard Docker Hub images
|
|
{
|
|
name: "simple image name defaults to latest",
|
|
scenario: ScenarioGood,
|
|
input: "nginx",
|
|
expectedImage: "nginx",
|
|
expectedTag: "latest",
|
|
expectedSha: "",
|
|
expectError: false,
|
|
},
|
|
{
|
|
name: "image with latest tag",
|
|
scenario: ScenarioGood,
|
|
input: "nginx:latest",
|
|
expectedImage: "nginx",
|
|
expectedTag: "latest",
|
|
expectedSha: "",
|
|
expectError: false,
|
|
},
|
|
{
|
|
name: "image with version tag",
|
|
scenario: ScenarioGood,
|
|
input: "nginx:1.21.0",
|
|
expectedImage: "nginx",
|
|
expectedTag: "1.21.0",
|
|
expectedSha: "",
|
|
expectError: false,
|
|
},
|
|
{
|
|
name: "image with semver tag",
|
|
scenario: ScenarioGood,
|
|
input: "redis:6.2.6",
|
|
expectedImage: "redis",
|
|
expectedTag: "6.2.6",
|
|
expectedSha: "",
|
|
expectError: false,
|
|
},
|
|
|
|
// Images with registry prefix
|
|
{
|
|
name: "gcr.io registry",
|
|
scenario: ScenarioGood,
|
|
input: "gcr.io/google-containers/pause:3.2",
|
|
expectedImage: "gcr.io/google-containers/pause",
|
|
expectedTag: "3.2",
|
|
expectedSha: "",
|
|
expectError: false,
|
|
},
|
|
{
|
|
name: "ghcr.io registry",
|
|
scenario: ScenarioGood,
|
|
input: "ghcr.io/owner/repo:v1.0.0",
|
|
expectedImage: "ghcr.io/owner/repo",
|
|
expectedTag: "v1.0.0",
|
|
expectedSha: "",
|
|
expectError: false,
|
|
},
|
|
{
|
|
name: "quay.io registry",
|
|
scenario: ScenarioGood,
|
|
input: "quay.io/coreos/etcd:v3.5.0",
|
|
expectedImage: "quay.io/coreos/etcd",
|
|
expectedTag: "v3.5.0",
|
|
expectedSha: "",
|
|
expectError: false,
|
|
},
|
|
{
|
|
name: "registry.k8s.io",
|
|
scenario: ScenarioGood,
|
|
input: "registry.k8s.io/pause:3.9",
|
|
expectedImage: "registry.k8s.io/pause",
|
|
expectedTag: "3.9",
|
|
expectedSha: "",
|
|
expectError: false,
|
|
},
|
|
{
|
|
name: "docker.io explicit registry",
|
|
scenario: ScenarioGood,
|
|
input: "docker.io/library/nginx:latest",
|
|
expectedImage: "docker.io/library/nginx",
|
|
expectedTag: "latest",
|
|
expectedSha: "",
|
|
expectError: false,
|
|
},
|
|
|
|
// Private registry with port
|
|
{
|
|
name: "private registry with port",
|
|
scenario: ScenarioGood,
|
|
input: "myregistry.local:5000/myimage:v1",
|
|
expectedImage: "myregistry.local",
|
|
expectedTag: "5000/myimage:v1", // This is the current behavior
|
|
expectedSha: "",
|
|
expectError: false,
|
|
},
|
|
|
|
// ========== NOT GOOD SCENARIOS ==========
|
|
// SHA-only references (no tag)
|
|
{
|
|
name: "image with sha256 digest only",
|
|
scenario: ScenarioNotGood,
|
|
input: "nginx@sha256:abc123def456789012345678901234567890123456789012345678901234",
|
|
expectedImage: "nginx",
|
|
expectedTag: "",
|
|
expectedSha: "sha256:abc123def456789012345678901234567890123456789012345678901234",
|
|
expectError: false,
|
|
},
|
|
{
|
|
name: "registry image with sha only",
|
|
scenario: ScenarioNotGood,
|
|
input: "gcr.io/distroless/static@sha256:abcdef1234567890",
|
|
expectedImage: "gcr.io/distroless/static",
|
|
expectedTag: "",
|
|
expectedSha: "sha256:abcdef1234567890",
|
|
expectError: false,
|
|
},
|
|
|
|
// Tag + SHA references (pinned images)
|
|
{
|
|
name: "cilium with tag and sha - real world example",
|
|
scenario: ScenarioNotGood,
|
|
input: "quay.io/cilium/cilium:v1.18.4@sha256:49d87af187eeeb9e9e3ec2bc6bd372261a0b5cb2d845659463ba7cc10fe9e45f",
|
|
expectedImage: "quay.io/cilium/cilium",
|
|
expectedTag: "v1.18.4",
|
|
expectedSha: "sha256:49d87af187eeeb9e9e3ec2bc6bd372261a0b5cb2d845659463ba7cc10fe9e45f",
|
|
expectError: false,
|
|
},
|
|
{
|
|
name: "distroless with tag and sha",
|
|
scenario: ScenarioNotGood,
|
|
input: "gcr.io/distroless/static:nonroot@sha256:abc123",
|
|
expectedImage: "gcr.io/distroless/static",
|
|
expectedTag: "nonroot",
|
|
expectedSha: "sha256:abc123",
|
|
expectError: false,
|
|
},
|
|
|
|
// Complex nested paths
|
|
{
|
|
name: "deeply nested registry path",
|
|
scenario: ScenarioNotGood,
|
|
input: "us-docker.pkg.dev/project/repo/subdir/image:tag",
|
|
expectedImage: "us-docker.pkg.dev/project/repo/subdir/image",
|
|
expectedTag: "tag",
|
|
expectedSha: "",
|
|
expectError: false,
|
|
},
|
|
|
|
// ========== REALLY BAD SCENARIOS ==========
|
|
// Empty and invalid inputs
|
|
{
|
|
name: "empty string",
|
|
scenario: ScenarioReallyBad,
|
|
input: "",
|
|
expectedImage: "",
|
|
expectedTag: "",
|
|
expectedSha: "",
|
|
expectError: true,
|
|
errorContains: "image name is required",
|
|
},
|
|
{
|
|
name: "only colon",
|
|
scenario: ScenarioReallyBad,
|
|
input: ":",
|
|
expectedImage: "",
|
|
expectedTag: "",
|
|
expectedSha: "",
|
|
expectError: true,
|
|
errorContains: "image name is required",
|
|
},
|
|
{
|
|
name: "only at sign",
|
|
scenario: ScenarioReallyBad,
|
|
input: "@",
|
|
expectedImage: "",
|
|
expectedTag: "",
|
|
expectedSha: "",
|
|
expectError: true,
|
|
errorContains: "invalid SHA format",
|
|
},
|
|
|
|
// Invalid SHA formats
|
|
{
|
|
name: "invalid sha format - missing colon",
|
|
scenario: ScenarioReallyBad,
|
|
input: "nginx@sha256abc123",
|
|
expectedImage: "",
|
|
expectedTag: "",
|
|
expectedSha: "",
|
|
expectError: true,
|
|
errorContains: "invalid SHA format",
|
|
},
|
|
{
|
|
name: "invalid sha format - wrong algorithm",
|
|
scenario: ScenarioReallyBad,
|
|
input: "nginx@md5:abc123",
|
|
expectedImage: "",
|
|
expectedTag: "",
|
|
expectedSha: "",
|
|
expectError: true,
|
|
errorContains: "invalid SHA format",
|
|
},
|
|
{
|
|
name: "multiple @ symbols",
|
|
scenario: ScenarioReallyBad,
|
|
input: "nginx@sha256:abc@sha256:def",
|
|
expectedImage: "",
|
|
expectedTag: "",
|
|
expectedSha: "",
|
|
expectError: true,
|
|
errorContains: "invalid container name format",
|
|
},
|
|
|
|
// Edge cases for Kubernetes volatility
|
|
{
|
|
name: "k8s pause image",
|
|
scenario: ScenarioGood,
|
|
input: "registry.k8s.io/pause:3.9",
|
|
expectedImage: "registry.k8s.io/pause",
|
|
expectedTag: "3.9",
|
|
expectedSha: "",
|
|
expectError: false,
|
|
},
|
|
{
|
|
name: "coredns image",
|
|
scenario: ScenarioGood,
|
|
input: "registry.k8s.io/coredns/coredns:v1.11.1",
|
|
expectedImage: "registry.k8s.io/coredns/coredns",
|
|
expectedTag: "v1.11.1",
|
|
expectedSha: "",
|
|
expectError: false,
|
|
},
|
|
{
|
|
name: "etcd with sha pinning",
|
|
scenario: ScenarioNotGood,
|
|
input: "registry.k8s.io/etcd:3.5.12-0@sha256:abc123",
|
|
expectedImage: "registry.k8s.io/etcd",
|
|
expectedTag: "3.5.12-0",
|
|
expectedSha: "sha256:abc123",
|
|
expectError: false,
|
|
},
|
|
}
|
|
|
|
for _, tc := range testCases {
|
|
s.Run(tc.name, func() {
|
|
result, err := ProcessContainerName(tc.input)
|
|
|
|
if tc.expectError {
|
|
require.Error(s.T(), err, "Scenario: %s - Expected error for input: %s", tc.scenario, tc.input)
|
|
if tc.errorContains != "" {
|
|
assert.Contains(s.T(), err.Error(), tc.errorContains)
|
|
}
|
|
} else {
|
|
require.NoError(s.T(), err, "Scenario: %s - Unexpected error for input: %s", tc.scenario, tc.input)
|
|
assert.Equal(s.T(), tc.expectedImage, result.Image, "Image mismatch")
|
|
assert.Equal(s.T(), tc.expectedTag, result.Tag, "Tag mismatch")
|
|
assert.Equal(s.T(), tc.expectedSha, result.Sha, "SHA mismatch")
|
|
|
|
// Verify FullName is preserved
|
|
if tc.input != "" {
|
|
assert.Equal(s.T(), tc.input, result.FullName, "FullName should match input")
|
|
}
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestProcessContainerNameCaching tests that the container cache works correctly
|
|
func (s *K8sTestSuite) TestProcessContainerNameCaching() {
|
|
// Clear the cache first by processing a unique image
|
|
uniqueImage := "test-cache-" + time.Now().Format("20060102150405")
|
|
|
|
// First call - should not be cached
|
|
result1, err := ProcessContainerName(uniqueImage)
|
|
require.NoError(s.T(), err)
|
|
assert.Equal(s.T(), uniqueImage, result1.Image)
|
|
assert.Equal(s.T(), "latest", result1.Tag) // Defaults to latest
|
|
|
|
// Second call - should be cached
|
|
result2, err := ProcessContainerName(uniqueImage)
|
|
require.NoError(s.T(), err)
|
|
assert.Equal(s.T(), result1, result2, "Cached result should match original")
|
|
}
|
|
|
|
// TestProcessContainerNameConcurrency tests thread safety of ProcessContainerName
|
|
func (s *K8sTestSuite) TestProcessContainerNameConcurrency() {
|
|
images := []string{
|
|
"nginx:latest",
|
|
"redis:6",
|
|
"postgres:14",
|
|
"mysql:8",
|
|
"mongo:5",
|
|
}
|
|
|
|
done := make(chan bool, len(images)*10)
|
|
errors := make(chan error, len(images)*10)
|
|
|
|
// Run 10 goroutines per image
|
|
for i := 0; i < 10; i++ {
|
|
for _, img := range images {
|
|
go func(image string) {
|
|
_, err := ProcessContainerName(image)
|
|
if err != nil {
|
|
errors <- err
|
|
}
|
|
done <- true
|
|
}(img)
|
|
}
|
|
}
|
|
|
|
// Wait for all goroutines
|
|
for i := 0; i < len(images)*10; i++ {
|
|
<-done
|
|
}
|
|
|
|
// Check for errors
|
|
close(errors)
|
|
for err := range errors {
|
|
s.T().Errorf("Concurrent access error: %v", err)
|
|
}
|
|
}
|
|
|
|
// TestContainerCacheOperations tests the ContainerCache directly
|
|
func (s *K8sTestSuite) TestContainerCacheOperations() {
|
|
cache := &ContainerCache{
|
|
cache: make(map[string]Container),
|
|
}
|
|
|
|
// Test Set and Get
|
|
testContainer := Container{
|
|
Image: "test-image",
|
|
Tag: "v1.0.0",
|
|
FullName: "test-image:v1.0.0",
|
|
}
|
|
|
|
cache.Set("test-key", testContainer)
|
|
|
|
retrieved, ok := cache.Get("test-key")
|
|
assert.True(s.T(), ok, "Should find cached container")
|
|
assert.Equal(s.T(), testContainer, retrieved)
|
|
|
|
// Test Get non-existent key
|
|
_, ok = cache.Get("non-existent")
|
|
assert.False(s.T(), ok, "Should not find non-existent key")
|
|
}
|
|
|
|
// TestProcessContainerWithContext tests context cancellation
|
|
func (s *K8sTestSuite) TestProcessContainerWithContext() {
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
|
|
// Test with active context
|
|
containersList := &ContainersList{}
|
|
err := processContainer(ctx, "nginx:latest", "default", containersList)
|
|
require.NoError(s.T(), err)
|
|
assert.Len(s.T(), containersList.Containers, 1)
|
|
|
|
// Test with cancelled context
|
|
cancel()
|
|
err = processContainer(ctx, "redis:6", "default", containersList)
|
|
assert.Error(s.T(), err)
|
|
assert.Equal(s.T(), context.Canceled, err)
|
|
}
|
|
|
|
// TestKubernetesVolatilityScenarios tests scenarios specific to Kubernetes environment volatility
|
|
func (s *K8sTestSuite) TestKubernetesVolatilityScenarios() {
|
|
testCases := []struct {
|
|
name string
|
|
description string
|
|
input string
|
|
expectError bool
|
|
}{
|
|
// Pod restart scenarios - same image, different instance
|
|
{
|
|
name: "pod_restart_same_image",
|
|
description: "When a pod restarts, the same image reference should parse identically",
|
|
input: "nginx:1.21",
|
|
expectError: false,
|
|
},
|
|
// Rolling update scenarios
|
|
{
|
|
name: "rolling_update_new_tag",
|
|
description: "During rolling update, new tag version",
|
|
input: "myapp:v2.0.0",
|
|
expectError: false,
|
|
},
|
|
// Image pull scenarios
|
|
{
|
|
name: "image_pull_backoff_recovery",
|
|
description: "Image that might have failed to pull initially",
|
|
input: "private-registry.io/secure/image:latest",
|
|
expectError: false,
|
|
},
|
|
// Helm chart common patterns
|
|
{
|
|
name: "helm_chart_image_pattern",
|
|
description: "Common Helm chart image reference pattern",
|
|
input: "bitnami/postgresql:14.5.0-debian-11-r14",
|
|
expectError: false,
|
|
},
|
|
// Operator-managed images
|
|
{
|
|
name: "operator_managed_image",
|
|
description: "Image managed by an operator with specific versioning",
|
|
input: "quay.io/prometheus/prometheus:v2.45.0",
|
|
expectError: false,
|
|
},
|
|
// Init container images
|
|
{
|
|
name: "init_container_image",
|
|
description: "Common init container image",
|
|
input: "busybox:1.36",
|
|
expectError: false,
|
|
},
|
|
// Sidecar images
|
|
{
|
|
name: "sidecar_envoy_proxy",
|
|
description: "Envoy sidecar proxy image",
|
|
input: "envoyproxy/envoy:v1.28.0",
|
|
expectError: false,
|
|
},
|
|
// CSI driver images
|
|
{
|
|
name: "csi_driver_image",
|
|
description: "CSI driver image with complex path",
|
|
input: "registry.k8s.io/sig-storage/csi-provisioner:v3.6.0",
|
|
expectError: false,
|
|
},
|
|
// Admission webhook images
|
|
{
|
|
name: "admission_webhook_image",
|
|
description: "Admission webhook controller image",
|
|
input: "k8s.gcr.io/ingress-nginx/kube-webhook-certgen:v1.3.0",
|
|
expectError: false,
|
|
},
|
|
}
|
|
|
|
for _, tc := range testCases {
|
|
s.Run(tc.name, func() {
|
|
result, err := ProcessContainerName(tc.input)
|
|
|
|
if tc.expectError {
|
|
assert.Error(s.T(), err, "Description: %s", tc.description)
|
|
} else {
|
|
assert.NoError(s.T(), err, "Description: %s", tc.description)
|
|
assert.NotEmpty(s.T(), result.Image, "Image should not be empty")
|
|
assert.Equal(s.T(), tc.input, result.FullName, "FullName should be preserved")
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestImageParsingEdgeCases tests edge cases that might occur in real Kubernetes clusters
|
|
func (s *K8sTestSuite) TestImageParsingEdgeCases() {
|
|
s.Run("image with plus sign in tag", func() {
|
|
// Some images use + in tags (e.g., build metadata)
|
|
result, err := ProcessContainerName("myimage:v1.0.0+build123")
|
|
require.NoError(s.T(), err)
|
|
assert.Equal(s.T(), "myimage", result.Image)
|
|
assert.Equal(s.T(), "v1.0.0+build123", result.Tag)
|
|
})
|
|
|
|
s.Run("image with underscore in name", func() {
|
|
result, err := ProcessContainerName("my_custom_image:latest")
|
|
require.NoError(s.T(), err)
|
|
assert.Equal(s.T(), "my_custom_image", result.Image)
|
|
})
|
|
|
|
s.Run("image with dash in tag", func() {
|
|
result, err := ProcessContainerName("nginx:1.21-alpine")
|
|
require.NoError(s.T(), err)
|
|
assert.Equal(s.T(), "1.21-alpine", result.Tag)
|
|
})
|
|
|
|
s.Run("image with rc/beta tag", func() {
|
|
result, err := ProcessContainerName("kubernetes/kube-apiserver:v1.29.0-rc.0")
|
|
require.NoError(s.T(), err)
|
|
assert.Equal(s.T(), "v1.29.0-rc.0", result.Tag)
|
|
})
|
|
|
|
s.Run("image with only sha256 digest (no tag)", func() {
|
|
sha := "sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
|
|
result, err := ProcessContainerName("alpine@" + sha)
|
|
require.NoError(s.T(), err)
|
|
assert.Equal(s.T(), "alpine", result.Image)
|
|
assert.Equal(s.T(), "", result.Tag)
|
|
assert.Equal(s.T(), sha, result.Sha)
|
|
})
|
|
|
|
s.Run("aws ecr image", func() {
|
|
result, err := ProcessContainerName("123456789.dkr.ecr.us-east-1.amazonaws.com/my-repo:latest")
|
|
require.NoError(s.T(), err)
|
|
assert.Equal(s.T(), "123456789.dkr.ecr.us-east-1.amazonaws.com/my-repo", result.Image)
|
|
assert.Equal(s.T(), "latest", result.Tag)
|
|
})
|
|
|
|
s.Run("azure acr image", func() {
|
|
result, err := ProcessContainerName("myregistry.azurecr.io/samples/nginx:v1")
|
|
require.NoError(s.T(), err)
|
|
assert.Equal(s.T(), "myregistry.azurecr.io/samples/nginx", result.Image)
|
|
assert.Equal(s.T(), "v1", result.Tag)
|
|
})
|
|
|
|
s.Run("google artifact registry", func() {
|
|
result, err := ProcessContainerName("us-central1-docker.pkg.dev/my-project/my-repo/my-image:tag")
|
|
require.NoError(s.T(), err)
|
|
assert.Equal(s.T(), "us-central1-docker.pkg.dev/my-project/my-repo/my-image", result.Image)
|
|
assert.Equal(s.T(), "tag", result.Tag)
|
|
})
|
|
}
|
|
|
|
// BenchmarkProcessContainerName benchmarks the ProcessContainerName function
|
|
func BenchmarkProcessContainerName(b *testing.B) {
|
|
images := []string{
|
|
"nginx",
|
|
"nginx:latest",
|
|
"quay.io/cilium/cilium:v1.18.4@sha256:49d87af187eeeb9e9e3ec2bc6bd372261a0b5cb2d845659463ba7cc10fe9e45f",
|
|
"gcr.io/google-containers/pause:3.2",
|
|
}
|
|
|
|
b.ResetTimer()
|
|
for i := 0; i < b.N; i++ {
|
|
for _, img := range images {
|
|
_, _ = ProcessContainerName(img)
|
|
}
|
|
}
|
|
}
|
|
|
|
// BenchmarkNormalizeImageName benchmarks the NormalizeImageName function
|
|
func BenchmarkNormalizeImageName(b *testing.B) {
|
|
images := []string{
|
|
"nginx",
|
|
"nginx:latest",
|
|
"quay.io/cilium/cilium:v1.18.4@sha256:49d87af187eeeb9e9e3ec2bc6bd372261a0b5cb2d845659463ba7cc10fe9e45f",
|
|
}
|
|
|
|
b.ResetTimer()
|
|
for i := 0; i < b.N; i++ {
|
|
for _, img := range images {
|
|
_ = NormalizeImageName(img)
|
|
}
|
|
}
|
|
}
|
|
|
|
// ========== K8s Resource Wrapper Tests ==========
|
|
|
|
// TestDeploymentWrapper tests the DeploymentWrapper GetPodSpec method
|
|
func (s *K8sTestSuite) TestDeploymentWrapper() {
|
|
deployment := appsv1.Deployment{
|
|
Spec: appsv1.DeploymentSpec{
|
|
Template: corev1.PodTemplateSpec{
|
|
Spec: corev1.PodSpec{
|
|
Containers: []corev1.Container{
|
|
{Name: "main", Image: "nginx:latest"},
|
|
},
|
|
InitContainers: []corev1.Container{
|
|
{Name: "init", Image: "busybox:1.36"},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
}
|
|
|
|
wrapper := (*DeploymentWrapper)(&deployment)
|
|
podSpec := wrapper.GetPodSpec()
|
|
|
|
require.NotNil(s.T(), podSpec)
|
|
assert.Len(s.T(), podSpec.Containers, 1)
|
|
assert.Equal(s.T(), "nginx:latest", podSpec.Containers[0].Image)
|
|
assert.Len(s.T(), podSpec.InitContainers, 1)
|
|
assert.Equal(s.T(), "busybox:1.36", podSpec.InitContainers[0].Image)
|
|
}
|
|
|
|
// TestJobWrapper tests the JobWrapper GetPodSpec method
|
|
func (s *K8sTestSuite) TestJobWrapper() {
|
|
job := batchv1.Job{
|
|
Spec: batchv1.JobSpec{
|
|
Template: corev1.PodTemplateSpec{
|
|
Spec: corev1.PodSpec{
|
|
Containers: []corev1.Container{
|
|
{Name: "worker", Image: "worker:v1.0"},
|
|
},
|
|
RestartPolicy: corev1.RestartPolicyNever,
|
|
},
|
|
},
|
|
},
|
|
}
|
|
|
|
wrapper := (*JobWrapper)(&job)
|
|
podSpec := wrapper.GetPodSpec()
|
|
|
|
require.NotNil(s.T(), podSpec)
|
|
assert.Len(s.T(), podSpec.Containers, 1)
|
|
assert.Equal(s.T(), "worker:v1.0", podSpec.Containers[0].Image)
|
|
}
|
|
|
|
// TestDaemonSetWrapper tests the DaemonSetWrapper GetPodSpec method
|
|
func (s *K8sTestSuite) TestDaemonSetWrapper() {
|
|
daemonSet := appsv1.DaemonSet{
|
|
Spec: appsv1.DaemonSetSpec{
|
|
Template: corev1.PodTemplateSpec{
|
|
Spec: corev1.PodSpec{
|
|
Containers: []corev1.Container{
|
|
{Name: "agent", Image: "prometheus/node-exporter:v1.6.0"},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
}
|
|
|
|
wrapper := (*DaemonSetWrapper)(&daemonSet)
|
|
podSpec := wrapper.GetPodSpec()
|
|
|
|
require.NotNil(s.T(), podSpec)
|
|
assert.Len(s.T(), podSpec.Containers, 1)
|
|
assert.Equal(s.T(), "prometheus/node-exporter:v1.6.0", podSpec.Containers[0].Image)
|
|
}
|
|
|
|
// TestCronJobWrapper tests the CronJobWrapper GetPodSpec method
|
|
func (s *K8sTestSuite) TestCronJobWrapper() {
|
|
cronJob := batchv1.CronJob{
|
|
Spec: batchv1.CronJobSpec{
|
|
Schedule: "*/5 * * * *",
|
|
JobTemplate: batchv1.JobTemplateSpec{
|
|
Spec: batchv1.JobSpec{
|
|
Template: corev1.PodTemplateSpec{
|
|
Spec: corev1.PodSpec{
|
|
Containers: []corev1.Container{
|
|
{Name: "cron-worker", Image: "myapp/cron:latest"},
|
|
},
|
|
RestartPolicy: corev1.RestartPolicyOnFailure,
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
}
|
|
|
|
wrapper := (*CronJobWrapper)(&cronJob)
|
|
podSpec := wrapper.GetPodSpec()
|
|
|
|
require.NotNil(s.T(), podSpec)
|
|
assert.Len(s.T(), podSpec.Containers, 1)
|
|
assert.Equal(s.T(), "myapp/cron:latest", podSpec.Containers[0].Image)
|
|
}
|
|
|
|
// ========== ProcessContainers Tests ==========
|
|
|
|
// TestProcessContainers tests the processContainers function
|
|
func (s *K8sTestSuite) TestProcessContainers() {
|
|
ctx := context.Background()
|
|
|
|
s.Run("deployment with containers and init containers", func() {
|
|
deployment := appsv1.Deployment{
|
|
Spec: appsv1.DeploymentSpec{
|
|
Template: corev1.PodTemplateSpec{
|
|
Spec: corev1.PodSpec{
|
|
Containers: []corev1.Container{
|
|
{Name: "main", Image: "nginx:1.21"},
|
|
{Name: "sidecar", Image: "envoyproxy/envoy:v1.28"},
|
|
},
|
|
InitContainers: []corev1.Container{
|
|
{Name: "init-db", Image: "postgres:15"},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
}
|
|
|
|
wrapper := (*DeploymentWrapper)(&deployment)
|
|
containersList := &ContainersList{}
|
|
err := processContainers(ctx, wrapper, "default", containersList)
|
|
|
|
require.NoError(s.T(), err)
|
|
assert.Len(s.T(), containersList.Containers, 3)
|
|
|
|
// Verify all images are processed
|
|
images := make([]string, len(containersList.Containers))
|
|
for i, c := range containersList.Containers {
|
|
images[i] = c.FullName
|
|
}
|
|
assert.Contains(s.T(), images, "nginx:1.21")
|
|
assert.Contains(s.T(), images, "envoyproxy/envoy:v1.28")
|
|
assert.Contains(s.T(), images, "postgres:15")
|
|
|
|
// Verify namespace is set
|
|
for _, c := range containersList.Containers {
|
|
assert.Equal(s.T(), "default", c.ImageNamespace)
|
|
}
|
|
})
|
|
|
|
s.Run("job with single container", func() {
|
|
job := batchv1.Job{
|
|
Spec: batchv1.JobSpec{
|
|
Template: corev1.PodTemplateSpec{
|
|
Spec: corev1.PodSpec{
|
|
Containers: []corev1.Container{
|
|
{Name: "worker", Image: "myapp/worker:v2.0"},
|
|
},
|
|
RestartPolicy: corev1.RestartPolicyNever,
|
|
},
|
|
},
|
|
},
|
|
}
|
|
|
|
wrapper := (*JobWrapper)(&job)
|
|
containersList := &ContainersList{}
|
|
err := processContainers(ctx, wrapper, "production", containersList)
|
|
|
|
require.NoError(s.T(), err)
|
|
assert.Len(s.T(), containersList.Containers, 1)
|
|
assert.Equal(s.T(), "myapp/worker", containersList.Containers[0].Image)
|
|
assert.Equal(s.T(), "v2.0", containersList.Containers[0].Tag)
|
|
assert.Equal(s.T(), "production", containersList.Containers[0].ImageNamespace)
|
|
})
|
|
|
|
s.Run("daemonset with multiple containers", func() {
|
|
daemonSet := appsv1.DaemonSet{
|
|
Spec: appsv1.DaemonSetSpec{
|
|
Template: corev1.PodTemplateSpec{
|
|
Spec: corev1.PodSpec{
|
|
Containers: []corev1.Container{
|
|
{Name: "exporter", Image: "prom/node-exporter:v1.6.0"},
|
|
{Name: "collector", Image: "otel/opentelemetry-collector:0.88.0"},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
}
|
|
|
|
wrapper := (*DaemonSetWrapper)(&daemonSet)
|
|
containersList := &ContainersList{}
|
|
err := processContainers(ctx, wrapper, "monitoring", containersList)
|
|
|
|
require.NoError(s.T(), err)
|
|
assert.Len(s.T(), containersList.Containers, 2)
|
|
})
|
|
|
|
s.Run("cronjob with container", func() {
|
|
cronJob := batchv1.CronJob{
|
|
Spec: batchv1.CronJobSpec{
|
|
Schedule: "0 * * * *",
|
|
JobTemplate: batchv1.JobTemplateSpec{
|
|
Spec: batchv1.JobSpec{
|
|
Template: corev1.PodTemplateSpec{
|
|
Spec: corev1.PodSpec{
|
|
Containers: []corev1.Container{
|
|
{Name: "backup", Image: "restic/restic:0.16.2"},
|
|
},
|
|
RestartPolicy: corev1.RestartPolicyNever,
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
}
|
|
|
|
wrapper := (*CronJobWrapper)(&cronJob)
|
|
containersList := &ContainersList{}
|
|
err := processContainers(ctx, wrapper, "backup", containersList)
|
|
|
|
require.NoError(s.T(), err)
|
|
assert.Len(s.T(), containersList.Containers, 1)
|
|
assert.Equal(s.T(), "restic/restic", containersList.Containers[0].Image)
|
|
})
|
|
|
|
s.Run("deployment with ephemeral containers", func() {
|
|
deployment := appsv1.Deployment{
|
|
Spec: appsv1.DeploymentSpec{
|
|
Template: corev1.PodTemplateSpec{
|
|
Spec: corev1.PodSpec{
|
|
Containers: []corev1.Container{
|
|
{Name: "main", Image: "myapp:v1"},
|
|
},
|
|
EphemeralContainers: []corev1.EphemeralContainer{
|
|
{
|
|
EphemeralContainerCommon: corev1.EphemeralContainerCommon{
|
|
Name: "debugger",
|
|
Image: "busybox:1.36",
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
}
|
|
|
|
wrapper := (*DeploymentWrapper)(&deployment)
|
|
containersList := &ContainersList{}
|
|
err := processContainers(ctx, wrapper, "default", containersList)
|
|
|
|
require.NoError(s.T(), err)
|
|
// Should include both main container and ephemeral container
|
|
assert.Len(s.T(), containersList.Containers, 2)
|
|
})
|
|
|
|
s.Run("context cancellation", func() {
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
cancel() // Cancel immediately
|
|
|
|
deployment := appsv1.Deployment{
|
|
Spec: appsv1.DeploymentSpec{
|
|
Template: corev1.PodTemplateSpec{
|
|
Spec: corev1.PodSpec{
|
|
Containers: []corev1.Container{
|
|
{Name: "main", Image: "nginx:latest"},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
}
|
|
|
|
wrapper := (*DeploymentWrapper)(&deployment)
|
|
containersList := &ContainersList{}
|
|
err := processContainers(ctx, wrapper, "default", containersList)
|
|
|
|
// Should return context error
|
|
assert.Error(s.T(), err)
|
|
assert.ErrorIs(s.T(), err, context.Canceled)
|
|
})
|
|
}
|
|
|
|
// TestProcessContainer tests the processContainer function directly
|
|
func (s *K8sTestSuite) TestProcessContainer() {
|
|
ctx := context.Background()
|
|
|
|
s.Run("valid image", func() {
|
|
containersList := &ContainersList{}
|
|
err := processContainer(ctx, "nginx:1.21", "default", containersList)
|
|
|
|
require.NoError(s.T(), err)
|
|
assert.Len(s.T(), containersList.Containers, 1)
|
|
assert.Equal(s.T(), "nginx", containersList.Containers[0].Image)
|
|
assert.Equal(s.T(), "1.21", containersList.Containers[0].Tag)
|
|
assert.Equal(s.T(), "default", containersList.Containers[0].ImageNamespace)
|
|
})
|
|
|
|
s.Run("image with sha", func() {
|
|
sha := "sha256:abc123def456"
|
|
containersList := &ContainersList{}
|
|
err := processContainer(ctx, "nginx:1.21@"+sha, "production", containersList)
|
|
|
|
require.NoError(s.T(), err)
|
|
assert.Len(s.T(), containersList.Containers, 1)
|
|
assert.Equal(s.T(), "nginx", containersList.Containers[0].Image)
|
|
assert.Equal(s.T(), "1.21", containersList.Containers[0].Tag)
|
|
assert.Equal(s.T(), sha, containersList.Containers[0].Sha)
|
|
})
|
|
|
|
s.Run("context already cancelled", func() {
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
cancel()
|
|
|
|
containersList := &ContainersList{}
|
|
err := processContainer(ctx, "nginx:latest", "default", containersList)
|
|
|
|
assert.Error(s.T(), err)
|
|
assert.ErrorIs(s.T(), err, context.Canceled)
|
|
})
|
|
|
|
s.Run("invalid image name", func() {
|
|
containersList := &ContainersList{}
|
|
err := processContainer(ctx, "@@@invalid@@@", "default", containersList)
|
|
|
|
assert.Error(s.T(), err)
|
|
})
|
|
}
|
|
|
|
// TestContainerCache tests the container cache functionality
|
|
func (s *K8sTestSuite) TestContainerCache() {
|
|
// Clear cache first
|
|
containerCache.Lock()
|
|
containerCache.cache = make(map[string]Container)
|
|
containerCache.Unlock()
|
|
|
|
s.Run("cache miss then hit", func() {
|
|
// First call - cache miss
|
|
result1, err := ProcessContainerName("redis:7.0")
|
|
require.NoError(s.T(), err)
|
|
assert.Equal(s.T(), "redis", result1.Image)
|
|
|
|
// Second call - should hit cache
|
|
result2, err := ProcessContainerName("redis:7.0")
|
|
require.NoError(s.T(), err)
|
|
assert.Equal(s.T(), result1, result2)
|
|
})
|
|
|
|
s.Run("get from empty cache", func() {
|
|
_, found := containerCache.Get("nonexistent:tag")
|
|
assert.False(s.T(), found)
|
|
})
|
|
|
|
s.Run("set and get from cache", func() {
|
|
testContainer := Container{
|
|
Image: "test-image",
|
|
Tag: "test-tag",
|
|
FullName: "test-image:test-tag",
|
|
}
|
|
containerCache.Set("test-key", testContainer)
|
|
|
|
retrieved, found := containerCache.Get("test-key")
|
|
assert.True(s.T(), found)
|
|
assert.Equal(s.T(), testContainer, retrieved)
|
|
})
|
|
}
|
|
|
|
// TestConcurrentProcessing tests concurrent container processing
|
|
func (s *K8sTestSuite) TestConcurrentProcessing() {
|
|
ctx := context.Background()
|
|
|
|
s.Run("concurrent processContainer calls", func() {
|
|
images := []string{
|
|
"nginx:1.21",
|
|
"redis:7.0",
|
|
"postgres:15",
|
|
"mysql:8.0",
|
|
"mongodb:6.0",
|
|
}
|
|
|
|
results := make(chan Container, len(images))
|
|
errors := make(chan error, len(images))
|
|
|
|
for _, img := range images {
|
|
go func(image string) {
|
|
containersList := &ContainersList{}
|
|
err := processContainer(ctx, image, "default", containersList)
|
|
if err != nil {
|
|
errors <- err
|
|
return
|
|
}
|
|
if len(containersList.Containers) > 0 {
|
|
results <- containersList.Containers[0]
|
|
}
|
|
}(img)
|
|
}
|
|
|
|
// Collect results
|
|
collected := 0
|
|
for collected < len(images) {
|
|
select {
|
|
case <-results:
|
|
collected++
|
|
case err := <-errors:
|
|
s.T().Errorf("Unexpected error: %v", err)
|
|
collected++
|
|
case <-time.After(5 * time.Second):
|
|
s.T().Fatal("Timeout waiting for concurrent processing")
|
|
}
|
|
}
|
|
|
|
assert.Equal(s.T(), len(images), collected)
|
|
})
|
|
}
|
|
|
|
// Helper to create a fake client
|
|
func (s *K8sTestSuite) newFakeClient(objs ...client.Object) client.Client {
|
|
scheme := runtime.NewScheme()
|
|
_ = appsv1.AddToScheme(scheme)
|
|
_ = batchv1.AddToScheme(scheme)
|
|
_ = corev1.AddToScheme(scheme)
|
|
|
|
return fake.NewClientBuilder().
|
|
WithScheme(scheme).
|
|
WithObjects(objs...).
|
|
Build()
|
|
}
|
|
|
|
// TestListAndProcessResources tests the ListAndProcessResources function
|
|
func (s *K8sTestSuite) TestListAndProcessResources() {
|
|
ctx := context.Background()
|
|
|
|
s.Run("process deployments", func() {
|
|
deployment := &appsv1.Deployment{
|
|
ObjectMeta: metav1.ObjectMeta{
|
|
Name: "test-deployment",
|
|
Namespace: "default",
|
|
},
|
|
Spec: appsv1.DeploymentSpec{
|
|
Selector: &metav1.LabelSelector{
|
|
MatchLabels: map[string]string{"app": "test"},
|
|
},
|
|
Template: corev1.PodTemplateSpec{
|
|
ObjectMeta: metav1.ObjectMeta{Labels: map[string]string{"app": "test"}},
|
|
Spec: corev1.PodSpec{
|
|
Containers: []corev1.Container{
|
|
{Name: "main", Image: "nginx:1.21"},
|
|
{Name: "sidecar", Image: "envoy:v1.28"},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
}
|
|
|
|
fakeClient := s.newFakeClient(deployment)
|
|
containersList := &ContainersList{}
|
|
err := ListAndProcessResources[*DeploymentWrapper](ctx, fakeClient, &appsv1.DeploymentList{}, containersList)
|
|
|
|
require.NoError(s.T(), err)
|
|
assert.Len(s.T(), containersList.Containers, 2)
|
|
|
|
// Verify images
|
|
images := make(map[string]bool)
|
|
for _, c := range containersList.Containers {
|
|
images[c.FullName] = true
|
|
}
|
|
assert.True(s.T(), images["nginx:1.21"])
|
|
assert.True(s.T(), images["envoy:v1.28"])
|
|
})
|
|
|
|
s.Run("process jobs", func() {
|
|
job := &batchv1.Job{
|
|
ObjectMeta: metav1.ObjectMeta{
|
|
Name: "test-job",
|
|
Namespace: "default",
|
|
},
|
|
Spec: batchv1.JobSpec{
|
|
Template: corev1.PodTemplateSpec{
|
|
Spec: corev1.PodSpec{
|
|
Containers: []corev1.Container{
|
|
{Name: "worker", Image: "busybox:1.36"},
|
|
},
|
|
RestartPolicy: corev1.RestartPolicyNever,
|
|
},
|
|
},
|
|
},
|
|
}
|
|
|
|
fakeClient := s.newFakeClient(job)
|
|
containersList := &ContainersList{}
|
|
err := ListAndProcessResources[*JobWrapper](ctx, fakeClient, &batchv1.JobList{}, containersList)
|
|
|
|
require.NoError(s.T(), err)
|
|
assert.Len(s.T(), containersList.Containers, 1)
|
|
assert.Equal(s.T(), "busybox", containersList.Containers[0].Image)
|
|
})
|
|
|
|
s.Run("process daemonsets", func() {
|
|
daemonSet := &appsv1.DaemonSet{
|
|
ObjectMeta: metav1.ObjectMeta{
|
|
Name: "test-ds",
|
|
Namespace: "monitoring",
|
|
},
|
|
Spec: appsv1.DaemonSetSpec{
|
|
Selector: &metav1.LabelSelector{
|
|
MatchLabels: map[string]string{"app": "exporter"},
|
|
},
|
|
Template: corev1.PodTemplateSpec{
|
|
ObjectMeta: metav1.ObjectMeta{Labels: map[string]string{"app": "exporter"}},
|
|
Spec: corev1.PodSpec{
|
|
Containers: []corev1.Container{
|
|
{Name: "exporter", Image: "prom/node-exporter:v1.6.0"},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
}
|
|
|
|
fakeClient := s.newFakeClient(daemonSet)
|
|
containersList := &ContainersList{}
|
|
err := ListAndProcessResources[*DaemonSetWrapper](ctx, fakeClient, &appsv1.DaemonSetList{}, containersList)
|
|
|
|
require.NoError(s.T(), err)
|
|
assert.Len(s.T(), containersList.Containers, 1)
|
|
assert.Equal(s.T(), "monitoring", containersList.Containers[0].ImageNamespace)
|
|
})
|
|
|
|
s.Run("process cronjobs", func() {
|
|
cronJob := &batchv1.CronJob{
|
|
ObjectMeta: metav1.ObjectMeta{
|
|
Name: "test-cron",
|
|
Namespace: "batch",
|
|
},
|
|
Spec: batchv1.CronJobSpec{
|
|
Schedule: "0 * * * *",
|
|
JobTemplate: batchv1.JobTemplateSpec{
|
|
Spec: batchv1.JobSpec{
|
|
Template: corev1.PodTemplateSpec{
|
|
Spec: corev1.PodSpec{
|
|
Containers: []corev1.Container{
|
|
{Name: "cron", Image: "alpine:3.18"},
|
|
},
|
|
RestartPolicy: corev1.RestartPolicyNever,
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
}
|
|
|
|
fakeClient := s.newFakeClient(cronJob)
|
|
containersList := &ContainersList{}
|
|
err := ListAndProcessResources[*CronJobWrapper](ctx, fakeClient, &batchv1.CronJobList{}, containersList)
|
|
|
|
require.NoError(s.T(), err)
|
|
assert.Len(s.T(), containersList.Containers, 1)
|
|
assert.Equal(s.T(), "alpine", containersList.Containers[0].Image)
|
|
})
|
|
|
|
s.Run("process multiple resources", func() {
|
|
deployment1 := &appsv1.Deployment{
|
|
ObjectMeta: metav1.ObjectMeta{Name: "deploy-1", Namespace: "ns1"},
|
|
Spec: appsv1.DeploymentSpec{
|
|
Selector: &metav1.LabelSelector{MatchLabels: map[string]string{"app": "app1"}},
|
|
Template: corev1.PodTemplateSpec{
|
|
ObjectMeta: metav1.ObjectMeta{Labels: map[string]string{"app": "app1"}},
|
|
Spec: corev1.PodSpec{
|
|
Containers: []corev1.Container{{Name: "main", Image: "app1:v1"}},
|
|
},
|
|
},
|
|
},
|
|
}
|
|
deployment2 := &appsv1.Deployment{
|
|
ObjectMeta: metav1.ObjectMeta{Name: "deploy-2", Namespace: "ns2"},
|
|
Spec: appsv1.DeploymentSpec{
|
|
Selector: &metav1.LabelSelector{MatchLabels: map[string]string{"app": "app2"}},
|
|
Template: corev1.PodTemplateSpec{
|
|
ObjectMeta: metav1.ObjectMeta{Labels: map[string]string{"app": "app2"}},
|
|
Spec: corev1.PodSpec{
|
|
Containers: []corev1.Container{{Name: "main", Image: "app2:v1"}},
|
|
},
|
|
},
|
|
},
|
|
}
|
|
|
|
fakeClient := s.newFakeClient(deployment1, deployment2)
|
|
containersList := &ContainersList{}
|
|
err := ListAndProcessResources[*DeploymentWrapper](ctx, fakeClient, &appsv1.DeploymentList{}, containersList)
|
|
|
|
require.NoError(s.T(), err)
|
|
assert.Len(s.T(), containersList.Containers, 2)
|
|
})
|
|
|
|
s.Run("empty list", func() {
|
|
fakeClient := s.newFakeClient()
|
|
containersList := &ContainersList{}
|
|
err := ListAndProcessResources[*DeploymentWrapper](ctx, fakeClient, &appsv1.DeploymentList{}, containersList)
|
|
|
|
require.NoError(s.T(), err)
|
|
assert.Len(s.T(), containersList.Containers, 0)
|
|
})
|
|
|
|
s.Run("context cancellation", func() {
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
cancel()
|
|
|
|
deployment := &appsv1.Deployment{
|
|
ObjectMeta: metav1.ObjectMeta{Name: "test", Namespace: "default"},
|
|
Spec: appsv1.DeploymentSpec{
|
|
Selector: &metav1.LabelSelector{MatchLabels: map[string]string{"app": "test"}},
|
|
Template: corev1.PodTemplateSpec{
|
|
ObjectMeta: metav1.ObjectMeta{Labels: map[string]string{"app": "test"}},
|
|
Spec: corev1.PodSpec{
|
|
Containers: []corev1.Container{{Name: "main", Image: "test:v1"}},
|
|
},
|
|
},
|
|
},
|
|
}
|
|
|
|
fakeClient := s.newFakeClient(deployment)
|
|
containersList := &ContainersList{}
|
|
err := ListAndProcessResources[*DeploymentWrapper](ctx, fakeClient, &appsv1.DeploymentList{}, containersList)
|
|
|
|
// Context cancellation should result in an error
|
|
assert.Error(s.T(), err)
|
|
})
|
|
|
|
s.Run("unsupported list type", func() {
|
|
fakeClient := s.newFakeClient()
|
|
containersList := &ContainersList{}
|
|
// Pass an unsupported list type (corev1.PodList is not handled)
|
|
err := ListAndProcessResources[*DeploymentWrapper](ctx, fakeClient, &corev1.PodList{}, containersList)
|
|
|
|
assert.Error(s.T(), err)
|
|
assert.Contains(s.T(), err.Error(), "unsupported list type")
|
|
})
|
|
}
|