1
0
Fork 0
mirror of https://github.com/imjasonh/client-go2 synced 2026-07-08 09:05:38 +00:00

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>
This commit is contained in:
Jason Hall 2025-07-28 10:32:34 -04:00
parent 63505cf9fd
commit e79b66caf5
Failed to extract signature
9 changed files with 1458 additions and 366 deletions

View file

@ -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)

View file

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

View file

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

121
generic/expansions.go Normal file
View file

@ -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
}

361
generic/expansions_test.go Normal file
View file

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