Files
kportal/internal/k8s/k8s_api_test.go
T

602 lines
16 KiB
Go

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")
}