1
0
Fork 0
mirror of https://github.com/imjasonh/client-go2 synced 2026-07-08 17:16:47 +00:00
client-go2/generic/e2e_test.go
Jason Hall e79b66caf5
Add expansion methods and missing client-go operations
This PR adds support for resource-specific expansion methods and implements
missing standard client-go operations to achieve feature parity.

## New Features

### Expansion Methods
- Added PodClient with methods: GetLogs, Bind, Evict, ProxyGet
- Added ServiceClient with method: ProxyGet
- Both clients implement the official k8s.io/client-go expansion interfaces
- Runtime type assertions ensure type safety (panic if wrong type)

### Missing Standard Methods
- Watch: Watch resources with optional label/field selectors
- DeleteCollection: Delete multiple resources matching criteria
- UpdateStatus: Update only the status subresource

### Other Improvements
- Refactored all tests to use inline configuration pattern
- Added comprehensive e2e tests for new functionality
- Updated documentation with examples
- Fixed linting error in stream.Close() handling

## Design Decisions

The expansion methods follow a namespace-scoped pattern where calling
`client.PodClient("namespace")` returns a client that implements the
standard client-go PodExpansion interface. This maintains full API
compatibility while providing type safety through runtime assertions.

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-07-28 10:32:34 -04:00

520 lines
14 KiB
Go

//go:build e2e
// +build e2e
package generic
import (
"context"
"encoding/json"
"testing"
"time"
corev1 "k8s.io/api/core/v1"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/runtime/schema"
"k8s.io/client-go/rest"
"k8s.io/client-go/tools/cache"
"k8s.io/client-go/tools/clientcmd"
)
// TestInferGVRE2E tests GVR inference against a real Kubernetes cluster
func TestInferGVRE2E(t *testing.T) {
config, err := clientcmd.NewNonInteractiveDeferredLoadingClientConfig(
clientcmd.NewDefaultClientConfigLoadingRules(),
&clientcmd.ConfigOverrides{},
).ClientConfig()
if err != nil {
t.Fatalf("failed to load kubeconfig: %v", err)
}
for _, tt := range []struct {
name string
inferFunc func() (schema.GroupVersionResource, error)
expectedGVR schema.GroupVersionResource
}{{
name: "Pod",
inferFunc: func() (schema.GroupVersionResource, error) {
return inferGVR[*corev1.Pod](config)
},
expectedGVR: schema.GroupVersionResource{
Group: "",
Version: "v1",
Resource: "pods",
},
}, {
name: "ConfigMap",
inferFunc: func() (schema.GroupVersionResource, error) {
return inferGVR[*corev1.ConfigMap](config)
},
expectedGVR: schema.GroupVersionResource{
Group: "",
Version: "v1",
Resource: "configmaps",
},
}, {
name: "Service",
inferFunc: func() (schema.GroupVersionResource, error) {
return inferGVR[*corev1.Service](config)
},
expectedGVR: schema.GroupVersionResource{
Group: "",
Version: "v1",
Resource: "services",
},
}, {
name: "Secret",
inferFunc: func() (schema.GroupVersionResource, error) {
return inferGVR[*corev1.Secret](config)
},
expectedGVR: schema.GroupVersionResource{
Group: "",
Version: "v1",
Resource: "secrets",
},
}, {
name: "Namespace",
inferFunc: func() (schema.GroupVersionResource, error) {
return inferGVR[*corev1.Namespace](config)
},
expectedGVR: schema.GroupVersionResource{
Group: "",
Version: "v1",
Resource: "namespaces",
},
}} {
t.Run(tt.name, func(t *testing.T) {
gvr, err := tt.inferFunc()
if err != nil {
t.Fatalf("failed to infer GVR: %v", err)
}
if gvr != tt.expectedGVR {
t.Errorf("expected GVR %v, got %v", tt.expectedGVR, gvr)
}
})
}
}
// TestNewClientE2E tests client creation with inferred GVR against a real cluster
func TestNewClientE2E(t *testing.T) {
config, err := clientcmd.NewNonInteractiveDeferredLoadingClientConfig(
clientcmd.NewDefaultClientConfigLoadingRules(),
&clientcmd.ConfigOverrides{},
).ClientConfig()
if err != nil {
t.Fatalf("failed to load kubeconfig: %v", err)
}
ctx := context.Background()
t.Run("Pod client operations", func(t *testing.T) {
client, err := NewClient[*corev1.Pod](config)
if err != nil {
t.Fatalf("failed to create pod client: %v", err)
}
// Try to list pods in default namespace
pods, err := client.List(ctx, "default", nil)
if err != nil {
t.Fatalf("failed to list pods: %v", err)
}
t.Logf("Successfully listed %d pods in default namespace", len(pods))
})
t.Run("ConfigMap client operations", func(t *testing.T) {
client, err := NewClient[*corev1.ConfigMap](config)
if err != nil {
t.Fatalf("failed to create configmap client: %v", err)
}
// Try to list configmaps in default namespace
cms, err := client.List(ctx, "default", nil)
if err != nil {
t.Fatalf("failed to list configmaps: %v", err)
}
t.Logf("Successfully listed %d configmaps in default namespace", len(cms))
})
t.Run("Service client operations", func(t *testing.T) {
client, err := NewClient[*corev1.Service](config)
if err != nil {
t.Fatalf("failed to create service client: %v", err)
}
// Try to list services in default namespace
svcs, err := client.List(ctx, "default", nil)
if err != nil {
t.Fatalf("failed to list services: %v", err)
}
t.Logf("Successfully listed %d services in default namespace", len(svcs))
})
}
// TestInferGVRErrorCases tests error cases for GVR inference
func TestInferGVRErrorCases(t *testing.T) {
config := &rest.Config{
Host: "http://localhost:8080",
}
t.Run("Unregistered type", func(t *testing.T) {
// This should fail because no scheme is registered for this type
type UnregisteredType struct {
*corev1.Pod
}
_, err := inferGVR[*UnregisteredType](config)
if err == nil {
t.Error("expected error for unregistered type, got nil")
}
})
}
// TestInformE2E tests informer functionality against a real cluster
func TestInformE2E(t *testing.T) {
config, err := clientcmd.NewNonInteractiveDeferredLoadingClientConfig(
clientcmd.NewDefaultClientConfigLoadingRules(),
&clientcmd.ConfigOverrides{},
).ClientConfig()
if err != nil {
t.Fatalf("failed to load kubeconfig: %v", err)
}
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
// Create a ConfigMap client with inferred GVR
client, err := NewClient[*corev1.ConfigMap](config)
if err != nil {
t.Fatalf("failed to create configmap client: %v", err)
}
// Track events
events := make(chan string, 100)
handler := InformerHandler[*corev1.ConfigMap]{
OnAdd: func(key string, obj *corev1.ConfigMap) {
namespace, name, _ := cache.SplitMetaNamespaceKey(key)
if namespace == "default" {
events <- "add:" + name
}
},
OnUpdate: func(key string, oldObj, newObj *corev1.ConfigMap) {
namespace, name, _ := cache.SplitMetaNamespaceKey(key)
if namespace == "default" {
events <- "update:" + name
}
},
OnDelete: func(key string, obj *corev1.ConfigMap) {
namespace, name, _ := cache.SplitMetaNamespaceKey(key)
if namespace == "default" {
events <- "delete:" + name
}
},
OnError: func(obj any, err error) {
t.Fatalf("Informer error: %v for object %v", err, obj)
},
}
client.Inform(ctx, handler, nil)
// Create a test ConfigMap
testCM := &corev1.ConfigMap{
ObjectMeta: metav1.ObjectMeta{
Name: "test-inform-" + time.Now().Format("20060102-150405"),
Namespace: "default",
},
Data: map[string]string{
"test": "data",
},
}
created, err := client.Create(ctx, "default", testCM, nil)
if err != nil {
t.Fatalf("failed to create test configmap: %v", err)
}
testCM = created
defer func() {
// Clean up
if err := client.Delete(ctx, "default", testCM.Name, nil); err != nil {
t.Logf("failed to delete test configmap: %v", err)
}
}()
// Wait for add event for our specific ConfigMap
deadline := time.After(10 * time.Second)
foundAdd := false
for !foundAdd {
select {
case event := <-events:
t.Logf("Received event: %s", event)
if event == "add:"+testCM.Name {
foundAdd = true
}
case <-deadline:
t.Fatal("timeout waiting for add event")
}
}
// Update the ConfigMap
testCM.Data["test"] = "updated"
updated, err := client.Update(ctx, "default", testCM, nil)
if err != nil {
t.Fatalf("failed to update test configmap: %v", err)
}
testCM = updated
// Wait for update event
deadline = time.After(10 * time.Second)
foundUpdate := false
for !foundUpdate {
select {
case event := <-events:
t.Logf("Received event: %s", event)
if event == "update:"+testCM.Name {
foundUpdate = true
}
case <-deadline:
t.Fatal("timeout waiting for update event")
}
}
}
// TestPodClientExpansionE2E tests PodClient expansion methods against a real cluster
func TestPodClientExpansionE2E(t *testing.T) {
config, err := clientcmd.NewNonInteractiveDeferredLoadingClientConfig(
clientcmd.NewDefaultClientConfigLoadingRules(),
&clientcmd.ConfigOverrides{},
).ClientConfig()
if err != nil {
t.Fatalf("failed to load kubeconfig: %v", err)
}
ctx := context.Background()
// Create a Pod client
client, err := NewClient[*corev1.Pod](config)
if err != nil {
t.Fatalf("failed to create pod client: %v", err)
}
// List pods in kube-system to find a suitable test pod
pods, err := client.List(ctx, "kube-system", &metav1.ListOptions{
LabelSelector: "k8s-app=kube-dns",
})
if err != nil {
t.Fatalf("failed to list pods: %v", err)
}
if len(pods) == 0 {
t.Fatal("no CoreDNS pods found in kube-system namespace")
}
// Use the first CoreDNS pod for testing
testPod := pods[0]
t.Logf("Using pod %s for expansion method tests", testPod.Name)
// Get a namespace-scoped PodClient
podClient := client.PodClient("kube-system")
t.Run("GetLogs", func(t *testing.T) {
// Test getting logs without options
req := podClient.GetLogs(testPod.Name, nil)
logs, err := req.DoRaw(ctx)
if err != nil {
// Some pods might not have logs yet, which is okay
t.Logf("GetLogs without options returned error (this might be expected): %v", err)
} else {
t.Logf("Successfully retrieved logs (%d bytes)", len(logs))
}
// Test getting logs with options
tailLines := int64(5)
logOpts := &corev1.PodLogOptions{
TailLines: &tailLines,
}
req = podClient.GetLogs(testPod.Name, logOpts)
logs, err = req.DoRaw(ctx)
if err != nil {
t.Logf("GetLogs with TailLines returned error: %v", err)
} else {
t.Logf("Successfully retrieved last 5 lines of logs (%d bytes)", len(logs))
// Count lines to verify we got at most 5
lines := 0
for _, c := range logs {
if c == '\n' {
lines++
}
}
if lines > 5 {
t.Errorf("expected at most 5 lines, got %d", lines)
}
}
// Test getting logs from specific container if pod has multiple containers
if len(testPod.Spec.Containers) > 0 {
containerName := testPod.Spec.Containers[0].Name
containerOpts := &corev1.PodLogOptions{
Container: containerName,
TailLines: &tailLines,
}
req = podClient.GetLogs(testPod.Name, containerOpts)
logs, err = req.DoRaw(ctx)
if err != nil {
t.Logf("GetLogs for container %s returned error: %v", containerName, err)
} else {
t.Logf("Successfully retrieved logs from container %s (%d bytes)", containerName, len(logs))
}
}
})
t.Run("ProxyGet", func(t *testing.T) {
// Most pods don't expose HTTP endpoints, so we expect this to fail
// but we're testing that the method works correctly
req := podClient.ProxyGet("http", testPod.Name, "8080", "healthz", nil)
_, err := req.DoRaw(ctx)
if err != nil {
// Expected - most pods don't have HTTP endpoints
t.Logf("ProxyGet returned expected error: %v", err)
} else {
t.Log("ProxyGet unexpectedly succeeded")
}
})
t.Run("PodClient panic on wrong type", func(t *testing.T) {
// Create a ConfigMap client and verify it panics when calling PodClient()
cmClient, err := NewClient[*corev1.ConfigMap](config)
if err != nil {
t.Fatalf("failed to create configmap client: %v", err)
}
defer func() {
if r := recover(); r == nil {
t.Error("expected panic when calling PodClient() on ConfigMap client")
} else {
t.Logf("Got expected panic: %v", r)
}
}()
// This should panic
_ = cmClient.PodClient("default")
})
}
// TestServiceClientExpansionE2E tests ServiceClient expansion methods against a real cluster
func TestServiceClientExpansionE2E(t *testing.T) {
config, err := clientcmd.NewNonInteractiveDeferredLoadingClientConfig(
clientcmd.NewDefaultClientConfigLoadingRules(),
&clientcmd.ConfigOverrides{},
).ClientConfig()
if err != nil {
t.Fatalf("failed to load kubeconfig: %v", err)
}
ctx := context.Background()
// Create a Service client
client, err := NewClient[*corev1.Service](config)
if err != nil {
t.Fatalf("failed to create service client: %v", err)
}
// Get the kubernetes service in default namespace
svc, err := client.Get(ctx, "default", "kubernetes", nil)
if err != nil {
t.Fatalf("failed to get kubernetes service: %v", err)
}
// Get a namespace-scoped ServiceClient
serviceClient := client.ServiceClient("default")
t.Run("ProxyGet", func(t *testing.T) {
// The kubernetes service exposes the API server
// Try to access the /version endpoint through the proxy
req := serviceClient.ProxyGet("https", svc.Name, "443", "version", nil)
body, err := req.DoRaw(ctx)
if err != nil {
// This might fail due to auth/TLS issues which is okay for this test
t.Logf("ProxyGet returned error (might be expected): %v", err)
} else {
t.Logf("Successfully proxied request to kubernetes service, got response: %s", string(body))
}
})
t.Run("ServiceClient panic on wrong type", func(t *testing.T) {
// Create a Pod client and verify it panics when calling ServiceClient()
podClient, err := NewClient[*corev1.Pod](config)
if err != nil {
t.Fatalf("failed to create pod client: %v", err)
}
defer func() {
if r := recover(); r == nil {
t.Error("expected panic when calling ServiceClient() on Pod client")
} else {
t.Logf("Got expected panic: %v", r)
}
}()
// This should panic
_ = podClient.ServiceClient("default")
})
}
// TestSubResourceE2E tests the generic SubResource method against a real cluster
func TestSubResourceE2E(t *testing.T) {
config, err := clientcmd.NewNonInteractiveDeferredLoadingClientConfig(
clientcmd.NewDefaultClientConfigLoadingRules(),
&clientcmd.ConfigOverrides{},
).ClientConfig()
if err != nil {
t.Fatalf("failed to load kubeconfig: %v", err)
}
ctx := context.Background()
// Create a Pod client
client, err := NewClient[*corev1.Pod](config)
if err != nil {
t.Fatalf("failed to create pod client: %v", err)
}
// List pods in kube-system
pods, err := client.List(ctx, "kube-system", &metav1.ListOptions{
Limit: 1,
})
if err != nil {
t.Fatalf("failed to list pods: %v", err)
}
if len(pods) == 0 {
t.Fatal("no pods found in kube-system namespace")
}
testPod := pods[0]
t.Run("Get pod status subresource", func(t *testing.T) {
req := client.SubResource("kube-system", testPod.Name, "status")
body, err := req.DoRaw(ctx)
if err != nil {
t.Fatalf("failed to get pod status: %v", err)
}
// Verify we got a valid pod status
var pod corev1.Pod
if err := json.Unmarshal(body, &pod); err != nil {
t.Fatalf("failed to decode pod status: %v", err)
}
if pod.Status.Phase == "" {
t.Error("expected pod status to have a phase")
} else {
t.Logf("Pod %s is in phase: %s", testPod.Name, pod.Status.Phase)
}
t.Logf("Successfully retrieved pod status (%d bytes)", len(body))
})
}