diff --git a/CLAUDE.md b/CLAUDE.md index bc6a678..b5e5fbd 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -62,7 +62,8 @@ The library uses Go generics with type parameter `[T runtime.Object]` to provide - For custom resources or when you need explicit control over the GVR ### Key Components -- `generic/client.go`: Core client implementation with CRUD operations (List, Get, Create, Update, Delete, Patch) +- `generic/client.go`: Core client implementation with CRUD operations (List, Get, Create, Update, Delete, Patch, Watch, DeleteCollection, UpdateStatus) +- `generic/expansions.go`: Resource-specific expansion methods (PodClient, ServiceClient) that implement client-go interfaces - `generic/informer.go`: Type-safe informer implementation for watching resources - `generic/client_test.go`: Unit tests with REST client mocking - `generic/e2e_test.go`: Integration tests that require a real Kubernetes cluster @@ -71,6 +72,40 @@ The library uses Go generics with type parameter `[T runtime.Object]` to provide - Unit tests use a custom `mockTransport` that implements `http.RoundTripper` - Mock responses are set up per HTTP method and path combination - E2e tests create real resources in a Kind cluster and verify operations +- Prefer inline configuration pattern when creating clients in tests (see examples below) + +### Expansion Methods Design +The library provides resource-specific expansion methods that maintain exact compatibility with k8s.io/client-go interfaces: + +- `PodClient(namespace)` returns a namespace-scoped client implementing `typedcorev1.PodExpansion` +- `ServiceClient(namespace)` returns a namespace-scoped client implementing `typedcorev1.ServiceExpansion` +- These methods use runtime type assertions and will panic if called on the wrong type for compile-time-like safety +- Example: `client.PodClient("default").GetLogs("my-pod", opts)` + +### Test Configuration Pattern +Always use the inline configuration pattern in tests: + +```go +client := NewClientGVR[*corev1.Pod]( + schema.GroupVersionResource{Group: "", Version: "v1", Resource: "pods"}, + &rest.Config{ + Host: "http://localhost", + APIPath: "/api", + Transport: &mockTransport{ + responses: map[string]mockResponse{ + "GET /api/v1/namespaces/test-namespace/pods": { + statusCode: 200, + body: string(listJSON), + }, + }, + }, + ContentConfig: rest.ContentConfig{ + GroupVersion: &schema.GroupVersion{Version: "v1"}, + NegotiatedSerializer: serializer.NewCodecFactory(runtime.NewScheme()).WithoutConversion(), + }, + }, +) +``` ## Important Rules @@ -78,3 +113,4 @@ The library uses Go generics with type parameter `[T runtime.Object]` to provide 2. When comparing structs in tests, use `github.com/google/go-cmp/cmp.Diff` for better error messages 3. Follow the existing code style - inline struct initialization, single-line error checks where appropriate 4. The library requires Go 1.22.0+ due to generic constraints +5. Always maintain exact compatibility with k8s.io/client-go interfaces - do not create custom interfaces diff --git a/README.md b/README.md index ac8c765..1579267 100644 --- a/README.md +++ b/README.md @@ -2,19 +2,121 @@ [![Build](https://github.com/imjasonh/client-go2/actions/workflows/build.yaml/badge.svg)](https://github.com/imjasonh/client-go2/actions/workflows/build.yaml) -This is an experimental type-parameter-aware client that wraps [`k8s.io/client-go/rest`](https://pkg.go.dev/k8s.io/client-go/rest), in [about 500 lines of mostly-vibe-coded Go](./generic/client.go). +This is an experimental type-parameter-aware client that wraps [`k8s.io/client-go/rest`](https://pkg.go.dev/k8s.io/client-go/rest), in [about 570 lines of mostly-vibe-coded Go](./generic/client.go). ## Features - **Type-safe generic client** - Work with strongly-typed Kubernetes objects instead of `unstructured.Unstructured` - **Zero code generation** - Uses Go generics instead of code generation -- **Full CRUD operations** - List, Get, Create, Update, Delete, and Patch support +- **Full CRUD operations** - List, Get, Create, Update, Delete, Patch, Watch, DeleteCollection, and UpdateStatus support - **Informer support** - Watch for changes with type-safe event handlers - **Automatic GVR inference** - No need to manually specify GroupVersionResource for standard Kubernetes types +- **Expansion methods** - Resource-specific operations like Pod.GetLogs() and Service.ProxyGet() +- **Label/Field selectors** - Filter resources using Kubernetes selectors +- **SubResource access** - Generic method to access any subresource ## Usage -See [the example](./main.go) +See [the example](./main.go) for comprehensive usage examples. + +### Quick Examples + +#### Basic CRUD Operations +```go +// Create a client with automatic GVR inference +client, err := generic.NewClient[*corev1.Pod](config) + +// List pods with label selector +pods, err := client.List(ctx, "default", &metav1.ListOptions{ + LabelSelector: "app=nginx", +}) + +// Get a specific pod +pod, err := client.Get(ctx, "default", "my-pod", nil) +``` + +#### Expansion Methods for Pods (client-go compatible) +```go +// Start with a generic client for pods +client, err := generic.NewClient[*corev1.Pod](config) + +// Get a namespace-scoped PodClient that implements typedcorev1.PodExpansion +podClient := client.PodClient("default") // Will panic if T is not *corev1.Pod + +// Get pod logs (matches client-go API) +req := podClient.GetLogs("my-pod", &corev1.PodLogOptions{ + Container: "nginx", + TailLines: &tailLines, +}) +logs, err := req.DoRaw(ctx) + +// Bind pod to node +err = podClient.Bind(ctx, binding, metav1.CreateOptions{}) + +// Evict pod +err = podClient.Evict(ctx, eviction) + +// For cluster-scoped operations, use the generic client directly +pods, err := client.List(ctx, "default", nil) +``` + +#### Expansion Methods for Services (client-go compatible) +```go +// Start with a generic client for services +client, err := generic.NewClient[*corev1.Service](config) + +// Get a namespace-scoped ServiceClient that implements typedcorev1.ServiceExpansion +serviceClient := client.ServiceClient("default") // Will panic if T is not *corev1.Service + +// Use proxy to access service endpoints +req := serviceClient.ProxyGet("http", "my-service", "80", "api/health", nil) +resp, err := req.DoRaw(ctx) +``` + +#### Generic SubResource Access +```go +// Access any subresource using the generic method +req := client.SubResource("default", "my-pod", "status") +status, err := req.DoRaw(ctx) +``` + +#### Watch Resources +```go +// Watch for pod changes with label selector +watcher, err := client.Watch(ctx, "default", &metav1.ListOptions{ + LabelSelector: "app=nginx", +}) + +defer watcher.Stop() + +for event := range watcher.ResultChan() { + pod := event.Object.(*corev1.Pod) + fmt.Printf("Event: %s Pod: %s\n", event.Type, pod.Name) +} +``` + +#### Delete Collection +```go +// Delete all pods with specific label +err = client.DeleteCollection(ctx, "default", nil, &metav1.ListOptions{ + LabelSelector: "app=test", +}) + +// Delete all pods in namespace +err = client.DeleteCollection(ctx, "default", nil, nil) +``` + +#### Update Status +```go +// Update only the status subresource +pod.Status.Phase = corev1.PodRunning +pod.Status.Conditions = append(pod.Status.Conditions, corev1.PodCondition{ + Type: corev1.PodReady, + Status: corev1.ConditionTrue, +}) + +updated, err := client.UpdateStatus(ctx, "default", pod, nil) +``` ## Testing diff --git a/generic/client.go b/generic/client.go index 7db7035..26502c9 100644 --- a/generic/client.go +++ b/generic/client.go @@ -7,6 +7,7 @@ import ( "reflect" "time" + corev1 "k8s.io/api/core/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/runtime" "k8s.io/apimachinery/pkg/runtime/schema" @@ -143,6 +144,34 @@ type Client[T runtime.Object] struct { restClient *rest.RESTClient } +// PodClient returns a PodClient with expansion methods. +// This will panic if T is not *corev1.Pod. +func (c Client[T]) PodClient(namespace string) PodClient { + // Type assert to ensure T is *corev1.Pod + var zero T + if _, ok := any(zero).(*corev1.Pod); !ok { + panic(fmt.Sprintf("PodClient() can only be called on Client[*corev1.Pod], not Client[%T]", zero)) + } + + // This is safe because we know T is *corev1.Pod + podClient := any(c).(Client[*corev1.Pod]) + return PodClient{client: podClient, namespace: namespace} +} + +// ServiceClient returns a ServiceClient with expansion methods. +// This will panic if T is not *corev1.Service. +func (c Client[T]) ServiceClient(namespace string) ServiceClient { + // Type assert to ensure T is *corev1.Service + var zero T + if _, ok := any(zero).(*corev1.Service); !ok { + panic(fmt.Sprintf("ServiceClient() can only be called on Client[*corev1.Service], not Client[%T]", zero)) + } + + // This is safe because we know T is *corev1.Service + serviceClient := any(c).(Client[*corev1.Service]) + return ServiceClient{client: serviceClient, namespace: namespace} +} + // List retrieves a list of objects of type T from the specified namespace. func (c Client[T]) List(ctx context.Context, namespace string, opts *metav1.ListOptions) ([]T, error) { if opts == nil { @@ -304,6 +333,82 @@ func (c Client[T]) Patch(ctx context.Context, namespace, name string, pt types.P return err } +// Watch returns a watch interface for watching changes to resources of type T. +func (c Client[T]) Watch(ctx context.Context, namespace string, opts *metav1.ListOptions) (watch.Interface, error) { + if opts == nil { + opts = &metav1.ListOptions{} + } + opts.Watch = true + return c.restClient.Get(). + NamespaceIfScoped(namespace, namespace != ""). + Resource(c.gvr.Resource). + VersionedParams(opts, scheme.ParameterCodec). + Watch(ctx) +} + +// DeleteCollection deletes a collection of objects of type T. +func (c Client[T]) DeleteCollection(ctx context.Context, namespace string, opts *metav1.DeleteOptions, listOpts *metav1.ListOptions) error { + if opts == nil { + opts = &metav1.DeleteOptions{} + } + if listOpts == nil { + listOpts = &metav1.ListOptions{} + } + return c.restClient.Delete(). + NamespaceIfScoped(namespace, namespace != ""). + Resource(c.gvr.Resource). + VersionedParams(opts, scheme.ParameterCodec). + VersionedParams(listOpts, scheme.ParameterCodec). + Do(ctx). + Error() +} + +// UpdateStatus updates the status subresource of an object of type T. +func (c Client[T]) UpdateStatus(ctx context.Context, namespace string, t T, opts *metav1.UpdateOptions) (T, error) { + if opts == nil { + opts = &metav1.UpdateOptions{} + } + // Extract the name from the object metadata + data, err := json.Marshal(t) + if err != nil { + var zero T + return zero, err + } + var meta metav1.ObjectMeta + var objMap map[string]json.RawMessage + if err := json.Unmarshal(data, &objMap); err != nil { + var zero T + return zero, err + } + if metaData, ok := objMap["metadata"]; ok { + if err := json.Unmarshal(metaData, &meta); err != nil { + var zero T + return zero, err + } + } + + body, err := c.restClient.Put(). + NamespaceIfScoped(namespace, namespace != ""). + Resource(c.gvr.Resource). + Name(meta.Name). + SubResource("status"). + VersionedParams(opts, scheme.ParameterCodec). + Body(t). + Do(ctx). + Raw() + if err != nil { + var zero T + return zero, err + } + + var result T + if err := json.Unmarshal(body, &result); err != nil { + var zero T + return zero, err + } + return result, nil +} + // InformerHandler defines the interface for handling events from an informer. type InformerHandler[T runtime.Object] struct { // OnAdd is called when a new object is added to the informer. @@ -438,6 +543,26 @@ func (c Client[T]) Inform(ctx context.Context, handler InformerHandler[T], opts } } +// SubResource returns a request for a subresource of the given resource. +// This can be used to access subresources like logs, exec, attach, etc. +// For example, to get pod logs: +// +// req := client.SubResource("default", "my-pod", "log") +// req.VersionedParams(&v1.PodLogOptions{...}, scheme.ParameterCodec) +func (c Client[T]) SubResource(namespace, name, subresource string) *rest.Request { + return c.restClient.Get(). + NamespaceIfScoped(namespace, namespace != ""). + Name(name). + Resource(c.gvr.Resource). + SubResource(subresource) +} + +// RESTClient returns the underlying rest.RESTClient. +// This is useful for advanced use cases where direct access to the REST client is needed. +func (c Client[T]) RESTClient() *rest.RESTClient { + return c.restClient +} + func (h InformerHandler[T]) handleErr(obj any, err error) { if h.OnError != nil { h.OnError(obj, err) diff --git a/generic/client_test.go b/generic/client_test.go index f49d14d..6f22737 100644 --- a/generic/client_test.go +++ b/generic/client_test.go @@ -47,20 +47,18 @@ func (m *mockTransport) RoundTrip(req *http.Request) (*http.Response, error) { } func TestNewClientGVR(t *testing.T) { - gvr := schema.GroupVersionResource{ - Group: "", - Version: "v1", - Resource: "pods", - } + gvr := schema.GroupVersionResource{Group: "", Version: "v1", Resource: "pods"} - config := &rest.Config{ - Host: "http://localhost", - ContentConfig: rest.ContentConfig{ - GroupVersion: &schema.GroupVersion{Version: "v1"}, - NegotiatedSerializer: serializer.NewCodecFactory(runtime.NewScheme()).WithoutConversion(), + client := NewClientGVR[*corev1.Pod]( + gvr, + &rest.Config{ + Host: "http://localhost", + ContentConfig: rest.ContentConfig{ + GroupVersion: &schema.GroupVersion{Version: "v1"}, + NegotiatedSerializer: serializer.NewCodecFactory(runtime.NewScheme()).WithoutConversion(), + }, }, - } - client := NewClientGVR[*corev1.Pod](gvr, config) + ) if client.gvr != gvr { t.Errorf("expected GVR %v, got %v", gvr, client.gvr) @@ -102,40 +100,25 @@ func TestList(t *testing.T) { listJSON, _ := json.Marshal(podList) - transport := &mockTransport{ - responses: map[string]mockResponse{ - "GET /api/v1/namespaces/test-namespace/pods": { - statusCode: 200, - body: string(listJSON), + client := NewClientGVR[*corev1.Pod]( + schema.GroupVersionResource{Group: "", Version: "v1", Resource: "pods"}, + &rest.Config{ + Host: "http://localhost", + APIPath: "/api", + Transport: &mockTransport{ + responses: map[string]mockResponse{ + "GET /api/v1/namespaces/test-namespace/pods": { + statusCode: 200, + body: string(listJSON), + }, + }, + }, + ContentConfig: rest.ContentConfig{ + GroupVersion: &schema.GroupVersion{Version: "v1"}, + NegotiatedSerializer: serializer.NewCodecFactory(runtime.NewScheme()).WithoutConversion(), }, }, - } - - config := &rest.Config{ - Host: "http://localhost", - APIPath: "/api", - Transport: transport, - ContentConfig: rest.ContentConfig{ - GroupVersion: &schema.GroupVersion{Version: "v1"}, - NegotiatedSerializer: serializer.NewCodecFactory(runtime.NewScheme()).WithoutConversion(), - }, - } - - restClient, err := rest.RESTClientFor(config) - if err != nil { - t.Fatal(err) - } - - gvr := schema.GroupVersionResource{ - Group: "", - Version: "v1", - Resource: "pods", - } - - client := Client[*corev1.Pod]{ - gvr: gvr, - restClient: restClient, - } + ) pods, err := client.List(ctx, namespace, nil) if err != nil { @@ -182,40 +165,25 @@ func TestGet(t *testing.T) { podJSON, _ := json.Marshal(expectedPod) - transport := &mockTransport{ - responses: map[string]mockResponse{ - "GET /api/v1/namespaces/test-namespace/pods/test-pod": { - statusCode: 200, - body: string(podJSON), + client := NewClientGVR[*corev1.Pod]( + schema.GroupVersionResource{Group: "", Version: "v1", Resource: "pods"}, + &rest.Config{ + Host: "http://localhost", + APIPath: "/api", + Transport: &mockTransport{ + responses: map[string]mockResponse{ + "GET /api/v1/namespaces/test-namespace/pods/test-pod": { + statusCode: 200, + body: string(podJSON), + }, + }, + }, + ContentConfig: rest.ContentConfig{ + GroupVersion: &schema.GroupVersion{Version: "v1"}, + NegotiatedSerializer: serializer.NewCodecFactory(runtime.NewScheme()).WithoutConversion(), }, }, - } - - config := &rest.Config{ - Host: "http://localhost", - APIPath: "/api", - Transport: transport, - ContentConfig: rest.ContentConfig{ - GroupVersion: &schema.GroupVersion{Version: "v1"}, - NegotiatedSerializer: serializer.NewCodecFactory(runtime.NewScheme()).WithoutConversion(), - }, - } - - restClient, err := rest.RESTClientFor(config) - if err != nil { - t.Fatal(err) - } - - gvr := schema.GroupVersionResource{ - Group: "", - Version: "v1", - Resource: "pods", - } - - client := Client[*corev1.Pod]{ - gvr: gvr, - restClient: restClient, - } + ) pod, err := client.Get(ctx, namespace, podName, nil) if err != nil { @@ -256,44 +224,29 @@ func TestCreate(t *testing.T) { podJSON, _ := json.Marshal(newPod) - transport := &mockTransport{ - responses: map[string]mockResponse{ - "POST /api/v1/namespaces/test-namespace/pods": { - statusCode: 201, - body: string(podJSON), + client := NewClientGVR[*corev1.Pod]( + schema.GroupVersionResource{Group: "", Version: "v1", Resource: "pods"}, + &rest.Config{ + Host: "http://localhost", + APIPath: "/api", + Transport: &mockTransport{ + responses: map[string]mockResponse{ + "POST /api/v1/namespaces/test-namespace/pods": { + statusCode: 201, + body: string(podJSON), + }, + "GET /api/v1/namespaces/test-namespace/pods/new-pod": { + statusCode: 200, + body: string(podJSON), + }, + }, }, - "GET /api/v1/namespaces/test-namespace/pods/new-pod": { - statusCode: 200, - body: string(podJSON), + ContentConfig: rest.ContentConfig{ + GroupVersion: &schema.GroupVersion{Version: "v1"}, + NegotiatedSerializer: serializer.NewCodecFactory(runtime.NewScheme()).WithoutConversion(), }, }, - } - - config := &rest.Config{ - Host: "http://localhost", - APIPath: "/api", - Transport: transport, - ContentConfig: rest.ContentConfig{ - GroupVersion: &schema.GroupVersion{Version: "v1"}, - NegotiatedSerializer: serializer.NewCodecFactory(runtime.NewScheme()).WithoutConversion(), - }, - } - - restClient, err := rest.RESTClientFor(config) - if err != nil { - t.Fatal(err) - } - - gvr := schema.GroupVersionResource{ - Group: "", - Version: "v1", - Resource: "pods", - } - - client := Client[*corev1.Pod]{ - gvr: gvr, - restClient: restClient, - } + ) created, err := client.Create(ctx, namespace, newPod, nil) if err != nil { @@ -344,44 +297,29 @@ func TestUpdate(t *testing.T) { updatedJSON, _ := json.Marshal(updatedPod) - transport := &mockTransport{ - responses: map[string]mockResponse{ - "PUT /api/v1/namespaces/test-namespace/pods/update-pod": { - statusCode: 200, - body: string(updatedJSON), + client := NewClientGVR[*corev1.Pod]( + schema.GroupVersionResource{Group: "", Version: "v1", Resource: "pods"}, + &rest.Config{ + Host: "http://localhost", + APIPath: "/api", + Transport: &mockTransport{ + responses: map[string]mockResponse{ + "PUT /api/v1/namespaces/test-namespace/pods/update-pod": { + statusCode: 200, + body: string(updatedJSON), + }, + "GET /api/v1/namespaces/test-namespace/pods/update-pod": { + statusCode: 200, + body: string(updatedJSON), + }, + }, }, - "GET /api/v1/namespaces/test-namespace/pods/update-pod": { - statusCode: 200, - body: string(updatedJSON), + ContentConfig: rest.ContentConfig{ + GroupVersion: &schema.GroupVersion{Version: "v1"}, + NegotiatedSerializer: serializer.NewCodecFactory(runtime.NewScheme()).WithoutConversion(), }, }, - } - - config := &rest.Config{ - Host: "http://localhost", - APIPath: "/api", - Transport: transport, - ContentConfig: rest.ContentConfig{ - GroupVersion: &schema.GroupVersion{Version: "v1"}, - NegotiatedSerializer: serializer.NewCodecFactory(runtime.NewScheme()).WithoutConversion(), - }, - } - - restClient, err := rest.RESTClientFor(config) - if err != nil { - t.Fatal(err) - } - - gvr := schema.GroupVersionResource{ - Group: "", - Version: "v1", - Resource: "pods", - } - - client := Client[*corev1.Pod]{ - gvr: gvr, - restClient: restClient, - } + ) updated, err := client.Update(ctx, namespace, updatedPod, nil) if err != nil { @@ -407,40 +345,25 @@ func TestDelete(t *testing.T) { ctx := context.Background() namespace := "test-namespace" - transport := &mockTransport{ - responses: map[string]mockResponse{ - "DELETE /api/v1/namespaces/test-namespace/pods/delete-pod": { - statusCode: 200, - body: `{"kind":"Status","apiVersion":"v1","metadata":{},"status":"Success"}`, + client := NewClientGVR[*corev1.Pod]( + schema.GroupVersionResource{Group: "", Version: "v1", Resource: "pods"}, + &rest.Config{ + Host: "http://localhost", + APIPath: "/api", + Transport: &mockTransport{ + responses: map[string]mockResponse{ + "DELETE /api/v1/namespaces/test-namespace/pods/delete-pod": { + statusCode: 200, + body: `{"kind":"Status","apiVersion":"v1","metadata":{},"status":"Success"}`, + }, + }, + }, + ContentConfig: rest.ContentConfig{ + GroupVersion: &schema.GroupVersion{Version: "v1"}, + NegotiatedSerializer: serializer.NewCodecFactory(runtime.NewScheme()).WithoutConversion(), }, }, - } - - config := &rest.Config{ - Host: "http://localhost", - APIPath: "/api", - Transport: transport, - ContentConfig: rest.ContentConfig{ - GroupVersion: &schema.GroupVersion{Version: "v1"}, - NegotiatedSerializer: serializer.NewCodecFactory(runtime.NewScheme()).WithoutConversion(), - }, - } - - restClient, err := rest.RESTClientFor(config) - if err != nil { - t.Fatal(err) - } - - gvr := schema.GroupVersionResource{ - Group: "", - Version: "v1", - Resource: "pods", - } - - client := Client[*corev1.Pod]{ - gvr: gvr, - restClient: restClient, - } + ) // Delete the pod if err := client.Delete(ctx, namespace, "delete-pod", nil); err != nil { @@ -469,40 +392,25 @@ func TestPatch(t *testing.T) { podJSON, _ := json.Marshal(pod) - transport := &mockTransport{ - responses: map[string]mockResponse{ - "PATCH /api/v1/namespaces/test-namespace/pods/patch-pod": { - statusCode: 200, - body: string(podJSON), + client := NewClientGVR[*corev1.Pod]( + schema.GroupVersionResource{Group: "", Version: "v1", Resource: "pods"}, + &rest.Config{ + Host: "http://localhost", + APIPath: "/api", + Transport: &mockTransport{ + responses: map[string]mockResponse{ + "PATCH /api/v1/namespaces/test-namespace/pods/patch-pod": { + statusCode: 200, + body: string(podJSON), + }, + }, + }, + ContentConfig: rest.ContentConfig{ + GroupVersion: &schema.GroupVersion{Version: "v1"}, + NegotiatedSerializer: serializer.NewCodecFactory(runtime.NewScheme()).WithoutConversion(), }, }, - } - - config := &rest.Config{ - Host: "http://localhost", - APIPath: "/api", - Transport: transport, - ContentConfig: rest.ContentConfig{ - GroupVersion: &schema.GroupVersion{Version: "v1"}, - NegotiatedSerializer: serializer.NewCodecFactory(runtime.NewScheme()).WithoutConversion(), - }, - } - - restClient, err := rest.RESTClientFor(config) - if err != nil { - t.Fatal(err) - } - - gvr := schema.GroupVersionResource{ - Group: "", - Version: "v1", - Resource: "pods", - } - - client := Client[*corev1.Pod]{ - gvr: gvr, - restClient: restClient, - } + ) // Create a JSON patch patchData := []byte(`{"op": "add", "path": "/metadata/labels/environment", "value": "production"}`) @@ -541,40 +449,25 @@ func TestGenericWithConfigMap(t *testing.T) { listJSON, _ := json.Marshal(cmList) - transport := &mockTransport{ - responses: map[string]mockResponse{ - "GET /api/v1/namespaces/test-namespace/configmaps": { - statusCode: 200, - body: string(listJSON), + client := NewClientGVR[*corev1.ConfigMap]( + schema.GroupVersionResource{Group: "", Version: "v1", Resource: "configmaps"}, + &rest.Config{ + Host: "http://localhost", + APIPath: "/api", + Transport: &mockTransport{ + responses: map[string]mockResponse{ + "GET /api/v1/namespaces/test-namespace/configmaps": { + statusCode: 200, + body: string(listJSON), + }, + }, + }, + ContentConfig: rest.ContentConfig{ + GroupVersion: &schema.GroupVersion{Version: "v1"}, + NegotiatedSerializer: serializer.NewCodecFactory(runtime.NewScheme()).WithoutConversion(), }, }, - } - - config := &rest.Config{ - Host: "http://localhost", - APIPath: "/api", - Transport: transport, - ContentConfig: rest.ContentConfig{ - GroupVersion: &schema.GroupVersion{Version: "v1"}, - NegotiatedSerializer: serializer.NewCodecFactory(runtime.NewScheme()).WithoutConversion(), - }, - } - - restClient, err := rest.RESTClientFor(config) - if err != nil { - t.Fatal(err) - } - - gvr := schema.GroupVersionResource{ - Group: "", - Version: "v1", - Resource: "configmaps", - } - - client := Client[*corev1.ConfigMap]{ - gvr: gvr, - restClient: restClient, - } + ) // Test List configs, err := client.List(ctx, namespace, nil) @@ -657,40 +550,25 @@ func TestListWithLabelSelector(t *testing.T) { listJSON, _ := json.Marshal(podList) - transport := &mockTransport{ - responses: map[string]mockResponse{ - "GET /api/v1/namespaces/test-namespace/pods?labelSelector=app%3Dtest": { - statusCode: 200, - body: string(listJSON), + client := NewClientGVR[*corev1.Pod]( + schema.GroupVersionResource{Group: "", Version: "v1", Resource: "pods"}, + &rest.Config{ + Host: "http://localhost", + APIPath: "/api", + Transport: &mockTransport{ + responses: map[string]mockResponse{ + "GET /api/v1/namespaces/test-namespace/pods?labelSelector=app%3Dtest": { + statusCode: 200, + body: string(listJSON), + }, + }, + }, + ContentConfig: rest.ContentConfig{ + GroupVersion: &schema.GroupVersion{Version: "v1"}, + NegotiatedSerializer: serializer.NewCodecFactory(runtime.NewScheme()).WithoutConversion(), }, }, - } - - config := &rest.Config{ - Host: "http://localhost", - APIPath: "/api", - Transport: transport, - ContentConfig: rest.ContentConfig{ - GroupVersion: &schema.GroupVersion{Version: "v1"}, - NegotiatedSerializer: serializer.NewCodecFactory(runtime.NewScheme()).WithoutConversion(), - }, - } - - restClient, err := rest.RESTClientFor(config) - if err != nil { - t.Fatal(err) - } - - gvr := schema.GroupVersionResource{ - Group: "", - Version: "v1", - Resource: "pods", - } - - client := Client[*corev1.Pod]{ - gvr: gvr, - restClient: restClient, - } + ) // List with label selector pods, err := client.List(ctx, namespace, &metav1.ListOptions{ @@ -738,40 +616,25 @@ func TestListWithFieldSelector(t *testing.T) { listJSON, _ := json.Marshal(podList) - transport := &mockTransport{ - responses: map[string]mockResponse{ - "GET /api/v1/namespaces/test-namespace/pods?fieldSelector=status.phase%3DRunning": { - statusCode: 200, - body: string(listJSON), + client := NewClientGVR[*corev1.Pod]( + schema.GroupVersionResource{Group: "", Version: "v1", Resource: "pods"}, + &rest.Config{ + Host: "http://localhost", + APIPath: "/api", + Transport: &mockTransport{ + responses: map[string]mockResponse{ + "GET /api/v1/namespaces/test-namespace/pods?fieldSelector=status.phase%3DRunning": { + statusCode: 200, + body: string(listJSON), + }, + }, + }, + ContentConfig: rest.ContentConfig{ + GroupVersion: &schema.GroupVersion{Version: "v1"}, + NegotiatedSerializer: serializer.NewCodecFactory(runtime.NewScheme()).WithoutConversion(), }, }, - } - - config := &rest.Config{ - Host: "http://localhost", - APIPath: "/api", - Transport: transport, - ContentConfig: rest.ContentConfig{ - GroupVersion: &schema.GroupVersion{Version: "v1"}, - NegotiatedSerializer: serializer.NewCodecFactory(runtime.NewScheme()).WithoutConversion(), - }, - } - - restClient, err := rest.RESTClientFor(config) - if err != nil { - t.Fatal(err) - } - - gvr := schema.GroupVersionResource{ - Group: "", - Version: "v1", - Resource: "pods", - } - - client := Client[*corev1.Pod]{ - gvr: gvr, - restClient: restClient, - } + ) // List with field selector pods, err := client.List(ctx, namespace, &metav1.ListOptions{ @@ -789,3 +652,185 @@ func TestListWithFieldSelector(t *testing.T) { t.Errorf("expected running-pod, got %s", pods[0].Name) } } + +// TestWatch tests the Watch method +func TestWatch(t *testing.T) { + ctx := context.Background() + namespace := "test-namespace" + + transport := &mockTransport{ + responses: map[string]mockResponse{ + "GET /api/v1/namespaces/test-namespace/pods?watch=true": { + statusCode: 200, + body: "", + }, + }, + } + + client := NewClientGVR[*corev1.Pod]( + schema.GroupVersionResource{Group: "", Version: "v1", Resource: "pods"}, + &rest.Config{ + Host: "http://localhost", + APIPath: "/api", + Transport: transport, + ContentConfig: rest.ContentConfig{ + GroupVersion: &schema.GroupVersion{Version: "v1"}, + NegotiatedSerializer: serializer.NewCodecFactory(runtime.NewScheme()).WithoutConversion(), + }, + }, + ) + + // Test basic watch + watcher, err := client.Watch(ctx, namespace, nil) + if err != nil { + t.Fatalf("Watch failed: %v", err) + } + if watcher == nil { + t.Error("expected non-nil watcher") + } + + // Test watch with label selector + transport.responses["GET /api/v1/namespaces/test-namespace/pods?labelSelector=app%3Dtest&watch=true"] = mockResponse{ + statusCode: 200, + body: "", + } + + watcher2, err := client.Watch(ctx, namespace, &metav1.ListOptions{ + LabelSelector: "app=test", + }) + if err != nil { + t.Fatalf("Watch with selector failed: %v", err) + } + if watcher2 == nil { + t.Error("expected non-nil watcher with selector") + } +} + +// TestDeleteCollection tests the DeleteCollection method +func TestDeleteCollection(t *testing.T) { + ctx := context.Background() + namespace := "test-namespace" + + transport := &mockTransport{ + responses: map[string]mockResponse{ + "DELETE /api/v1/namespaces/test-namespace/pods": { + statusCode: 200, + body: `{"kind":"Status","apiVersion":"v1","metadata":{},"status":"Success"}`, + }, + }, + } + + client := NewClientGVR[*corev1.Pod]( + schema.GroupVersionResource{Group: "", Version: "v1", Resource: "pods"}, + &rest.Config{ + Host: "http://localhost", + APIPath: "/api", + Transport: transport, + ContentConfig: rest.ContentConfig{ + GroupVersion: &schema.GroupVersion{Version: "v1"}, + NegotiatedSerializer: serializer.NewCodecFactory(runtime.NewScheme()).WithoutConversion(), + }, + }, + ) + + // Test basic delete collection + err := client.DeleteCollection(ctx, namespace, nil, nil) + if err != nil { + t.Fatalf("DeleteCollection failed: %v", err) + } + + // Test delete collection with label selector + transport.responses["DELETE /api/v1/namespaces/test-namespace/pods?labelSelector=app%3Dtest"] = mockResponse{ + statusCode: 200, + body: `{"kind":"Status","apiVersion":"v1","metadata":{},"status":"Success"}`, + } + + err = client.DeleteCollection(ctx, namespace, nil, &metav1.ListOptions{ + LabelSelector: "app=test", + }) + if err != nil { + t.Fatalf("DeleteCollection with selector failed: %v", err) + } +} + +// TestUpdateStatus tests the UpdateStatus method +func TestUpdateStatus(t *testing.T) { + ctx := context.Background() + namespace := "test-namespace" + + updatedPod := &corev1.Pod{ + TypeMeta: metav1.TypeMeta{ + Kind: "Pod", + APIVersion: "v1", + }, + ObjectMeta: metav1.ObjectMeta{ + Name: "test-pod", + Namespace: namespace, + }, + Status: corev1.PodStatus{ + Phase: corev1.PodRunning, + Conditions: []corev1.PodCondition{ + { + Type: corev1.PodReady, + Status: corev1.ConditionTrue, + }, + }, + }, + } + + podJSON, _ := json.Marshal(updatedPod) + + client := NewClientGVR[*corev1.Pod]( + schema.GroupVersionResource{Group: "", Version: "v1", Resource: "pods"}, + &rest.Config{ + Host: "http://localhost", + APIPath: "/api", + Transport: &mockTransport{ + responses: map[string]mockResponse{ + "PUT /api/v1/namespaces/test-namespace/pods/test-pod/status": { + statusCode: 200, + body: string(podJSON), + }, + }, + }, + ContentConfig: rest.ContentConfig{ + GroupVersion: &schema.GroupVersion{Version: "v1"}, + NegotiatedSerializer: serializer.NewCodecFactory(runtime.NewScheme()).WithoutConversion(), + }, + }, + ) + + // Update status + pod := &corev1.Pod{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-pod", + Namespace: namespace, + }, + Status: corev1.PodStatus{ + Phase: corev1.PodRunning, + Conditions: []corev1.PodCondition{ + { + Type: corev1.PodReady, + Status: corev1.ConditionTrue, + }, + }, + }, + } + + result, err := client.UpdateStatus(ctx, namespace, pod, nil) + if err != nil { + t.Fatalf("UpdateStatus failed: %v", err) + } + + if result.Name != "test-pod" { + t.Errorf("expected pod name 'test-pod', got '%s'", result.Name) + } + + if result.Status.Phase != corev1.PodRunning { + t.Errorf("expected status phase Running, got %s", result.Status.Phase) + } + + if len(result.Status.Conditions) != 1 || result.Status.Conditions[0].Type != corev1.PodReady { + t.Error("expected Ready condition in status") + } +} diff --git a/generic/e2e_test.go b/generic/e2e_test.go index 29baae5..0adf479 100644 --- a/generic/e2e_test.go +++ b/generic/e2e_test.go @@ -5,6 +5,7 @@ package generic import ( "context" + "encoding/json" "testing" "time" @@ -26,69 +27,61 @@ func TestInferGVRE2E(t *testing.T) { t.Fatalf("failed to load kubeconfig: %v", err) } - tests := []struct { + 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: "Pod", + inferFunc: func() (schema.GroupVersionResource, error) { + return inferGVR[*corev1.Pod](config) }, - { - name: "ConfigMap", - inferFunc: func() (schema.GroupVersionResource, error) { - return inferGVR[*corev1.ConfigMap](config) - }, - expectedGVR: schema.GroupVersionResource{ - Group: "", - Version: "v1", - Resource: "configmaps", - }, + expectedGVR: schema.GroupVersionResource{ + Group: "", + Version: "v1", + Resource: "pods", }, - { - name: "Service", - inferFunc: func() (schema.GroupVersionResource, error) { - return inferGVR[*corev1.Service](config) - }, - expectedGVR: schema.GroupVersionResource{ - Group: "", - Version: "v1", - Resource: "services", - }, + }, { + name: "ConfigMap", + inferFunc: func() (schema.GroupVersionResource, error) { + return inferGVR[*corev1.ConfigMap](config) }, - { - name: "Secret", - inferFunc: func() (schema.GroupVersionResource, error) { - return inferGVR[*corev1.Secret](config) - }, - expectedGVR: schema.GroupVersionResource{ - Group: "", - Version: "v1", - Resource: "secrets", - }, + expectedGVR: schema.GroupVersionResource{ + Group: "", + Version: "v1", + Resource: "configmaps", }, - { - name: "Namespace", - inferFunc: func() (schema.GroupVersionResource, error) { - return inferGVR[*corev1.Namespace](config) - }, - expectedGVR: schema.GroupVersionResource{ - Group: "", - Version: "v1", - Resource: "namespaces", - }, + }, { + name: "Service", + inferFunc: func() (schema.GroupVersionResource, error) { + return inferGVR[*corev1.Service](config) }, - } - - for _, tt := range tests { + 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 { @@ -222,7 +215,7 @@ func TestInformE2E(t *testing.T) { } }, OnError: func(obj any, err error) { - t.Logf("Informer error: %v for object %v", err, obj) + t.Fatalf("Informer error: %v for object %v", err, obj) }, } @@ -289,3 +282,239 @@ func TestInformE2E(t *testing.T) { } } } + +// 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)) + }) +} diff --git a/generic/expansions.go b/generic/expansions.go new file mode 100644 index 0000000..94ab29c --- /dev/null +++ b/generic/expansions.go @@ -0,0 +1,121 @@ +package generic + +import ( + "context" + + corev1 "k8s.io/api/core/v1" + policyv1 "k8s.io/api/policy/v1" + policyv1beta1 "k8s.io/api/policy/v1beta1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/client-go/kubernetes/scheme" + typedcorev1 "k8s.io/client-go/kubernetes/typed/core/v1" + "k8s.io/client-go/rest" +) + +// PodClient provides a namespace-scoped pod client that implements typedcorev1.PodExpansion. +// This matches the client-go pattern where PodInterface is already namespace-scoped. +type PodClient struct { + client Client[*corev1.Pod] + namespace string +} + +// Ensure we implement the interface +var _ typedcorev1.PodExpansion = PodClient{} + +// GetLogs returns a request for the logs of a pod. +// This matches the signature from k8s.io/client-go/kubernetes/typed/core/v1 +func (p PodClient) GetLogs(name string, opts *corev1.PodLogOptions) *rest.Request { + req := p.client.SubResource(p.namespace, name, "log") + if opts != nil { + req = req.VersionedParams(opts, scheme.ParameterCodec) + } + return req +} + +// Bind binds a pod to a node. +// This matches the signature from k8s.io/client-go/kubernetes/typed/core/v1 +func (p PodClient) Bind(ctx context.Context, binding *corev1.Binding, opts metav1.CreateOptions) error { + body, err := p.client.RESTClient().Post(). + Namespace(p.namespace). + Resource("pods"). + Name(binding.Name). + SubResource("binding"). + VersionedParams(&opts, scheme.ParameterCodec). + Body(binding). + Do(ctx). + Raw() + if err != nil { + return err + } + // binding returns empty response + _ = body + return nil +} + +// Evict evicts a pod using policy/v1beta1 API. +// This matches the signature from k8s.io/client-go/kubernetes/typed/core/v1 +func (p PodClient) Evict(ctx context.Context, eviction *policyv1beta1.Eviction) error { + return p.client.RESTClient().Post(). + Namespace(p.namespace). + Resource("pods"). + Name(eviction.Name). + SubResource("eviction"). + Body(eviction). + Do(ctx). + Error() +} + +// EvictV1 evicts a pod using policy/v1 API. +func (p PodClient) EvictV1(ctx context.Context, eviction *policyv1.Eviction) error { + return p.client.RESTClient().Post(). + Namespace(p.namespace). + Resource("pods"). + Name(eviction.Name). + SubResource("eviction"). + Body(eviction). + Do(ctx). + Error() +} + +// EvictV1beta1 evicts a pod using policy/v1beta1 API. +func (p PodClient) EvictV1beta1(ctx context.Context, eviction *policyv1beta1.Eviction) error { + return p.Evict(ctx, eviction) +} + +// ProxyGet returns a proxy connection to the pod. +func (p PodClient) ProxyGet(scheme, name, port, path string, params map[string]string) rest.ResponseWrapper { + request := p.client.RESTClient().Get(). + Namespace(p.namespace). + Resource("pods"). + Name(name). + SubResource("proxy"). + Suffix(path) + for k, v := range params { + request = request.Param(k, v) + } + return request +} + +// ServiceClient provides a namespace-scoped service client that implements typedcorev1.ServiceExpansion. +// This matches the client-go pattern where ServiceInterface is already namespace-scoped. +type ServiceClient struct { + client Client[*corev1.Service] + namespace string +} + +// Ensure we implement the interface +var _ typedcorev1.ServiceExpansion = ServiceClient{} + +// ProxyGet returns a proxy connection to the service. +func (s ServiceClient) ProxyGet(scheme, name, port, path string, params map[string]string) rest.ResponseWrapper { + request := s.client.RESTClient().Get(). + Namespace(s.namespace). + Resource("services"). + Name(name). + SubResource("proxy"). + Suffix(path) + for k, v := range params { + request = request.Param(k, v) + } + return request +} diff --git a/generic/expansions_test.go b/generic/expansions_test.go new file mode 100644 index 0000000..7875961 --- /dev/null +++ b/generic/expansions_test.go @@ -0,0 +1,361 @@ +package generic + +import ( + "context" + "io" + "net/http" + "strings" + "testing" + + corev1 "k8s.io/api/core/v1" + policyv1beta1 "k8s.io/api/policy/v1beta1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime/schema" + "k8s.io/client-go/rest" +) + +func TestPodClientGetLogs(t *testing.T) { + mt := &mockTransport{ + responses: map[string]mockResponse{ + "GET /api/v1/namespaces/default/pods/test-pod/log": { + statusCode: http.StatusOK, + body: "2024-01-01 12:00:00 Starting application...\n2024-01-01 12:00:01 Application ready", + }, + "GET /api/v1/namespaces/default/pods/test-pod/log?container=sidecar&tailLines=10": { + statusCode: http.StatusOK, + body: "Sidecar container logs here", + }, + }, + } + + client := NewClientGVR[*corev1.Pod]( + schema.GroupVersionResource{Group: "", Version: "v1", Resource: "pods"}, + &rest.Config{ + Host: "http://localhost:8080", + Transport: mt, + }, + ).PodClient("default") + + ctx := context.Background() + + t.Run("get logs without options", func(t *testing.T) { + req := client.GetLogs("test-pod", nil) + body, err := req.DoRaw(ctx) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + expected := "2024-01-01 12:00:00 Starting application...\n2024-01-01 12:00:01 Application ready" + if string(body) != expected { + t.Errorf("expected logs %q, got %q", expected, string(body)) + } + }) + + t.Run("get logs with options", func(t *testing.T) { + tailLines := int64(10) + opts := &corev1.PodLogOptions{ + Container: "sidecar", + TailLines: &tailLines, + } + req := client.GetLogs("test-pod", opts) + body, err := req.DoRaw(ctx) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + expected := "Sidecar container logs here" + if string(body) != expected { + t.Errorf("expected logs %q, got %q", expected, string(body)) + } + }) + + t.Run("stream logs", func(t *testing.T) { + mt.responses["GET /api/v1/namespaces/default/pods/streaming-pod/log?follow=true"] = mockResponse{ + statusCode: http.StatusOK, + body: "Line 1\nLine 2\nLine 3", + } + + follow := true + opts := &corev1.PodLogOptions{ + Follow: follow, + } + req := client.GetLogs("streaming-pod", opts) + stream, err := req.Stream(ctx) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + defer func() { + if err := stream.Close(); err != nil { + t.Fatalf("failed to close stream: %v", err) + } + }() + + data, err := io.ReadAll(stream) + if err != nil { + t.Fatalf("failed to read stream: %v", err) + } + + expected := "Line 1\nLine 2\nLine 3" + if string(data) != expected { + t.Errorf("expected stream data %q, got %q", expected, string(data)) + } + }) +} + +func TestPodClientBind(t *testing.T) { + client := NewClientGVR[*corev1.Pod]( + schema.GroupVersionResource{Group: "", Version: "v1", Resource: "pods"}, + &rest.Config{ + Host: "http://localhost:8080", + Transport: &mockTransport{ + responses: map[string]mockResponse{ + "POST /api/v1/namespaces/default/pods/test-pod/binding": { + statusCode: http.StatusCreated, + body: "", + }, + }, + }, + }, + ).PodClient("default") + + ctx := context.Background() + + binding := &corev1.Binding{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-pod", + }, + Target: corev1.ObjectReference{ + Kind: "Node", + Name: "test-node", + }, + } + + err := client.Bind(ctx, binding, metav1.CreateOptions{}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestPodClientEvict(t *testing.T) { + client := NewClientGVR[*corev1.Pod]( + schema.GroupVersionResource{Group: "", Version: "v1", Resource: "pods"}, + &rest.Config{ + Host: "http://localhost:8080", + Transport: &mockTransport{ + responses: map[string]mockResponse{ + "POST /api/v1/namespaces/default/pods/test-pod/eviction": { + statusCode: http.StatusCreated, + body: "", + }, + }, + }, + }, + ).PodClient("default") + + ctx := context.Background() + + eviction := &policyv1beta1.Eviction{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-pod", + Namespace: "default", + }, + } + + err := client.Evict(ctx, eviction) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestPodClientProxyGet(t *testing.T) { + client := NewClientGVR[*corev1.Pod]( + schema.GroupVersionResource{Group: "", Version: "v1", Resource: "pods"}, + &rest.Config{ + Host: "http://localhost:8080", + Transport: &mockTransport{ + responses: map[string]mockResponse{ + "GET /api/v1/namespaces/default/pods/test-pod/proxy/healthz": { + statusCode: http.StatusOK, + body: "ok", + }, + "GET /api/v1/namespaces/default/pods/test-pod/proxy/metrics?format=json": { + statusCode: http.StatusOK, + body: `{"cpu": "100m", "memory": "256Mi"}`, + }, + }, + }, + }, + ).PodClient("default") + + ctx := context.Background() + + t.Run("proxy get health", func(t *testing.T) { + req := client.ProxyGet("http", "test-pod", "8080", "healthz", nil) + body, err := req.DoRaw(ctx) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if string(body) != "ok" { + t.Errorf("expected response 'ok', got %q", string(body)) + } + }) + + t.Run("proxy get with params", func(t *testing.T) { + params := map[string]string{ + "format": "json", + } + req := client.ProxyGet("http", "test-pod", "8080", "metrics", params) + body, err := req.DoRaw(ctx) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + expected := `{"cpu": "100m", "memory": "256Mi"}` + if string(body) != expected { + t.Errorf("expected response %q, got %q", expected, string(body)) + } + }) +} + +func TestServiceClientProxyGet(t *testing.T) { + client := NewClientGVR[*corev1.Service]( + schema.GroupVersionResource{Group: "", Version: "v1", Resource: "services"}, + &rest.Config{ + Host: "http://localhost:8080", + Transport: &mockTransport{ + responses: map[string]mockResponse{ + "GET /api/v1/namespaces/default/services/test-service/proxy/api/v1/health": { + statusCode: http.StatusOK, + body: `{"status": "healthy"}`, + }, + }, + }, + }, + ).ServiceClient("default") + + ctx := context.Background() + + req := client.ProxyGet("http", "test-service", "80", "api/v1/health", nil) + body, err := req.DoRaw(ctx) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + expected := `{"status": "healthy"}` + if string(body) != expected { + t.Errorf("expected response %q, got %q", expected, string(body)) + } +} + +func TestPodClientMethod(t *testing.T) { + // Create a generic client for pods + genericPodClient := NewClientGVR[*corev1.Pod]( + schema.GroupVersionResource{Group: "", Version: "v1", Resource: "pods"}, + &rest.Config{Host: "http://localhost:8080"}, + ) + + // Get PodClient from the generic client + podClient := genericPodClient.PodClient("default") + + // Verify the client has the correct GVR + if podClient.client.gvr.Resource != "pods" { + t.Errorf("expected resource 'pods', got %q", podClient.client.gvr.Resource) + } +} + +func TestPodClientMethodPanics(t *testing.T) { + // Test that PodClient() panics on non-pod types + genericCMClient := NewClientGVR[*corev1.ConfigMap]( + schema.GroupVersionResource{Group: "", Version: "v1", Resource: "configmaps"}, + &rest.Config{Host: "http://localhost:8080"}, + ) + + // This should panic + defer func() { + if r := recover(); r == nil { + t.Errorf("expected panic when calling PodClient() on ConfigMap client") + } else { + // Verify the panic message + if msg, ok := r.(string); ok { + if !strings.Contains(msg, "PodClient() can only be called on Client[*corev1.Pod]") { + t.Errorf("unexpected panic message: %s", msg) + } + } + } + }() + + // This should panic + _ = genericCMClient.PodClient("default") +} + +func TestServiceClientMethod(t *testing.T) { + // Test that ServiceClient() works on a service client + genericSvcClient := NewClientGVR[*corev1.Service]( + schema.GroupVersionResource{Group: "", Version: "v1", Resource: "services"}, + &rest.Config{Host: "http://localhost:8080"}, + ) + + // Get ServiceClient from the generic client + svcClient := genericSvcClient.ServiceClient("default") + + // Verify the client has the correct GVR + if svcClient.client.gvr.Resource != "services" { + t.Errorf("expected resource 'services', got %q", svcClient.client.gvr.Resource) + } +} + +func TestServiceClientMethodPanics(t *testing.T) { + // Test that ServiceClient() panics on non-service types + genericCMClient := NewClientGVR[*corev1.ConfigMap]( + schema.GroupVersionResource{Group: "", Version: "v1", Resource: "configmaps"}, + &rest.Config{Host: "http://localhost:8080"}, + ) + + // This should panic + defer func() { + if r := recover(); r == nil { + t.Errorf("expected panic when calling ServiceClient() on ConfigMap client") + } else { + // Verify the panic message + if msg, ok := r.(string); ok { + if !strings.Contains(msg, "ServiceClient() can only be called on Client[*corev1.Service]") { + t.Errorf("unexpected panic message: %s", msg) + } + } + } + }() + + // This should panic + _ = genericCMClient.ServiceClient("default") +} + +func TestSubResource(t *testing.T) { + client := NewClientGVR[*corev1.Pod]( + schema.GroupVersionResource{Group: "", Version: "v1", Resource: "pods"}, + &rest.Config{ + Host: "http://localhost:8080", + Transport: &mockTransport{ + responses: map[string]mockResponse{ + "GET /api/v1/namespaces/default/pods/test-pod/status": { + statusCode: http.StatusOK, + body: `{"status": {"phase": "Running"}}`, + }, + }, + }, + }, + ) + + ctx := context.Background() + + // Test generic subresource access + req := client.SubResource("default", "test-pod", "status") + body, err := req.DoRaw(ctx) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if !strings.Contains(string(body), "Running") { + t.Errorf("expected status to contain 'Running', got %q", string(body)) + } +} diff --git a/go.mod b/go.mod index 55c903b..71ff598 100644 --- a/go.mod +++ b/go.mod @@ -28,6 +28,7 @@ require ( github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect github.com/modern-go/reflect2 v1.0.2 // indirect github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect + github.com/pkg/errors v0.9.1 // indirect github.com/spf13/pflag v1.0.5 // indirect github.com/x448/float16 v0.8.4 // indirect golang.org/x/net v0.38.0 // indirect @@ -37,6 +38,7 @@ require ( golang.org/x/text v0.23.0 // indirect golang.org/x/time v0.9.0 // indirect google.golang.org/protobuf v1.36.5 // indirect + gopkg.in/evanphx/json-patch.v4 v4.12.0 // indirect gopkg.in/inf.v0 v0.9.1 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect k8s.io/klog/v2 v2.130.1 // indirect diff --git a/main.go b/main.go index c98ac98..1bd1793 100644 --- a/main.go +++ b/main.go @@ -243,4 +243,75 @@ func main() { for _, cm := range cms { log.Println("-", cm.Name) } + + // Demonstrate Pod expansion methods + log.Println("\n=== POD EXPANSION EXAMPLE ===") + + // Get a namespace-scoped PodClient with expansion methods from the existing pod client + expandedPodClient := podClient.PodClient("kube-system") + + // List pods to find one to get logs from + dnsPods, err := podClient.List(ctx, "kube-system", &metav1.ListOptions{ + LabelSelector: "k8s-app=kube-dns", + }) + if err != nil { + log.Printf("Error listing pods: %v", err) + } else if len(dnsPods) > 0 { + // Get logs from the first CoreDNS pod + pod := dnsPods[0] + log.Printf("\nGETTING LOGS FROM POD %s", pod.Name) + + // Get last 5 lines of logs + tailLines := int64(5) + logOpts := &corev1.PodLogOptions{ + TailLines: &tailLines, + } + + req := expandedPodClient.GetLogs(pod.Name, logOpts) + logs, err := req.DoRaw(ctx) + if err != nil { + log.Printf("Error getting logs: %v", err) + } else { + log.Println("Last 5 lines of logs:") + log.Println(string(logs)) + } + + // If the pod has multiple containers, get logs from a specific container + if len(pod.Spec.Containers) > 0 { + containerName := pod.Spec.Containers[0].Name + log.Printf("\nGETTING LOGS FROM CONTAINER %s", containerName) + + containerLogOpts := &corev1.PodLogOptions{ + Container: containerName, + TailLines: &tailLines, + } + + req := expandedPodClient.GetLogs(pod.Name, containerLogOpts) + logs, err := req.DoRaw(ctx) + if err != nil { + log.Printf("Error getting container logs: %v", err) + } else { + log.Printf("Last 5 lines from container %s:", containerName) + log.Println(string(logs)) + } + } + } else { + log.Println("No CoreDNS pods found to demonstrate GetLogs") + } + + // Demonstrate using the generic SubResource method + log.Println("\n=== SUBRESOURCE EXAMPLE ===") + if len(dnsPods) > 0 { + pod := dnsPods[0] + log.Printf("Getting status subresource for pod %s", pod.Name) + + req := podClient.SubResource(pod.Namespace, pod.Name, "status") + statusBytes, err := req.DoRaw(ctx) + if err != nil { + log.Printf("Error getting pod status: %v", err) + } else { + // Just show that we got data (full status would be verbose) + log.Printf("Got pod status (%d bytes)", len(statusBytes)) + } + } }