mirror of
https://github.com/lukaszraczylo/kportal.git
synced 2026-06-11 00:09:31 +00:00
602 lines
16 KiB
Go
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")
|
|
}
|