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:
parent
63505cf9fd
commit
e79b66caf5
9 changed files with 1458 additions and 366 deletions
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
121
generic/expansions.go
Normal 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
361
generic/expansions_test.go
Normal 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))
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue