fixup! Update go.mod and go.sum (#48)

This commit is contained in:
2026-02-20 15:39:27 +00:00
parent 0aaf2dc78c
commit 8e5eaab0af
43 changed files with 5271 additions and 129 deletions
+16 -4
View File
@@ -24,7 +24,7 @@ import (
// ClientPool manages Kubernetes clients per context with thread-safe access.
type ClientPool struct {
loader clientcmd.ClientConfig
clients map[string]*kubernetes.Clientset
clients map[string]kubernetes.Interface
configs map[string]*rest.Config
mu sync.RWMutex
}
@@ -38,7 +38,7 @@ func NewClientPool() (*ClientPool, error) {
loader := clientcmd.NewNonInteractiveDeferredLoadingClientConfig(loadingRules, configOverrides)
return &ClientPool{
clients: make(map[string]*kubernetes.Clientset),
clients: make(map[string]kubernetes.Interface),
configs: make(map[string]*rest.Config),
loader: loader,
}, nil
@@ -47,7 +47,7 @@ func NewClientPool() (*ClientPool, error) {
// GetClient returns a Kubernetes client for the given context.
// Clients are cached and reused across multiple calls.
// This method is thread-safe.
func (p *ClientPool) GetClient(contextName string) (*kubernetes.Clientset, error) {
func (p *ClientPool) GetClient(contextName string) (kubernetes.Interface, error) {
// Try to get cached client (read lock)
p.mu.RLock()
client, exists := p.clients[contextName]
@@ -183,7 +183,7 @@ func (p *ClientPool) ClearCache() {
p.mu.Lock()
defer p.mu.Unlock()
p.clients = make(map[string]*kubernetes.Clientset)
p.clients = make(map[string]kubernetes.Interface)
p.configs = make(map[string]*rest.Config)
}
@@ -216,3 +216,15 @@ func (p *ClientPool) GetNamespace(contextName string) (string, error) {
return context.Namespace, nil
}
// setTestClient is a test helper that injects a fake client for a context.
// This is only used in tests to enable testing without real kubeconfig.
func (p *ClientPool) setTestClient(contextName string, client kubernetes.Interface) {
p.mu.Lock()
defer p.mu.Unlock()
if p.clients == nil {
p.clients = make(map[string]kubernetes.Interface)
}
p.clients[contextName] = client
}
+270
View File
@@ -0,0 +1,270 @@
package k8s
import (
"sync"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
corev1 "k8s.io/api/core/v1"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/client-go/kubernetes/fake"
)
// =============================================================================
// ClientPool Extended Tests
// =============================================================================
func TestClientPool_GetClient_Caching(t *testing.T) {
pool := setupTestPool(t, "test-context",
&corev1.Pod{
ObjectMeta: metav1.ObjectMeta{
Name: "test-pod",
Namespace: "default",
},
},
)
// First call - should create and cache
client1, err := pool.GetClient("test-context")
require.NoError(t, err)
assert.NotNil(t, client1)
// Second call - should return cached
client2, err := pool.GetClient("test-context")
require.NoError(t, err)
assert.Equal(t, client1, client2)
}
func TestClientPool_GetRestConfig_Caching(t *testing.T) {
// This test would require actual kubeconfig context
// Skip it for unit testing - covered by integration tests
t.Skip("Requires actual kubeconfig context - skipping in unit tests")
}
func TestClientPool_ClearCache_ThreadSafe(t *testing.T) {
pool := setupTestPool(t, "test-context")
// Populate client cache
_, err := pool.GetClient("test-context")
require.NoError(t, err)
// Manually populate configs for testing
pool.mu.Lock()
pool.configs["test-context"] = nil
pool.mu.Unlock()
// Clear cache multiple times concurrently
var wg sync.WaitGroup
for i := 0; i < 10; i++ {
wg.Add(1)
go func() {
defer wg.Done()
pool.ClearCache()
}()
}
wg.Wait()
// Verify cache is empty
pool.mu.RLock()
assert.Empty(t, pool.clients)
assert.Empty(t, pool.configs)
pool.mu.RUnlock()
}
func TestClientPool_RemoveContext_ThreadSafe(t *testing.T) {
pool := setupTestPool(t, "test-context")
// Populate cache
_, err := pool.GetClient("test-context")
require.NoError(t, err)
// Remove from multiple goroutines
var wg sync.WaitGroup
for i := 0; i < 10; i++ {
wg.Add(1)
go func() {
defer wg.Done()
pool.RemoveContext("test-context")
}()
}
wg.Wait()
// Verify removed
pool.mu.RLock()
_, exists := pool.clients["test-context"]
pool.mu.RUnlock()
assert.False(t, exists)
}
func TestClientPool_ConcurrentGetClient(t *testing.T) {
pool := setupTestPool(t, "test-context")
var wg sync.WaitGroup
// Concurrent reads
for i := 0; i < 5; i++ {
wg.Add(1)
go func() {
defer wg.Done()
_, _ = pool.GetClient("test-context")
}()
}
// Concurrent config reads
for i := 0; i < 5; i++ {
wg.Add(1)
go func() {
defer wg.Done()
_, _ = pool.GetRestConfig("test-context")
}()
}
// Concurrent cache operations
for i := 0; i < 3; i++ {
wg.Add(1)
go func() {
defer wg.Done()
pool.ClearCache()
}()
}
wg.Wait()
// If we got here without panic/deadlock, the test passed
assert.NotNil(t, pool)
}
func TestClientPool_GetClient_MultipleContexts(t *testing.T) {
fakeClient1 := fake.NewClientset()
fakeClient2 := fake.NewClientset()
pool, err := NewClientPool()
require.NoError(t, err)
pool.setTestClient("context-1", fakeClient1)
pool.setTestClient("context-2", fakeClient2)
client1, err := pool.GetClient("context-1")
require.NoError(t, err)
assert.Equal(t, fakeClient1, client1)
client2, err := pool.GetClient("context-2")
require.NoError(t, err)
assert.Equal(t, fakeClient2, client2)
// Verify they are different
assert.NotEqual(t, client1, client2)
}
func TestClientPool_GetRestConfig_MultipleContexts(t *testing.T) {
// This test would require actual kubeconfig contexts
// Skip it for unit testing - covered by integration tests
t.Skip("Requires actual kubeconfig contexts - skipping in unit tests")
}
func TestClientPool_RemoveContext_Specific(t *testing.T) {
pool := setupTestPool(t, "context-1")
pool.setTestClient("context-2", fake.NewClientset())
// Populate both caches
_, err := pool.GetClient("context-1")
require.NoError(t, err)
_, err = pool.GetClient("context-2")
require.NoError(t, err)
// Remove only context-1
pool.RemoveContext("context-1")
// Verify context-1 removed but context-2 still there
pool.mu.RLock()
_, exists1 := pool.clients["context-1"]
_, exists2 := pool.clients["context-2"]
pool.mu.RUnlock()
assert.False(t, exists1)
assert.True(t, exists2)
}
func TestClientPool_setTestClient_NilMap(t *testing.T) {
pool, err := NewClientPool()
require.NoError(t, err)
// Clear the map manually to simulate nil case
pool.mu.Lock()
pool.clients = nil
pool.mu.Unlock()
// Should handle nil map
pool.setTestClient("test-context", fake.NewClientset())
// Verify it was set
pool.mu.RLock()
_, exists := pool.clients["test-context"]
pool.mu.RUnlock()
assert.True(t, exists)
}
func TestClientPool_GetNamespace_WithTestClient(t *testing.T) {
pool := setupTestPool(t, "test-context")
// The GetNamespace method uses the loader to get namespace from kubeconfig context
// Since we're using test client, this may fail depending on kubeconfig
_, err := pool.GetNamespace("test-context")
// May succeed or fail depending on environment
// Just verify it doesn't panic
_ = err
}
func TestClientPool_GetClient_NotFound(t *testing.T) {
pool, err := NewClientPool()
require.NoError(t, err)
// Try to get client for non-existent context without setting test client
_, err = pool.GetClient("non-existent-context")
assert.Error(t, err)
assert.Contains(t, err.Error(), "not found in kubeconfig")
}
func TestClientPool_GetRestConfig_NotFound(t *testing.T) {
pool, err := NewClientPool()
require.NoError(t, err)
// Try to get rest config for non-existent context
_, err = pool.GetRestConfig("non-existent-context")
assert.Error(t, err)
assert.Contains(t, err.Error(), "not found in kubeconfig")
}
func TestClientPool_DoubleCheckCache(t *testing.T) {
pool := setupTestPool(t, "test-context")
// Simulate race where two goroutines try to get the same client
// One creates it, the other should get cached version
var client1, client2 interface{}
var err1, err2 error
var wg sync.WaitGroup
wg.Add(2)
go func() {
defer wg.Done()
client1, err1 = pool.GetClient("test-context")
}()
go func() {
defer wg.Done()
client2, err2 = pool.GetClient("test-context")
}()
wg.Wait()
require.NoError(t, err1)
require.NoError(t, err2)
assert.Equal(t, client1, client2)
}
func TestClientPool_DoubleCheckRestConfig(t *testing.T) {
// This test would require actual kubeconfig context
// Skip it for unit testing - covered by integration tests
t.Skip("Requires actual kubeconfig context - skipping in unit tests")
}
+601
View File
@@ -0,0 +1,601 @@
package k8s
import (
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
corev1 "k8s.io/api/core/v1"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/runtime"
"k8s.io/apimachinery/pkg/util/intstr"
"k8s.io/client-go/kubernetes/fake"
)
// =============================================================================
// Test Helpers
// =============================================================================
func setupTestPool(t *testing.T, contextName string, objects ...runtime.Object) *ClientPool {
t.Helper()
pool, err := NewClientPool()
require.NoError(t, err)
fakeClient := fake.NewClientset(objects...)
// Type assertion to convert fake client to *kubernetes.Clientset
// Note: This works because fake.Clientset embeds *kubernetes.Clientset
pool.setTestClient(contextName, fakeClient)
return pool
}
// =============================================================================
// Discovery API Tests
// =============================================================================
func TestDiscovery_ListNamespaces_WithClient(t *testing.T) {
pool := setupTestPool(t, "test-context",
&corev1.Namespace{
ObjectMeta: metav1.ObjectMeta{Name: "default"},
},
&corev1.Namespace{
ObjectMeta: metav1.ObjectMeta{Name: "kube-system"},
},
&corev1.Namespace{
ObjectMeta: metav1.ObjectMeta{Name: "production"},
},
)
d := NewDiscovery(pool)
namespaces, err := d.ListNamespaces(t.Context(), "test-context")
require.NoError(t, err)
assert.Len(t, namespaces, 3)
assert.Contains(t, namespaces, "default")
assert.Contains(t, namespaces, "kube-system")
assert.Contains(t, namespaces, "production")
}
func TestDiscovery_ListNamespaces_Error(t *testing.T) {
// Pool without test client - should fail
pool, err := NewClientPool()
require.NoError(t, err)
d := NewDiscovery(pool)
_, err = d.ListNamespaces(t.Context(), "non-existent-context")
assert.Error(t, err)
}
func TestDiscovery_ListPods_WithClient(t *testing.T) {
baseTime := time.Now()
pool := setupTestPool(t, "test-context",
&corev1.Pod{
ObjectMeta: metav1.ObjectMeta{
Name: "running-pod",
Namespace: "default",
CreationTimestamp: metav1.Time{Time: baseTime},
},
Status: corev1.PodStatus{Phase: corev1.PodRunning},
Spec: corev1.PodSpec{
Containers: []corev1.Container{
{
Name: "main",
Ports: []corev1.ContainerPort{
{Name: "http", ContainerPort: 8080},
{Name: "metrics", ContainerPort: 9090},
},
},
},
},
},
&corev1.Pod{
ObjectMeta: metav1.ObjectMeta{
Name: "pending-pod",
Namespace: "default",
CreationTimestamp: metav1.Time{Time: baseTime.Add(-time.Hour)},
},
Status: corev1.PodStatus{Phase: corev1.PodPending},
},
&corev1.Pod{
ObjectMeta: metav1.ObjectMeta{
Name: "succeeded-pod",
Namespace: "default",
CreationTimestamp: metav1.Time{Time: baseTime},
},
Status: corev1.PodStatus{Phase: corev1.PodSucceeded},
},
)
d := NewDiscovery(pool)
pods, err := d.ListPods(t.Context(), "test-context", "default")
require.NoError(t, err)
// Only Running and Pending pods
assert.Len(t, pods, 2)
// Should be sorted by creation time (newest first)
assert.Equal(t, "running-pod", pods[0].Name)
assert.Equal(t, "pending-pod", pods[1].Name)
// Check container info
assert.Len(t, pods[0].Containers, 1)
assert.Len(t, pods[0].Containers[0].Ports, 2)
assert.Equal(t, "http", pods[0].Containers[0].Ports[0].Name)
assert.Equal(t, int32(8080), pods[0].Containers[0].Ports[0].Port)
}
func TestDiscovery_ListPods_EmptyNamespace(t *testing.T) {
pool := setupTestPool(t, "test-context")
d := NewDiscovery(pool)
pods, err := d.ListPods(t.Context(), "test-context", "default")
require.NoError(t, err)
assert.Empty(t, pods)
}
func TestDiscovery_ListPodsWithSelector_WithClient(t *testing.T) {
baseTime := time.Now()
pool := setupTestPool(t, "test-context",
&corev1.Pod{
ObjectMeta: metav1.ObjectMeta{
Name: "app-pod-1",
Namespace: "default",
Labels: map[string]string{"app": "myapp"},
CreationTimestamp: metav1.Time{Time: baseTime},
},
Status: corev1.PodStatus{Phase: corev1.PodRunning},
},
&corev1.Pod{
ObjectMeta: metav1.ObjectMeta{
Name: "app-pod-2",
Namespace: "default",
Labels: map[string]string{"app": "myapp"},
CreationTimestamp: metav1.Time{Time: baseTime.Add(-time.Hour)},
},
Status: corev1.PodStatus{Phase: corev1.PodRunning},
},
&corev1.Pod{
ObjectMeta: metav1.ObjectMeta{
Name: "other-pod",
Namespace: "default",
Labels: map[string]string{"app": "other"},
CreationTimestamp: metav1.Time{Time: baseTime},
},
Status: corev1.PodStatus{Phase: corev1.PodRunning},
},
)
d := NewDiscovery(pool)
pods, err := d.ListPodsWithSelector(t.Context(), "test-context", "default", "app=myapp")
require.NoError(t, err)
// Only Running pods with matching selector
assert.Len(t, pods, 2)
names := []string{pods[0].Name, pods[1].Name}
assert.Contains(t, names, "app-pod-1")
assert.Contains(t, names, "app-pod-2")
}
func TestDiscovery_ListPodsWithSelector_EmptySelector(t *testing.T) {
pool := setupTestPool(t, "test-context")
d := NewDiscovery(pool)
_, err := d.ListPodsWithSelector(t.Context(), "test-context", "default", "")
assert.Error(t, err)
assert.Contains(t, err.Error(), "selector cannot be empty")
}
func TestDiscovery_ListPodsWithSelector_NoRunningPods(t *testing.T) {
pool := setupTestPool(t, "test-context",
&corev1.Pod{
ObjectMeta: metav1.ObjectMeta{
Name: "pending-pod",
Namespace: "default",
Labels: map[string]string{"app": "myapp"},
},
Status: corev1.PodStatus{Phase: corev1.PodPending},
},
)
d := NewDiscovery(pool)
pods, err := d.ListPodsWithSelector(t.Context(), "test-context", "default", "app=myapp")
require.NoError(t, err)
assert.Empty(t, pods)
}
func TestDiscovery_ListServices_WithClient(t *testing.T) {
pool := setupTestPool(t, "test-context",
&corev1.Pod{
ObjectMeta: metav1.ObjectMeta{
Name: "web-pod",
Namespace: "default",
Labels: map[string]string{"app": "web"},
},
Status: corev1.PodStatus{Phase: corev1.PodRunning},
Spec: corev1.PodSpec{
Containers: []corev1.Container{
{
Name: "main",
Ports: []corev1.ContainerPort{
{Name: "http", ContainerPort: 8080},
},
},
},
},
},
&corev1.Service{
ObjectMeta: metav1.ObjectMeta{
Name: "web-svc",
Namespace: "default",
},
Spec: corev1.ServiceSpec{
Type: corev1.ServiceTypeClusterIP,
Selector: map[string]string{"app": "web"},
Ports: []corev1.ServicePort{
{Name: "http", Port: 80, TargetPort: intstr.FromString("http")},
},
},
},
&corev1.Service{
ObjectMeta: metav1.ObjectMeta{
Name: "api-svc",
Namespace: "default",
},
Spec: corev1.ServiceSpec{
Type: corev1.ServiceTypeClusterIP,
Selector: map[string]string{"app": "api"},
Ports: []corev1.ServicePort{
{Port: 8080, TargetPort: intstr.FromInt(8080)},
},
},
},
)
d := NewDiscovery(pool)
services, err := d.ListServices(t.Context(), "test-context", "default")
require.NoError(t, err)
assert.Len(t, services, 2)
// Should be sorted alphabetically
assert.Equal(t, "api-svc", services[0].Name)
assert.Equal(t, "web-svc", services[1].Name)
// Check port resolution for named port
assert.Len(t, services[1].Ports, 1)
assert.Equal(t, int32(8080), services[1].Ports[0].TargetPort) // Resolved from pod
}
func TestDiscovery_ListServices_Empty(t *testing.T) {
pool := setupTestPool(t, "test-context")
d := NewDiscovery(pool)
services, err := d.ListServices(t.Context(), "test-context", "default")
require.NoError(t, err)
assert.Empty(t, services)
}
// =============================================================================
// ResourceResolver API Tests
// =============================================================================
func TestResourceResolver_ResolvePodPrefix_WithClient(t *testing.T) {
baseTime := time.Now()
pool := setupTestPool(t, "test-context",
&corev1.Pod{
ObjectMeta: metav1.ObjectMeta{
Name: "my-app-xyz789",
Namespace: "default",
CreationTimestamp: metav1.Time{Time: baseTime},
},
Status: corev1.PodStatus{Phase: corev1.PodRunning},
},
&corev1.Pod{
ObjectMeta: metav1.ObjectMeta{
Name: "my-app-abc123",
Namespace: "default",
CreationTimestamp: metav1.Time{Time: baseTime.Add(-time.Hour)},
},
Status: corev1.PodStatus{Phase: corev1.PodRunning},
},
&corev1.Pod{
ObjectMeta: metav1.ObjectMeta{
Name: "other-app",
Namespace: "default",
},
Status: corev1.PodStatus{Phase: corev1.PodRunning},
},
)
r := NewResourceResolver(pool)
result, err := r.Resolve(t.Context(), "test-context", "default", "pod/my-app", "")
require.NoError(t, err)
// Should return newest pod matching prefix
assert.Equal(t, "pod/my-app-xyz789", result)
}
func TestResourceResolver_ResolvePodPrefix_NotFound(t *testing.T) {
pool := setupTestPool(t, "test-context",
&corev1.Pod{
ObjectMeta: metav1.ObjectMeta{
Name: "other-app",
Namespace: "default",
},
Status: corev1.PodStatus{Phase: corev1.PodRunning},
},
)
r := NewResourceResolver(pool)
_, err := r.Resolve(t.Context(), "test-context", "default", "pod/my-app", "")
assert.Error(t, err)
assert.Contains(t, err.Error(), "no running pods found matching prefix")
}
func TestResourceResolver_ResolvePodSelector_WithClient(t *testing.T) {
pool := setupTestPool(t, "test-context",
&corev1.Pod{
ObjectMeta: metav1.ObjectMeta{
Name: "app-pod",
Namespace: "default",
Labels: map[string]string{"app": "myapp"},
},
Status: corev1.PodStatus{Phase: corev1.PodRunning},
},
&corev1.Pod{
ObjectMeta: metav1.ObjectMeta{
Name: "other-pod",
Namespace: "default",
Labels: map[string]string{"app": "other"},
},
Status: corev1.PodStatus{Phase: corev1.PodRunning},
},
)
r := NewResourceResolver(pool)
result, err := r.Resolve(t.Context(), "test-context", "default", "pod", "app=myapp")
require.NoError(t, err)
assert.Equal(t, "pod/app-pod", result)
}
func TestResourceResolver_ResolvePodSelector_NotFound(t *testing.T) {
pool := setupTestPool(t, "test-context",
&corev1.Pod{
ObjectMeta: metav1.ObjectMeta{
Name: "other-pod",
Namespace: "default",
Labels: map[string]string{"app": "other"},
},
Status: corev1.PodStatus{Phase: corev1.PodRunning},
},
)
r := NewResourceResolver(pool)
_, err := r.Resolve(t.Context(), "test-context", "default", "pod", "app=myapp")
assert.Error(t, err)
assert.Contains(t, err.Error(), "no running pods found matching selector")
}
func TestResourceResolver_Resolve_Caching(t *testing.T) {
baseTime := time.Now()
pool := setupTestPool(t, "test-context",
&corev1.Pod{
ObjectMeta: metav1.ObjectMeta{
Name: "my-app-xyz789",
Namespace: "default",
CreationTimestamp: metav1.Time{Time: baseTime},
},
Status: corev1.PodStatus{Phase: corev1.PodRunning},
},
)
r := NewResourceResolver(pool)
r.SetCacheTTL(100 * time.Millisecond)
// First call - hits API
result1, err := r.Resolve(t.Context(), "test-context", "default", "pod/my-app", "")
require.NoError(t, err)
// Second call - uses cache
result2, err := r.Resolve(t.Context(), "test-context", "default", "pod/my-app", "")
require.NoError(t, err)
assert.Equal(t, result1, result2)
// Wait for expiry
time.Sleep(150 * time.Millisecond)
// Third call - hits API again
result3, err := r.Resolve(t.Context(), "test-context", "default", "pod/my-app", "")
require.NoError(t, err)
assert.Equal(t, result1, result3)
}
// =============================================================================
// PortForwarder API Tests
// =============================================================================
func TestPortForwarder_GetPodForResource_Pod(t *testing.T) {
pool := setupTestPool(t, "test-context",
&corev1.Pod{
ObjectMeta: metav1.ObjectMeta{
Name: "my-pod",
Namespace: "default",
},
Status: corev1.PodStatus{Phase: corev1.PodRunning},
},
)
r := NewResourceResolver(pool)
pf := NewPortForwarder(pool, r)
podName, err := pf.GetPodForResource(t.Context(), "test-context", "default", "pod/my-pod", "")
require.NoError(t, err)
assert.Equal(t, "my-pod", podName)
}
func TestPortForwarder_GetPodForResource_Service(t *testing.T) {
pool := setupTestPool(t, "test-context",
&corev1.Pod{
ObjectMeta: metav1.ObjectMeta{
Name: "backend-pod",
Namespace: "default",
Labels: map[string]string{"app": "backend"},
},
Status: corev1.PodStatus{Phase: corev1.PodRunning},
},
&corev1.Service{
ObjectMeta: metav1.ObjectMeta{
Name: "backend-svc",
Namespace: "default",
},
Spec: corev1.ServiceSpec{
Selector: map[string]string{"app": "backend"},
Ports: []corev1.ServicePort{
{Port: 80, TargetPort: intstr.FromInt(8080)},
},
},
},
)
r := NewResourceResolver(pool)
pf := NewPortForwarder(pool, r)
podName, err := pf.GetPodForResource(t.Context(), "test-context", "default", "service/backend-svc", "")
require.NoError(t, err)
assert.Equal(t, "backend-pod", podName)
}
func TestPortForwarder_GetPodForResource_ServiceNoSelector(t *testing.T) {
pool := setupTestPool(t, "test-context",
&corev1.Service{
ObjectMeta: metav1.ObjectMeta{
Name: "headless-svc",
Namespace: "default",
},
Spec: corev1.ServiceSpec{
// No selector
Ports: []corev1.ServicePort{
{Port: 80, TargetPort: intstr.FromInt(8080)},
},
},
},
)
r := NewResourceResolver(pool)
pf := NewPortForwarder(pool, r)
_, err := pf.GetPodForResource(t.Context(), "test-context", "default", "service/headless-svc", "")
assert.Error(t, err)
assert.Contains(t, err.Error(), "no selector")
}
func TestPortForwarder_GetPodForResource_ServiceNoRunningPods(t *testing.T) {
pool := setupTestPool(t, "test-context",
&corev1.Pod{
ObjectMeta: metav1.ObjectMeta{
Name: "pending-pod",
Namespace: "default",
Labels: map[string]string{"app": "backend"},
},
Status: corev1.PodStatus{Phase: corev1.PodPending},
},
&corev1.Service{
ObjectMeta: metav1.ObjectMeta{
Name: "backend-svc",
Namespace: "default",
},
Spec: corev1.ServiceSpec{
Selector: map[string]string{"app": "backend"},
Ports: []corev1.ServicePort{
{Port: 80, TargetPort: intstr.FromInt(8080)},
},
},
},
)
r := NewResourceResolver(pool)
pf := NewPortForwarder(pool, r)
_, err := pf.GetPodForResource(t.Context(), "test-context", "default", "service/backend-svc", "")
assert.Error(t, err)
assert.Contains(t, err.Error(), "no running pods found")
}
func TestPortForwarder_Forward_ServiceResolution(t *testing.T) {
pool := setupTestPool(t, "test-context",
&corev1.Pod{
ObjectMeta: metav1.ObjectMeta{
Name: "backend-pod",
Namespace: "default",
Labels: map[string]string{"app": "backend"},
},
Status: corev1.PodStatus{Phase: corev1.PodRunning},
},
&corev1.Service{
ObjectMeta: metav1.ObjectMeta{
Name: "backend-svc",
Namespace: "default",
},
Spec: corev1.ServiceSpec{
Selector: map[string]string{"app": "backend"},
Ports: []corev1.ServicePort{
{Port: 80, TargetPort: intstr.FromInt(8080)},
},
},
},
)
r := NewResourceResolver(pool)
pf := NewPortForwarder(pool, r)
// Test that service resolution works (Forward will fail on actual port-forward,
// but we can test the resolution part)
stopChan := make(chan struct{})
req := &ForwardRequest{
StopChan: stopChan,
ContextName: "test-context",
Namespace: "default",
Resource: "service/backend-svc",
LocalPort: 8080,
RemotePort: 80,
}
err := pf.Forward(t.Context(), req)
// Will fail on port-forward setup, but should have resolved the service
assert.Error(t, err)
// Error should not be about resource resolution
assert.NotContains(t, err.Error(), "failed to resolve resource")
}
+588
View File
@@ -0,0 +1,588 @@
package k8s
import (
"sync"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
corev1 "k8s.io/api/core/v1"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/runtime"
"k8s.io/apimachinery/pkg/util/intstr"
"k8s.io/client-go/kubernetes/fake"
)
// =============================================================================
// ForwardRequest Tests
// =============================================================================
func TestForwardRequest_Fields(t *testing.T) {
stopChan := make(chan struct{})
readyChan := make(chan struct{})
outWriter := &mockWriter{}
errWriter := &mockWriter{}
req := &ForwardRequest{
Out: outWriter,
ErrOut: errWriter,
StopChan: stopChan,
ReadyChan: readyChan,
ContextName: "test-context",
Namespace: "test-namespace",
Resource: "pod/test-pod",
Selector: "app=test",
LocalPort: 8080,
RemotePort: 80,
}
assert.Equal(t, outWriter, req.Out)
assert.Equal(t, errWriter, req.ErrOut)
assert.Equal(t, stopChan, req.StopChan)
assert.Equal(t, readyChan, req.ReadyChan)
assert.Equal(t, "test-context", req.ContextName)
assert.Equal(t, "test-namespace", req.Namespace)
assert.Equal(t, "pod/test-pod", req.Resource)
assert.Equal(t, "app=test", req.Selector)
assert.Equal(t, 8080, req.LocalPort)
assert.Equal(t, 80, req.RemotePort)
}
func TestForwardRequest_NilWriters(t *testing.T) {
stopChan := make(chan struct{})
readyChan := make(chan struct{})
req := &ForwardRequest{
Out: nil,
ErrOut: nil,
StopChan: stopChan,
ReadyChan: readyChan,
ContextName: "test-context",
Namespace: "default",
Resource: "pod/test-pod",
LocalPort: 8080,
RemotePort: 80,
}
// nil writers should be acceptable
assert.Nil(t, req.Out)
assert.Nil(t, req.ErrOut)
}
// mockWriter is a test double for io.Writer
type mockWriter struct {
written []byte
}
func (m *mockWriter) Write(p []byte) (n int, err error) {
m.written = append(m.written, p...)
return len(p), nil
}
// =============================================================================
// PortForwarder Extended Tests
// =============================================================================
func TestPortForwarder_ForwardRequestValidation(t *testing.T) {
pool, _ := NewClientPool()
r := NewResourceResolver(pool)
pf := NewPortForwarder(pool, r)
ctx := t.Context()
tests := []struct {
name string
resource string
errContains string
expectedErr bool
}{
{
name: "invalid resource format - no slash",
resource: "invalid",
expectedErr: true,
errContains: "unsupported resource type",
},
{
name: "unsupported resource type",
resource: "deployment/my-deployment",
expectedErr: true,
errContains: "unsupported resource type",
},
{
name: "empty resource",
resource: "",
expectedErr: true,
errContains: "unsupported resource type",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
stopChan := make(chan struct{})
req := &ForwardRequest{
StopChan: stopChan,
ContextName: "test-context",
Namespace: "default",
Resource: tt.resource,
LocalPort: 8080,
RemotePort: 80,
}
err := pf.Forward(ctx, req)
assert.Error(t, err)
assert.Contains(t, err.Error(), tt.errContains)
})
}
}
// =============================================================================
// Discovery Method Tests (with fake client integration)
// =============================================================================
func TestDiscovery_ListNamespaces_WithFakeClient(t *testing.T) {
objects := []runtime.Object{
createTestNamespace("default"),
createTestNamespace("kube-system"),
createTestNamespace("production"),
}
fakeClient := fake.NewClientset(objects...)
ctx := t.Context()
nsList, err := fakeClient.CoreV1().Namespaces().List(ctx, metav1.ListOptions{})
require.NoError(t, err)
namespaces := make([]string, 0, len(nsList.Items))
for _, ns := range nsList.Items {
namespaces = append(namespaces, ns.Name)
}
assert.Len(t, namespaces, 3)
assert.Contains(t, namespaces, "default")
assert.Contains(t, namespaces, "kube-system")
assert.Contains(t, namespaces, "production")
}
func TestDiscovery_ListServices_WithPorts(t *testing.T) {
objects := []runtime.Object{
createTestService("web-svc", "default", map[string]string{"app": "web"}, []corev1.ServicePort{
{Name: "http", Port: 80, TargetPort: intstr.FromInt(8080)},
{Name: "https", Port: 443, TargetPort: intstr.FromInt(8443)},
}),
createTestService("api-svc", "default", map[string]string{"app": "api"}, []corev1.ServicePort{
{Port: 8080, TargetPort: intstr.FromInt(8080)},
}),
}
fakeClient := fake.NewClientset(objects...)
ctx := t.Context()
svcList, err := fakeClient.CoreV1().Services("default").List(ctx, metav1.ListOptions{})
require.NoError(t, err)
assert.Len(t, svcList.Items, 2)
// Verify service with multiple ports
var webSvc *corev1.Service
for i := range svcList.Items {
if svcList.Items[i].Name == "web-svc" {
webSvc = &svcList.Items[i]
break
}
}
require.NotNil(t, webSvc)
assert.Len(t, webSvc.Spec.Ports, 2)
// Verify port details
foundHTTP := false
foundHTTPS := false
for _, port := range webSvc.Spec.Ports {
if port.Name == "http" {
foundHTTP = true
assert.Equal(t, int32(80), port.Port)
assert.Equal(t, int32(8080), port.TargetPort.IntVal)
}
if port.Name == "https" {
foundHTTPS = true
assert.Equal(t, int32(443), port.Port)
assert.Equal(t, int32(8443), port.TargetPort.IntVal)
}
}
assert.True(t, foundHTTP, "http port not found")
assert.True(t, foundHTTPS, "https port not found")
}
// =============================================================================
// ContainerInfo and PortInfo Tests
// =============================================================================
func TestContainerInfo_Struct(t *testing.T) {
container := ContainerInfo{
Name: "test-container",
Ports: []PortInfo{
{Name: "http", Port: 8080, Protocol: "TCP"},
{Name: "grpc", Port: 50051, Protocol: "TCP"},
},
}
assert.Equal(t, "test-container", container.Name)
assert.Len(t, container.Ports, 2)
assert.Equal(t, "http", container.Ports[0].Name)
assert.Equal(t, int32(8080), container.Ports[0].Port)
assert.Equal(t, "TCP", container.Ports[0].Protocol)
}
func TestPortInfo_Struct(t *testing.T) {
port := PortInfo{
Name: "test-port",
Protocol: "TCP",
Port: 8080,
TargetPort: 80,
}
assert.Equal(t, "test-port", port.Name)
assert.Equal(t, "TCP", port.Protocol)
assert.Equal(t, int32(8080), port.Port)
assert.Equal(t, int32(80), port.TargetPort)
}
// =============================================================================
// GetUniquePorts Edge Cases
// =============================================================================
func TestGetUniquePorts_MultipleContainers(t *testing.T) {
pods := []PodInfo{
{
Name: "pod1",
Containers: []ContainerInfo{
{
Name: "app",
Ports: []PortInfo{
{Name: "http", Port: 8080},
},
},
{
Name: "sidecar",
Ports: []PortInfo{
{Name: "metrics", Port: 9090},
},
},
},
},
}
result := GetUniquePorts(pods)
assert.Len(t, result, 2)
ports := make([]int32, len(result))
for i, p := range result {
ports[i] = p.Port
}
assert.Contains(t, ports, int32(8080))
assert.Contains(t, ports, int32(9090))
}
func TestGetUniquePorts_DuplicateAcrossPods(t *testing.T) {
pods := []PodInfo{
{
Name: "pod1",
Containers: []ContainerInfo{
{
Name: "main",
Ports: []PortInfo{
{Name: "http", Port: 8080},
},
},
},
},
{
Name: "pod2",
Containers: []ContainerInfo{
{
Name: "main",
Ports: []PortInfo{
{Name: "http", Port: 8080}, // Same port, same name
},
},
},
},
}
result := GetUniquePorts(pods)
assert.Len(t, result, 1)
assert.Equal(t, int32(8080), result[0].Port)
assert.Equal(t, "http", result[0].Name)
}
func TestGetUniquePorts_NamedVsUnnamedDuplicate(t *testing.T) {
pods := []PodInfo{
{
Name: "pod1",
Containers: []ContainerInfo{
{
Name: "main",
Ports: []PortInfo{
{Port: 8080}, // Unnamed - generates "port-8080"
},
},
},
},
{
Name: "pod2",
Containers: []ContainerInfo{
{
Name: "main",
Ports: []PortInfo{
{Name: "http", Port: 8080}, // Named - should take precedence
},
},
},
},
}
result := GetUniquePorts(pods)
assert.Len(t, result, 1)
assert.Equal(t, int32(8080), result[0].Port)
assert.Equal(t, "http", result[0].Name, "named port should take precedence over generated name")
}
// =============================================================================
// Cache Entry Tests
// =============================================================================
func TestCacheEntry_Struct(t *testing.T) {
now := time.Now()
entry := cacheEntry{
expiresAt: now.Add(30 * time.Second),
resource: ResolvedResource{
Timestamp: now,
Name: "test-pod",
Namespace: "default",
},
}
assert.Equal(t, now.Add(30*time.Second), entry.expiresAt)
assert.Equal(t, "test-pod", entry.resource.Name)
assert.Equal(t, "default", entry.resource.Namespace)
assert.Equal(t, now, entry.resource.Timestamp)
}
// =============================================================================
// ClientPool Extended Tests
// =============================================================================
func TestClientPool_ConcurrentAccess(t *testing.T) {
pool, err := NewClientPool()
require.NoError(t, err)
var wg sync.WaitGroup
// Concurrent reads and writes to cache
for i := 0; i < 20; i++ {
wg.Add(1)
go func(id int) {
defer wg.Done()
pool.ClearCache()
pool.RemoveContext("context")
_, _ = pool.GetCurrentContext()
_, _ = pool.ListContexts()
}(i)
}
wg.Wait()
// If we get here without panic, concurrent access is safe
}
func TestClientPool_MultipleContexts(t *testing.T) {
pool, err := NewClientPool()
require.NoError(t, err)
// Test that multiple contexts can be tracked
pool.mu.Lock()
pool.clients["context1"] = nil
pool.clients["context2"] = nil
pool.clients["context3"] = nil
pool.mu.Unlock()
// Remove one context
pool.RemoveContext("context2")
// Verify context2 is removed
pool.mu.RLock()
_, exists1 := pool.clients["context1"]
_, exists2 := pool.clients["context2"]
_, exists3 := pool.clients["context3"]
pool.mu.RUnlock()
assert.True(t, exists1)
assert.False(t, exists2)
assert.True(t, exists3)
// Clear all
pool.ClearCache()
pool.mu.RLock()
assert.Equal(t, 0, len(pool.clients))
pool.mu.RUnlock()
}
// =============================================================================
// ResourceResolver Resolve Tests (using internal methods)
// =============================================================================
func TestResourceResolver_Resolve_InvalidFormat(t *testing.T) {
pool, _ := NewClientPool()
r := NewResourceResolver(pool)
ctx := t.Context()
tests := []struct {
name string
resource string
selector string
errContains string
}{
{
name: "unsupported resource type",
resource: "configmap/my-config",
selector: "",
errContains: "unsupported resource type",
},
{
name: "pod without prefix or selector",
resource: "pod",
selector: "",
errContains: "pod resource requires either a name prefix",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
_, err := r.Resolve(ctx, "test-context", "default", tt.resource, tt.selector)
assert.Error(t, err)
assert.Contains(t, err.Error(), tt.errContains)
})
}
}
func TestResourceResolver_Resolve_ServiceVariations(t *testing.T) {
pool, _ := NewClientPool()
r := NewResourceResolver(pool)
ctx := t.Context()
tests := []struct {
name string
resource string
expected string
}{
{
name: "simple service",
resource: "service/my-service",
expected: "service/my-service",
},
{
name: "service with namespace in name",
resource: "service/my-service.namespace",
expected: "service/my-service.namespace",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result, err := r.Resolve(ctx, "test-context", "default", tt.resource, "")
require.NoError(t, err)
assert.Equal(t, tt.expected, result)
})
}
}
// =============================================================================
// resolveTargetPort Extended Tests
// =============================================================================
func TestResolveTargetPort_EdgeCases(t *testing.T) {
tests := []struct {
name string
service *corev1.Service
servicePort corev1.ServicePort
pods []corev1.Pod
expected int32
}{
{
name: "zero value targetPort returns service port",
service: &corev1.Service{
ObjectMeta: metav1.ObjectMeta{Name: "svc", Namespace: "default"},
Spec: corev1.ServiceSpec{
Selector: map[string]string{"app": "test"},
Ports: []corev1.ServicePort{{Port: 80}},
},
},
servicePort: corev1.ServicePort{
Port: 80,
// TargetPort is zero value
},
pods: nil,
expected: 80,
},
{
name: "empty named port returns service port",
service: &corev1.Service{
ObjectMeta: metav1.ObjectMeta{Name: "svc", Namespace: "default"},
Spec: corev1.ServiceSpec{
Selector: map[string]string{"app": "test"},
},
},
servicePort: corev1.ServicePort{
Port: 80,
TargetPort: intstr.FromString(""), // Empty string
},
pods: nil,
expected: 80,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
var objects []runtime.Object
for i := range tt.pods {
objects = append(objects, &tt.pods[i])
}
fakeClient := fake.NewClientset(objects...)
d := &Discovery{}
result := d.resolveTargetPort(t.Context(), fakeClient, "default", tt.service, &tt.servicePort)
assert.Equal(t, tt.expected, result)
})
}
}
// =============================================================================
// PortForwarder Settings Tests
// =============================================================================
func TestPortForwarder_DefaultSettings(t *testing.T) {
pool, _ := NewClientPool()
r := NewResourceResolver(pool)
pf := NewPortForwarder(pool, r)
// Verify defaults are set
assert.NotZero(t, pf.tcpKeepalive)
assert.NotZero(t, pf.dialTimeout)
}
func TestPortForwarder_SettingsChain(t *testing.T) {
pool, _ := NewClientPool()
r := NewResourceResolver(pool)
pf := NewPortForwarder(pool, r)
// Chain multiple settings
pf.SetTCPKeepalive(60 * time.Second)
pf.SetDialTimeout(45 * time.Second)
pf.SetTCPKeepalive(30 * time.Second) // Override
assert.Equal(t, 30*time.Second, pf.tcpKeepalive)
assert.Equal(t, 45*time.Second, pf.dialTimeout)
}
+932
View File
@@ -0,0 +1,932 @@
package k8s
import (
"context"
"net"
"sync"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
corev1 "k8s.io/api/core/v1"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/runtime"
"k8s.io/apimachinery/pkg/util/intstr"
"k8s.io/client-go/kubernetes/fake"
)
// =============================================================================
// Test Helpers
// =============================================================================
func createTestPod(name, namespace string, labels map[string]string, phase corev1.PodPhase, creationTime time.Time) *corev1.Pod {
return &corev1.Pod{
ObjectMeta: metav1.ObjectMeta{
Name: name,
Namespace: namespace,
Labels: labels,
CreationTimestamp: metav1.Time{Time: creationTime},
},
Status: corev1.PodStatus{
Phase: phase,
},
Spec: corev1.PodSpec{
Containers: []corev1.Container{
{
Name: "main",
Ports: []corev1.ContainerPort{
{Name: "http", ContainerPort: 8080},
{Name: "metrics", ContainerPort: 9090},
},
},
},
},
}
}
func createTestService(name, namespace string, selector map[string]string, ports []corev1.ServicePort) *corev1.Service {
return &corev1.Service{
ObjectMeta: metav1.ObjectMeta{
Name: name,
Namespace: namespace,
},
Spec: corev1.ServiceSpec{
Selector: selector,
Ports: ports,
Type: corev1.ServiceTypeClusterIP,
},
}
}
func createTestNamespace(name string) *corev1.Namespace {
return &corev1.Namespace{
ObjectMeta: metav1.ObjectMeta{
Name: name,
},
}
}
// =============================================================================
// Discovery Tests
// =============================================================================
func TestNewDiscovery(t *testing.T) {
pool, err := NewClientPool()
require.NoError(t, err)
d := NewDiscovery(pool)
assert.NotNil(t, d)
assert.Equal(t, pool, d.pool)
}
func TestDiscovery_ListNamespaces(t *testing.T) {
tests := []struct {
name string
errContains string
objects []runtime.Object
expectedNS []string
expectedErr bool
}{
{
name: "successful namespace listing",
objects: []runtime.Object{
createTestNamespace("default"),
createTestNamespace("kube-system"),
createTestNamespace("production"),
},
expectedNS: []string{"default", "kube-system", "production"},
},
{
name: "empty namespace list",
objects: []runtime.Object{},
expectedNS: []string{},
},
{
name: "single namespace",
objects: []runtime.Object{createTestNamespace("default")},
expectedNS: []string{"default"},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
fakeClient := fake.NewClientset(tt.objects...)
// Directly test with fake client
ctx := context.Background()
nsList, err := fakeClient.CoreV1().Namespaces().List(ctx, metav1.ListOptions{})
require.NoError(t, err)
namespaces := make([]string, 0, len(nsList.Items))
for _, ns := range nsList.Items {
namespaces = append(namespaces, ns.Name)
}
assert.Equal(t, tt.expectedNS, namespaces)
})
}
}
func TestDiscovery_ListPods(t *testing.T) {
baseTime := time.Now()
tests := []struct {
validateFn func(t *testing.T, pods *corev1.PodList)
name string
objects []runtime.Object
expectedLen int
}{
{
name: "list all pods in namespace",
objects: []runtime.Object{
createTestPod("running-pod", "default", nil, corev1.PodRunning, baseTime),
createTestPod("pending-pod", "default", nil, corev1.PodPending, baseTime.Add(-time.Hour)),
createTestPod("succeeded-pod", "default", nil, corev1.PodSucceeded, baseTime),
},
expectedLen: 3,
validateFn: func(t *testing.T, pods *corev1.PodList) {
// Verify all pods are returned
names := make([]string, len(pods.Items))
for i, p := range pods.Items {
names[i] = p.Name
}
assert.Contains(t, names, "running-pod")
assert.Contains(t, names, "pending-pod")
assert.Contains(t, names, "succeeded-pod")
},
},
{
name: "empty pod list",
objects: []runtime.Object{},
expectedLen: 0,
},
{
name: "pods in different namespaces",
objects: []runtime.Object{
createTestPod("pod-default", "default", nil, corev1.PodRunning, baseTime),
createTestPod("pod-kube-system", "kube-system", nil, corev1.PodRunning, baseTime),
},
expectedLen: 1,
validateFn: func(t *testing.T, pods *corev1.PodList) {
assert.Equal(t, "default", pods.Items[0].Namespace)
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
fakeClient := fake.NewClientset(tt.objects...)
ctx := context.Background()
var listOpts metav1.ListOptions
// List pods in the default namespace (test name indicates filtering intent)
pods, err := fakeClient.CoreV1().Pods("default").List(ctx, listOpts)
require.NoError(t, err)
assert.Len(t, pods.Items, tt.expectedLen)
if tt.validateFn != nil {
tt.validateFn(t, pods)
}
})
}
}
func TestDiscovery_ListPodsWithSelector(t *testing.T) {
baseTime := time.Now()
tests := []struct {
validateFn func(t *testing.T, pods *corev1.PodList)
name string
selector string
objects []runtime.Object
expectedLen int
}{
{
name: "match pods by label selector",
objects: []runtime.Object{
createTestPod("app1-pod", "default", map[string]string{"app": "myapp"}, corev1.PodRunning, baseTime),
createTestPod("app2-pod", "default", map[string]string{"app": "myapp"}, corev1.PodRunning, baseTime.Add(-time.Hour)),
createTestPod("other-pod", "default", map[string]string{"app": "other"}, corev1.PodRunning, baseTime),
},
selector: "app=myapp",
expectedLen: 2,
validateFn: func(t *testing.T, pods *corev1.PodList) {
names := make([]string, len(pods.Items))
for i, p := range pods.Items {
names[i] = p.Name
}
assert.Contains(t, names, "app1-pod")
assert.Contains(t, names, "app2-pod")
},
},
{
name: "only running pods returned",
objects: []runtime.Object{
createTestPod("running-pod", "default", map[string]string{"app": "test"}, corev1.PodRunning, baseTime),
createTestPod("pending-pod", "default", map[string]string{"app": "test"}, corev1.PodPending, baseTime),
},
selector: "app=test",
expectedLen: 2, // Fake client returns all, filtering is done in ListPodsWithSelector
},
{
name: "no matching pods",
objects: []runtime.Object{},
selector: "app=nonexistent",
expectedLen: 0,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
fakeClient := fake.NewClientset(tt.objects...)
ctx := context.Background()
pods, err := fakeClient.CoreV1().Pods("default").List(ctx, metav1.ListOptions{
LabelSelector: tt.selector,
})
require.NoError(t, err)
assert.Len(t, pods.Items, tt.expectedLen)
if tt.validateFn != nil {
tt.validateFn(t, pods)
}
})
}
}
func TestDiscovery_ListServices(t *testing.T) {
tests := []struct {
validateFn func(t *testing.T, services *corev1.ServiceList)
name string
objects []runtime.Object
expectedLen int
}{
{
name: "list services",
objects: []runtime.Object{
createTestService("svc1", "default", map[string]string{"app": "test"}, []corev1.ServicePort{
{Port: 80, TargetPort: intstr.FromInt(8080)},
}),
createTestService("svc2", "default", map[string]string{"app": "other"}, []corev1.ServicePort{
{Port: 443, TargetPort: intstr.FromInt(8443)},
}),
},
expectedLen: 2,
validateFn: func(t *testing.T, services *corev1.ServiceList) {
names := make([]string, len(services.Items))
for i, s := range services.Items {
names[i] = s.Name
}
assert.Contains(t, names, "svc1")
assert.Contains(t, names, "svc2")
},
},
{
name: "empty service list",
objects: []runtime.Object{},
expectedLen: 0,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
fakeClient := fake.NewClientset(tt.objects...)
ctx := context.Background()
services, err := fakeClient.CoreV1().Services("default").List(ctx, metav1.ListOptions{})
require.NoError(t, err)
assert.Len(t, services.Items, tt.expectedLen)
if tt.validateFn != nil {
tt.validateFn(t, services)
}
})
}
}
// =============================================================================
// CheckPortAvailability Tests
// =============================================================================
func TestCheckPortAvailability(t *testing.T) {
tests := []struct {
name string
expectedErrMsg string
port int
expectedAvail bool
expectedErr bool
}{
{
name: "port 0 is invalid",
port: 0,
expectedAvail: false,
expectedErr: true,
expectedErrMsg: "invalid port",
},
{
name: "negative port is invalid",
port: -1,
expectedAvail: false,
expectedErr: true,
expectedErrMsg: "invalid port",
},
{
name: "port too high is invalid",
port: 65536,
expectedAvail: false,
expectedErr: true,
expectedErrMsg: "invalid port",
},
{
name: "valid high port should be available",
port: 65535,
expectedAvail: true,
expectedErr: false,
expectedErrMsg: "",
},
{
name: "common high port should be available",
port: 8080,
expectedAvail: true,
expectedErr: false,
expectedErrMsg: "",
},
{
name: "lowest valid port",
port: 1,
expectedAvail: true,
expectedErr: false,
expectedErrMsg: "",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
available, processInfo, err := CheckPortAvailability(tt.port)
if tt.expectedErr {
assert.False(t, available)
assert.Error(t, err)
assert.Empty(t, processInfo)
assert.Contains(t, err.Error(), tt.expectedErrMsg)
return
}
// For valid ports, we can only reliably test that no error occurs
// Port might be in use by system or other tests
require.NoError(t, err)
if available {
assert.Empty(t, processInfo)
}
})
}
}
func TestCheckPortAvailability_PortInUse(t *testing.T) {
// Start a listener on a specific port on all interfaces
// #nosec G102 - Binding to all interfaces is intentional for this test
listener, err := net.Listen("tcp", ":0")
require.NoError(t, err)
defer func() {
_ = listener.Close() // Error ignored - best effort cleanup
}()
// Get the port that was assigned
port := listener.Addr().(*net.TCPAddr).Port
// Check that the port is reported as in use
available, processInfo, err := CheckPortAvailability(port)
require.NoError(t, err)
assert.False(t, available)
assert.NotEmpty(t, processInfo)
}
// =============================================================================
// ResourceResolver Tests
// =============================================================================
func TestNewResourceResolver(t *testing.T) {
pool, err := NewClientPool()
require.NoError(t, err)
r := NewResourceResolver(pool)
assert.NotNil(t, r)
assert.Equal(t, pool, r.clientPool)
assert.NotNil(t, r.cache)
assert.Equal(t, defaultCacheTTL, r.cacheTTL)
}
func TestResourceResolver_SetCacheTTL(t *testing.T) {
pool, err := NewClientPool()
require.NoError(t, err)
r := NewResourceResolver(pool)
newTTL := 5 * time.Minute
r.SetCacheTTL(newTTL)
assert.Equal(t, newTTL, r.cacheTTL)
}
func TestResourceResolver_Resolve_Service(t *testing.T) {
pool, err := NewClientPool()
require.NoError(t, err)
r := NewResourceResolver(pool)
tests := []struct {
name string
resource string
expected string
errContains string
expectedErr bool
}{
{
name: "valid service resource",
resource: "service/my-service",
expected: "service/my-service",
},
{
// Note: "service/" returns the resource as-is (current behavior)
name: "service with empty name part",
resource: "service/",
expected: "service/",
},
{
name: "service without slash returns error",
resource: "service",
expectedErr: true,
errContains: "invalid service resource format",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
ctx := context.Background()
result, err := r.Resolve(ctx, "test-context", "default", tt.resource, "")
if tt.expectedErr {
assert.Error(t, err)
if tt.errContains != "" {
assert.Contains(t, err.Error(), tt.errContains)
}
return
}
require.NoError(t, err)
assert.Equal(t, tt.expected, result)
})
}
}
func TestResourceResolver_Resolve_UnsupportedType(t *testing.T) {
pool, err := NewClientPool()
require.NoError(t, err)
r := NewResourceResolver(pool)
ctx := context.Background()
result, err := r.Resolve(ctx, "test-context", "default", "deployment/my-deploy", "")
assert.Error(t, err)
assert.Contains(t, err.Error(), "unsupported resource type")
assert.Empty(t, result)
}
func TestResourceResolver_Resolve_PodWithoutPrefixOrSelector(t *testing.T) {
pool, err := NewClientPool()
require.NoError(t, err)
r := NewResourceResolver(pool)
ctx := context.Background()
result, err := r.Resolve(ctx, "test-context", "default", "pod", "")
assert.Error(t, err)
assert.Contains(t, err.Error(), "pod resource requires either a name prefix")
assert.Empty(t, result)
}
func TestResourceResolver_Cache_Operations(t *testing.T) {
pool, err := NewClientPool()
require.NoError(t, err)
r := NewResourceResolver(pool)
// Test putInCache and getFromCache
key := "test-context/default/pod/test"
value := "test-pod-123"
// Initially empty
result := r.getFromCache(key)
assert.Empty(t, result)
// Put in cache
r.putInCache(key, value)
// Should be retrievable
result = r.getFromCache(key)
assert.Equal(t, value, result)
}
func TestResourceResolver_Cache_Expiry(t *testing.T) {
pool, err := NewClientPool()
require.NoError(t, err)
r := NewResourceResolver(pool)
// Set very short TTL
r.SetCacheTTL(50 * time.Millisecond)
key := "test-context/default/pod/test"
value := "test-pod-123"
// Put in cache
r.putInCache(key, value)
// Should be immediately retrievable
result := r.getFromCache(key)
assert.Equal(t, value, result)
// Wait for expiry
time.Sleep(100 * time.Millisecond)
// Should be expired
result = r.getFromCache(key)
assert.Empty(t, result)
// Cache entry should be cleaned up
r.cacheMu.RLock()
_, exists := r.cache[key]
r.cacheMu.RUnlock()
assert.False(t, exists)
}
func TestResourceResolver_Cache_ConcurrentAccess(t *testing.T) {
pool, err := NewClientPool()
require.NoError(t, err)
r := NewResourceResolver(pool)
var wg sync.WaitGroup
for i := 0; i < 10; i++ {
wg.Add(1)
go func(id int) {
defer wg.Done()
key := "key"
value := "value"
r.putInCache(key, value)
_ = r.getFromCache(key)
}(i)
}
wg.Wait()
// Verify no race conditions occurred
assert.NotNil(t, r.cache)
}
func TestResourceResolver_ClearCache(t *testing.T) {
pool, err := NewClientPool()
require.NoError(t, err)
r := NewResourceResolver(pool)
// Populate cache
r.putInCache("key1", "value1")
r.putInCache("key2", "value2")
// Verify cache has entries
r.cacheMu.RLock()
assert.Greater(t, len(r.cache), 0)
r.cacheMu.RUnlock()
// Clear cache
r.ClearCache()
// Verify cache is empty
r.cacheMu.RLock()
assert.Equal(t, 0, len(r.cache))
r.cacheMu.RUnlock()
}
func TestResourceResolver_InvalidateCache(t *testing.T) {
pool, err := NewClientPool()
require.NoError(t, err)
r := NewResourceResolver(pool)
// Populate cache with multiple entries in same namespace
r.putInCache("test-context/default/pod/app1", "pod1")
r.putInCache("test-context/default/pod/app2", "pod2")
r.putInCache("test-context/other/pod/app1", "pod3")
// Invalidate for specific namespace
r.InvalidateCache("test-context", "default", "pod/app1")
// All entries for that namespace should be cleared
r.cacheMu.RLock()
_, exists1 := r.cache["test-context/default/pod/app1"]
_, exists2 := r.cache["test-context/default/pod/app2"]
_, exists3 := r.cache["test-context/other/pod/app1"]
r.cacheMu.RUnlock()
assert.False(t, exists1)
assert.False(t, exists2)
assert.True(t, exists3, "other namespace should not be affected")
}
// =============================================================================
// PortForwarder Tests
// =============================================================================
func TestNewPortForwarder(t *testing.T) {
pool, err := NewClientPool()
require.NoError(t, err)
r := NewResourceResolver(pool)
pf := NewPortForwarder(pool, r)
assert.NotNil(t, pf)
assert.Equal(t, pool, pf.clientPool)
assert.Equal(t, r, pf.resolver)
assert.NotZero(t, pf.tcpKeepalive)
assert.NotZero(t, pf.dialTimeout)
}
func TestPortForwarder_SetTCPKeepalive(t *testing.T) {
pool, err := NewClientPool()
require.NoError(t, err)
r := NewResourceResolver(pool)
pf := NewPortForwarder(pool, r)
newKeepalive := 60 * time.Second
pf.SetTCPKeepalive(newKeepalive)
assert.Equal(t, newKeepalive, pf.tcpKeepalive)
}
func TestPortForwarder_SetDialTimeout(t *testing.T) {
pool, err := NewClientPool()
require.NoError(t, err)
r := NewResourceResolver(pool)
pf := NewPortForwarder(pool, r)
newTimeout := 45 * time.Second
pf.SetDialTimeout(newTimeout)
assert.Equal(t, newTimeout, pf.dialTimeout)
}
func TestPortForwarder_Forward_InvalidResource(t *testing.T) {
pool, err := NewClientPool()
require.NoError(t, err)
r := NewResourceResolver(pool)
pf := NewPortForwarder(pool, r)
ctx := context.Background()
req := &ForwardRequest{
ContextName: "test-context",
Namespace: "default",
Resource: "invalid-resource",
}
err = pf.Forward(ctx, req)
assert.Error(t, err)
assert.Contains(t, err.Error(), "unsupported resource type")
}
func TestForwardRequest_Struct(t *testing.T) {
// Test that ForwardRequest struct fields are correctly accessible
stopChan := make(chan struct{})
readyChan := make(chan struct{})
req := &ForwardRequest{
Out: nil,
ErrOut: nil,
StopChan: stopChan,
ReadyChan: readyChan,
ContextName: "test-context",
Namespace: "default",
Resource: "pod/my-pod",
Selector: "",
LocalPort: 8080,
RemotePort: 80,
}
assert.Equal(t, "test-context", req.ContextName)
assert.Equal(t, "default", req.Namespace)
assert.Equal(t, "pod/my-pod", req.Resource)
assert.Equal(t, 8080, req.LocalPort)
assert.Equal(t, 80, req.RemotePort)
assert.Equal(t, stopChan, req.StopChan)
assert.Equal(t, readyChan, req.ReadyChan)
}
// =============================================================================
// PodInfo and ServiceInfo Tests
// =============================================================================
func TestPodInfo_Struct(t *testing.T) {
now := time.Now()
podInfo := PodInfo{
Created: metav1.Time{Time: now},
Name: "test-pod",
Namespace: "default",
Status: "Running",
Containers: []ContainerInfo{
{
Name: "main",
Ports: []PortInfo{
{Name: "http", Port: 8080, Protocol: "TCP"},
},
},
},
}
assert.Equal(t, "test-pod", podInfo.Name)
assert.Equal(t, "default", podInfo.Namespace)
assert.Equal(t, "Running", podInfo.Status)
assert.Len(t, podInfo.Containers, 1)
assert.Equal(t, "main", podInfo.Containers[0].Name)
assert.Equal(t, int32(8080), podInfo.Containers[0].Ports[0].Port)
}
func TestServiceInfo_Struct(t *testing.T) {
svcInfo := ServiceInfo{
Name: "test-svc",
Namespace: "default",
Type: "ClusterIP",
Ports: []PortInfo{
{Name: "http", Port: 80, TargetPort: 8080, Protocol: "TCP"},
},
}
assert.Equal(t, "test-svc", svcInfo.Name)
assert.Equal(t, "default", svcInfo.Namespace)
assert.Equal(t, "ClusterIP", svcInfo.Type)
assert.Len(t, svcInfo.Ports, 1)
assert.Equal(t, int32(80), svcInfo.Ports[0].Port)
assert.Equal(t, int32(8080), svcInfo.Ports[0].TargetPort)
}
// =============================================================================
// ResolvedResource Tests
// =============================================================================
func TestResolvedResource_Struct(t *testing.T) {
now := time.Now()
resource := ResolvedResource{
Timestamp: now,
Name: "my-pod",
Namespace: "default",
}
assert.Equal(t, "my-pod", resource.Name)
assert.Equal(t, "default", resource.Namespace)
assert.Equal(t, now, resource.Timestamp)
}
// =============================================================================
// GetUniquePorts Additional Tests
// =============================================================================
func TestGetUniquePorts_EmptyInput(t *testing.T) {
result := GetUniquePorts([]PodInfo{})
assert.Empty(t, result)
}
func TestGetUniquePorts_SinglePod(t *testing.T) {
pods := []PodInfo{
{
Name: "single-pod",
Containers: []ContainerInfo{
{
Name: "main",
Ports: []PortInfo{
{Name: "http", Port: 8080},
},
},
},
},
}
result := GetUniquePorts(pods)
assert.Len(t, result, 1)
assert.Equal(t, int32(8080), result[0].Port)
assert.Equal(t, "http", result[0].Name)
}
func TestGetUniquePorts_NoNamedPorts(t *testing.T) {
pods := []PodInfo{
{
Name: "pod1",
Containers: []ContainerInfo{
{
Name: "main",
Ports: []PortInfo{
{Port: 8080}, // No name
},
},
},
},
}
result := GetUniquePorts(pods)
assert.Len(t, result, 1)
assert.Equal(t, int32(8080), result[0].Port)
assert.Equal(t, "port-8080", result[0].Name)
}
func TestGetUniquePorts_PreferNamedOverGenerated(t *testing.T) {
pods := []PodInfo{
{
Name: "pod1",
Containers: []ContainerInfo{
{
Name: "main",
Ports: []PortInfo{
{Port: 8080}, // No name, generates "port-8080"
},
},
},
},
{
Name: "pod2",
Containers: []ContainerInfo{
{
Name: "main",
Ports: []PortInfo{
{Name: "http", Port: 8080}, // Named port
},
},
},
},
}
result := GetUniquePorts(pods)
assert.Len(t, result, 1)
assert.Equal(t, int32(8080), result[0].Port)
assert.Equal(t, "http", result[0].Name, "named port should take precedence")
}
func TestGetUniquePorts_SortedByPortNumber(t *testing.T) {
pods := []PodInfo{
{
Name: "pod1",
Containers: []ContainerInfo{
{
Name: "main",
Ports: []PortInfo{
{Name: "high", Port: 9000},
{Name: "low", Port: 80},
{Name: "mid", Port: 8080},
},
},
},
},
}
result := GetUniquePorts(pods)
assert.Len(t, result, 3)
assert.Equal(t, int32(80), result[0].Port)
assert.Equal(t, int32(8080), result[1].Port)
assert.Equal(t, int32(9000), result[2].Port)
}
// =============================================================================
// Discovery Context Operations Tests
// =============================================================================
func TestDiscovery_ListContexts(t *testing.T) {
pool, err := NewClientPool()
require.NoError(t, err)
d := NewDiscovery(pool)
// This will either succeed or fail based on kubeconfig availability
contexts, err := d.ListContexts()
if err != nil {
// Expected if no kubeconfig
assert.Contains(t, err.Error(), "kubeconfig")
} else {
// If successful, should be a slice
assert.NotNil(t, contexts)
}
}
func TestDiscovery_GetCurrentContext(t *testing.T) {
pool, err := NewClientPool()
require.NoError(t, err)
d := NewDiscovery(pool)
// This will either succeed or fail based on kubeconfig availability
context, err := d.GetCurrentContext()
if err != nil {
// Expected if no kubeconfig
assert.Contains(t, err.Error(), "kubeconfig")
} else {
// If successful, should be a string
assert.NotEmpty(t, context)
}
}
+1 -1
View File
@@ -10,7 +10,7 @@ import (
"strings"
"time"
"github.com/nvm/kportal/internal/config"
"github.com/lukaszraczylo/kportal/internal/config"
corev1 "k8s.io/api/core/v1"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+343
View File
@@ -0,0 +1,343 @@
package k8s
import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
corev1 "k8s.io/api/core/v1"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
)
// =============================================================================
// PortForwarder Extended Tests
// =============================================================================
func TestPortForwarder_Forward_ServiceResolutionError(t *testing.T) {
// Create pool without any pods/services
pool := setupTestPool(t, "test-context")
r := NewResourceResolver(pool)
pf := NewPortForwarder(pool, r)
stopChan := make(chan struct{})
req := &ForwardRequest{
StopChan: stopChan,
ContextName: "test-context",
Namespace: "default",
Resource: "service/nonexistent-svc",
LocalPort: 8080,
RemotePort: 80,
}
err := pf.Forward(t.Context(), req)
assert.Error(t, err)
// Should fail trying to get the service
assert.Contains(t, err.Error(), "failed to get service")
}
func TestPortForwarder_Forward_PodNotRunning(t *testing.T) {
pool := setupTestPool(t, "test-context",
&corev1.Pod{
ObjectMeta: metav1.ObjectMeta{
Name: "pending-pod",
Namespace: "default",
},
Status: corev1.PodStatus{Phase: corev1.PodPending},
},
)
r := NewResourceResolver(pool)
pf := NewPortForwarder(pool, r)
stopChan := make(chan struct{})
req := &ForwardRequest{
StopChan: stopChan,
ContextName: "test-context",
Namespace: "default",
Resource: "pod/pending-pod",
LocalPort: 8080,
RemotePort: 80,
}
err := pf.Forward(t.Context(), req)
assert.Error(t, err)
// Since pod is not running, it won't be found during resolution
assert.Contains(t, err.Error(), "no running pods found")
}
func TestPortForwarder_Forward_PodPhaseCheck(t *testing.T) {
// Create a running pod for resolution
pool := setupTestPool(t, "test-context",
&corev1.Pod{
ObjectMeta: metav1.ObjectMeta{
Name: "test-pod",
Namespace: "default",
},
Status: corev1.PodStatus{Phase: corev1.PodRunning},
},
)
r := NewResourceResolver(pool)
pf := NewPortForwarder(pool, r)
stopChan := make(chan struct{})
req := &ForwardRequest{
StopChan: stopChan,
ContextName: "test-context",
Namespace: "default",
Resource: "pod/test-pod",
LocalPort: 8080,
RemotePort: 80,
}
err := pf.Forward(t.Context(), req)
// Will fail on port-forward since we can't actually forward
// but the pod phase check should have passed
assert.Error(t, err)
// Error should not be about pod not running
assert.NotContains(t, err.Error(), "pod is not running")
}
func TestPortForwarder_Forward_UnsupportedResourceType(t *testing.T) {
pool := setupTestPool(t, "test-context")
r := NewResourceResolver(pool)
pf := NewPortForwarder(pool, r)
stopChan := make(chan struct{})
req := &ForwardRequest{
StopChan: stopChan,
ContextName: "test-context",
Namespace: "default",
Resource: "deployment/my-deploy",
LocalPort: 8080,
RemotePort: 80,
}
err := pf.Forward(t.Context(), req)
assert.Error(t, err)
assert.Contains(t, err.Error(), "unsupported resource type")
}
func TestPortForwarder_Forward_GetClientError(t *testing.T) {
// Create pool without setting test client
pool, _ := NewClientPool()
r := NewResourceResolver(pool)
pf := NewPortForwarder(pool, r)
stopChan := make(chan struct{})
req := &ForwardRequest{
StopChan: stopChan,
ContextName: "non-existent-context",
Namespace: "default",
Resource: "service/my-service",
LocalPort: 8080,
RemotePort: 80,
}
err := pf.Forward(t.Context(), req)
assert.Error(t, err)
// Will fail trying to get client (via resolver)
assert.Contains(t, err.Error(), "failed to get client")
}
func TestPortForwarder_GetPodForResource_ServiceNotFound(t *testing.T) {
pool := setupTestPool(t, "test-context")
r := NewResourceResolver(pool)
pf := NewPortForwarder(pool, r)
_, err := pf.GetPodForResource(t.Context(), "test-context", "default", "service/nonexistent", "")
assert.Error(t, err)
assert.Contains(t, err.Error(), "failed to get service")
}
func TestPortForwarder_GetPodForResource_UnsupportedType(t *testing.T) {
pool := setupTestPool(t, "test-context")
r := NewResourceResolver(pool)
pf := NewPortForwarder(pool, r)
_, err := pf.GetPodForResource(t.Context(), "test-context", "default", "deployment/my-deploy", "")
assert.Error(t, err)
assert.Contains(t, err.Error(), "unsupported resource type")
}
func TestPortForwarder_GetPodForResource_DirectPod(t *testing.T) {
pool := setupTestPool(t, "test-context",
&corev1.Pod{
ObjectMeta: metav1.ObjectMeta{
Name: "test-pod",
Namespace: "default",
},
Status: corev1.PodStatus{Phase: corev1.PodRunning},
},
)
r := NewResourceResolver(pool)
pf := NewPortForwarder(pool, r)
// For pod resources, GetPodForResource returns the pod name directly
podName, err := pf.GetPodForResource(t.Context(), "test-context", "default", "pod/test-pod", "")
require.NoError(t, err)
assert.Equal(t, "test-pod", podName)
}
func TestPortForwarder_ForwardRequest_DefaultChannels(t *testing.T) {
// Test that ForwardRequest can be created without channels
req := &ForwardRequest{
ContextName: "test-context",
Namespace: "default",
Resource: "pod/my-pod",
LocalPort: 8080,
RemotePort: 80,
// StopChan and ReadyChan not set
}
assert.Nil(t, req.StopChan)
assert.Nil(t, req.ReadyChan)
assert.Nil(t, req.Out)
assert.Nil(t, req.ErrOut)
}
func TestPortForwarder_Settings(t *testing.T) {
pool := setupTestPool(t, "test-context")
r := NewResourceResolver(pool)
pf := NewPortForwarder(pool, r)
// Test TCP keepalive setting
pf.SetTCPKeepalive(30 * 1000000000) // 30 seconds in nanoseconds
// Test dial timeout setting
pf.SetDialTimeout(10 * 1000000000) // 10 seconds in nanoseconds
// Just verify they don't panic
assert.NotNil(t, pf)
}
func TestPortForwarder_Forward_GetPodError(t *testing.T) {
pool := setupTestPool(t, "test-context")
r := NewResourceResolver(pool)
pf := NewPortForwarder(pool, r)
stopChan := make(chan struct{})
req := &ForwardRequest{
StopChan: stopChan,
ContextName: "test-context",
Namespace: "default",
Resource: "pod/nonexistent-prefix-xyz",
LocalPort: 8080,
RemotePort: 80,
}
err := pf.Forward(t.Context(), req)
assert.Error(t, err)
assert.Contains(t, err.Error(), "failed to resolve resource")
}
func TestPortForwarder_ForwardToService_NoRunningPods(t *testing.T) {
pool := setupTestPool(t, "test-context",
&corev1.Pod{
ObjectMeta: metav1.ObjectMeta{
Name: "pending-pod",
Namespace: "default",
Labels: map[string]string{"app": "backend"},
},
Status: corev1.PodStatus{Phase: corev1.PodPending},
},
&corev1.Service{
ObjectMeta: metav1.ObjectMeta{
Name: "backend-svc",
Namespace: "default",
},
Spec: corev1.ServiceSpec{
Selector: map[string]string{"app": "backend"},
Ports: []corev1.ServicePort{
{Port: 80},
},
},
},
)
r := NewResourceResolver(pool)
pf := NewPortForwarder(pool, r)
stopChan := make(chan struct{})
req := &ForwardRequest{
StopChan: stopChan,
ContextName: "test-context",
Namespace: "default",
Resource: "service/backend-svc",
LocalPort: 8080,
RemotePort: 80,
}
err := pf.Forward(t.Context(), req)
assert.Error(t, err)
assert.Contains(t, err.Error(), "no running pods found for service")
}
func TestPortForwarder_GetPodForResource_ServiceWithRunningPod(t *testing.T) {
pool := setupTestPool(t, "test-context",
&corev1.Pod{
ObjectMeta: metav1.ObjectMeta{
Name: "running-pod",
Namespace: "default",
Labels: map[string]string{"app": "backend"},
},
Status: corev1.PodStatus{Phase: corev1.PodRunning},
},
&corev1.Service{
ObjectMeta: metav1.ObjectMeta{
Name: "backend-svc",
Namespace: "default",
},
Spec: corev1.ServiceSpec{
Selector: map[string]string{"app": "backend"},
Ports: []corev1.ServicePort{
{Port: 80},
},
},
},
)
r := NewResourceResolver(pool)
pf := NewPortForwarder(pool, r)
podName, err := pf.GetPodForResource(t.Context(), "test-context", "default", "service/backend-svc", "")
require.NoError(t, err)
assert.Equal(t, "running-pod", podName)
}
func TestPortForwarder_GetPodForResource_ServicePendingPod(t *testing.T) {
pool := setupTestPool(t, "test-context",
&corev1.Pod{
ObjectMeta: metav1.ObjectMeta{
Name: "pending-pod",
Namespace: "default",
Labels: map[string]string{"app": "backend"},
},
Status: corev1.PodStatus{Phase: corev1.PodPending},
},
&corev1.Service{
ObjectMeta: metav1.ObjectMeta{
Name: "backend-svc",
Namespace: "default",
},
Spec: corev1.ServiceSpec{
Selector: map[string]string{"app": "backend"},
Ports: []corev1.ServicePort{
{Port: 80},
},
},
},
)
r := NewResourceResolver(pool)
pf := NewPortForwarder(pool, r)
_, err := pf.GetPodForResource(t.Context(), "test-context", "default", "service/backend-svc", "")
assert.Error(t, err)
assert.Contains(t, err.Error(), "no running pods found for service")
}
+430
View File
@@ -0,0 +1,430 @@
package k8s
import (
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
corev1 "k8s.io/api/core/v1"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/util/intstr"
)
// =============================================================================
// ResourceResolver Extended Tests
// =============================================================================
func TestResourceResolver_ResolvePodPrefix_CacheHit(t *testing.T) {
baseTime := time.Now()
pool := setupTestPool(t, "test-context",
&corev1.Pod{
ObjectMeta: metav1.ObjectMeta{
Name: "my-app-xyz789",
Namespace: "default",
CreationTimestamp: metav1.Time{Time: baseTime},
},
Status: corev1.PodStatus{Phase: corev1.PodRunning},
},
)
r := NewResourceResolver(pool)
// First call - hits API
result1, err := r.Resolve(t.Context(), "test-context", "default", "pod/my-app", "")
require.NoError(t, err)
assert.Equal(t, "pod/my-app-xyz789", result1)
// Second call - should use cache (instant)
start := time.Now()
result2, err := r.Resolve(t.Context(), "test-context", "default", "pod/my-app", "")
require.NoError(t, err)
assert.Equal(t, result1, result2)
// Should be very fast since it's cached
assert.Less(t, time.Since(start), 10*time.Millisecond)
}
func TestResourceResolver_ResolvePodSelector_CacheHit(t *testing.T) {
pool := setupTestPool(t, "test-context",
&corev1.Pod{
ObjectMeta: metav1.ObjectMeta{
Name: "app-pod",
Namespace: "default",
Labels: map[string]string{"app": "myapp"},
},
Status: corev1.PodStatus{Phase: corev1.PodRunning},
},
)
r := NewResourceResolver(pool)
// First call - hits API
result1, err := r.Resolve(t.Context(), "test-context", "default", "pod", "app=myapp")
require.NoError(t, err)
assert.Equal(t, "pod/app-pod", result1)
// Second call - should use cache
result2, err := r.Resolve(t.Context(), "test-context", "default", "pod", "app=myapp")
require.NoError(t, err)
assert.Equal(t, result1, result2)
}
func TestResourceResolver_ResolvePodPrefix_ExcludesNonRunning(t *testing.T) {
baseTime := time.Now()
pool := setupTestPool(t, "test-context",
&corev1.Pod{
ObjectMeta: metav1.ObjectMeta{
Name: "my-app-pending",
Namespace: "default",
CreationTimestamp: metav1.Time{Time: baseTime},
},
Status: corev1.PodStatus{Phase: corev1.PodPending},
},
&corev1.Pod{
ObjectMeta: metav1.ObjectMeta{
Name: "my-app-succeeded",
Namespace: "default",
CreationTimestamp: metav1.Time{Time: baseTime},
},
Status: corev1.PodStatus{Phase: corev1.PodSucceeded},
},
&corev1.Pod{
ObjectMeta: metav1.ObjectMeta{
Name: "my-app-failed",
Namespace: "default",
CreationTimestamp: metav1.Time{Time: baseTime},
},
Status: corev1.PodStatus{Phase: corev1.PodFailed},
},
)
r := NewResourceResolver(pool)
_, err := r.Resolve(t.Context(), "test-context", "default", "pod/my-app", "")
assert.Error(t, err)
assert.Contains(t, err.Error(), "no running pods found matching prefix")
}
func TestResourceResolver_ResolvePodSelector_ExcludesNonRunning(t *testing.T) {
pool := setupTestPool(t, "test-context",
&corev1.Pod{
ObjectMeta: metav1.ObjectMeta{
Name: "app-pod-pending",
Namespace: "default",
Labels: map[string]string{"app": "myapp"},
},
Status: corev1.PodStatus{Phase: corev1.PodPending},
},
)
r := NewResourceResolver(pool)
_, err := r.Resolve(t.Context(), "test-context", "default", "pod", "app=myapp")
assert.Error(t, err)
assert.Contains(t, err.Error(), "no running pods found matching selector")
}
func TestResourceResolver_getFromCache_NotFound(t *testing.T) {
pool, _ := NewClientPool()
r := NewResourceResolver(pool)
result := r.getFromCache("non-existent-key")
assert.Empty(t, result)
}
func TestResourceResolver_getFromCache_ExpiredEntry(t *testing.T) {
pool, _ := NewClientPool()
r := NewResourceResolver(pool)
r.SetCacheTTL(1 * time.Millisecond)
// Put entry in cache
r.putInCache("test-key", "test-value")
// Verify it's there
result := r.getFromCache("test-key")
assert.Equal(t, "test-value", result)
// Wait for expiry
time.Sleep(10 * time.Millisecond)
// Should be expired and cleaned up
result = r.getFromCache("test-key")
assert.Empty(t, result)
// Verify entry was deleted
r.cacheMu.RLock()
_, exists := r.cache["test-key"]
r.cacheMu.RUnlock()
assert.False(t, exists)
}
func TestResourceResolver_InvalidateCache_NoEntries(t *testing.T) {
pool, _ := NewClientPool()
r := NewResourceResolver(pool)
// Should not panic on empty cache
r.InvalidateCache("test-context", "default", "pod/app")
assert.NotNil(t, r.cache)
}
func TestResourceResolver_Resolve_GetClientError(t *testing.T) {
// Create pool without test client - should fail when trying to get client
pool, _ := NewClientPool()
r := NewResourceResolver(pool)
_, err := r.Resolve(t.Context(), "non-existent-context", "default", "pod/test", "")
assert.Error(t, err)
assert.Contains(t, err.Error(), "failed to get client")
}
func TestResourceResolver_ResolvePodPrefix_MultipleMatchesReturnsNewest(t *testing.T) {
baseTime := time.Now()
pool := setupTestPool(t, "test-context",
&corev1.Pod{
ObjectMeta: metav1.ObjectMeta{
Name: "my-app-oldest",
Namespace: "default",
CreationTimestamp: metav1.Time{Time: baseTime.Add(-2 * time.Hour)},
},
Status: corev1.PodStatus{Phase: corev1.PodRunning},
},
&corev1.Pod{
ObjectMeta: metav1.ObjectMeta{
Name: "my-app-middle",
Namespace: "default",
CreationTimestamp: metav1.Time{Time: baseTime.Add(-1 * time.Hour)},
},
Status: corev1.PodStatus{Phase: corev1.PodRunning},
},
&corev1.Pod{
ObjectMeta: metav1.ObjectMeta{
Name: "my-app-newest",
Namespace: "default",
CreationTimestamp: metav1.Time{Time: baseTime},
},
Status: corev1.PodStatus{Phase: corev1.PodRunning},
},
)
r := NewResourceResolver(pool)
result, err := r.Resolve(t.Context(), "test-context", "default", "pod/my-app", "")
require.NoError(t, err)
assert.Equal(t, "pod/my-app-newest", result)
}
func TestResourceResolver_ResolvePodSelector_FirstRunning(t *testing.T) {
pool := setupTestPool(t, "test-context",
&corev1.Pod{
ObjectMeta: metav1.ObjectMeta{
Name: "app-pod-1",
Namespace: "default",
Labels: map[string]string{"app": "myapp"},
},
Status: corev1.PodStatus{Phase: corev1.PodRunning},
},
&corev1.Pod{
ObjectMeta: metav1.ObjectMeta{
Name: "app-pod-2",
Namespace: "default",
Labels: map[string]string{"app": "myapp"},
},
Status: corev1.PodStatus{Phase: corev1.PodRunning},
},
)
r := NewResourceResolver(pool)
result, err := r.Resolve(t.Context(), "test-context", "default", "pod", "app=myapp")
require.NoError(t, err)
// Should return the first running pod found
assert.Equal(t, "pod/app-pod-1", result)
}
// =============================================================================
// Discovery Extended Tests
// =============================================================================
func TestDiscovery_ListPods_FilteringAndSorting(t *testing.T) {
baseTime := time.Now()
pool := setupTestPool(t, "test-context",
&corev1.Pod{
ObjectMeta: metav1.ObjectMeta{
Name: "newer-running-pod",
Namespace: "default",
CreationTimestamp: metav1.Time{Time: baseTime},
},
Status: corev1.PodStatus{Phase: corev1.PodRunning},
Spec: corev1.PodSpec{
Containers: []corev1.Container{
{
Name: "main",
Ports: []corev1.ContainerPort{
{ContainerPort: 8080, Protocol: corev1.ProtocolTCP},
},
},
},
},
},
&corev1.Pod{
ObjectMeta: metav1.ObjectMeta{
Name: "older-pending-pod",
Namespace: "default",
CreationTimestamp: metav1.Time{Time: baseTime.Add(-time.Hour)},
},
Status: corev1.PodStatus{Phase: corev1.PodPending},
Spec: corev1.PodSpec{
Containers: []corev1.Container{
{Name: "main"},
},
},
},
&corev1.Pod{
ObjectMeta: metav1.ObjectMeta{
Name: "older-running-pod",
Namespace: "default",
CreationTimestamp: metav1.Time{Time: baseTime.Add(-2 * time.Hour)},
},
Status: corev1.PodStatus{Phase: corev1.PodRunning},
Spec: corev1.PodSpec{
Containers: []corev1.Container{
{Name: "main"},
},
},
},
// Pods in other namespaces should not appear
&corev1.Pod{
ObjectMeta: metav1.ObjectMeta{
Name: "other-namespace-pod",
Namespace: "kube-system",
},
Status: corev1.PodStatus{Phase: corev1.PodRunning},
},
)
d := NewDiscovery(pool)
pods, err := d.ListPods(t.Context(), "test-context", "default")
require.NoError(t, err)
assert.Len(t, pods, 3) // 2 running + 1 pending
// Should be sorted by creation time (newest first)
assert.Equal(t, "newer-running-pod", pods[0].Name)
assert.Equal(t, "older-pending-pod", pods[1].Name)
assert.Equal(t, "older-running-pod", pods[2].Name)
// Check protocol is set correctly
assert.Equal(t, "TCP", pods[0].Containers[0].Ports[0].Protocol)
}
func TestDiscovery_ListPodsWithSelector_OnlyRunning(t *testing.T) {
pool := setupTestPool(t, "test-context",
&corev1.Pod{
ObjectMeta: metav1.ObjectMeta{
Name: "running-pod",
Namespace: "default",
Labels: map[string]string{"app": "myapp"},
},
Status: corev1.PodStatus{Phase: corev1.PodRunning},
},
&corev1.Pod{
ObjectMeta: metav1.ObjectMeta{
Name: "pending-pod",
Namespace: "default",
Labels: map[string]string{"app": "myapp"},
},
Status: corev1.PodStatus{Phase: corev1.PodPending},
},
)
d := NewDiscovery(pool)
pods, err := d.ListPodsWithSelector(t.Context(), "test-context", "default", "app=myapp")
require.NoError(t, err)
// Only running pods should be returned for selector-based queries
assert.Len(t, pods, 1)
assert.Equal(t, "running-pod", pods[0].Name)
}
func TestDiscovery_ListServices_WithNamedPortResolution(t *testing.T) {
pool := setupTestPool(t, "test-context",
&corev1.Pod{
ObjectMeta: metav1.ObjectMeta{
Name: "backend-pod",
Namespace: "default",
Labels: map[string]string{"app": "backend"},
},
Status: corev1.PodStatus{Phase: corev1.PodRunning},
Spec: corev1.PodSpec{
Containers: []corev1.Container{
{
Name: "main",
Ports: []corev1.ContainerPort{
{Name: "http", ContainerPort: 8080},
{Name: "grpc", ContainerPort: 50051},
},
},
},
},
},
&corev1.Service{
ObjectMeta: metav1.ObjectMeta{
Name: "backend-svc",
Namespace: "default",
},
Spec: corev1.ServiceSpec{
Type: corev1.ServiceTypeClusterIP,
Selector: map[string]string{"app": "backend"},
Ports: []corev1.ServicePort{
{Name: "http", Port: 80, TargetPort: intstr.FromString("http")},
{Name: "grpc", Port: 50051, TargetPort: intstr.FromString("grpc")},
},
},
},
)
d := NewDiscovery(pool)
services, err := d.ListServices(t.Context(), "test-context", "default")
require.NoError(t, err)
assert.Len(t, services, 1)
// Named ports should be resolved
assert.Len(t, services[0].Ports, 2)
assert.Equal(t, int32(80), services[0].Ports[0].Port)
assert.Equal(t, int32(8080), services[0].Ports[0].TargetPort) // Resolved from pod
assert.Equal(t, int32(50051), services[0].Ports[1].Port)
assert.Equal(t, int32(50051), services[0].Ports[1].TargetPort) // Resolved from pod
}
func TestDiscovery_ListServices_NoBackingPods(t *testing.T) {
pool := setupTestPool(t, "test-context",
&corev1.Service{
ObjectMeta: metav1.ObjectMeta{
Name: "backend-svc",
Namespace: "default",
},
Spec: corev1.ServiceSpec{
Type: corev1.ServiceTypeClusterIP,
Selector: map[string]string{"app": "nonexistent"},
Ports: []corev1.ServicePort{
{Name: "http", Port: 80, TargetPort: intstr.FromString("http")},
},
},
},
)
d := NewDiscovery(pool)
services, err := d.ListServices(t.Context(), "test-context", "default")
require.NoError(t, err)
assert.Len(t, services, 1)
// When no backing pods, falls back to service port
assert.Equal(t, int32(80), services[0].Ports[0].TargetPort)
}