mirror of
https://github.com/imjasonh/client-go2
synced 2026-07-18 06:37:34 +00:00
Update to use stable Go and add comprehensive CI/CD
- Replace gotip with stable Go in GitHub Actions - Add automatic GVR inference for Kubernetes types - Rename API: NewClient (inferred GVR) and NewClientGVR (explicit) - Add comprehensive test coverage including e2e tests - Update CI to include linting, unit tests, and e2e tests with kind - Add .gitignore for common Go development files - Update README with usage examples and features 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
parent
d6bb3cc61c
commit
74aace1a2d
7 changed files with 554 additions and 122 deletions
46
.github/workflows/build.yaml
vendored
46
.github/workflows/build.yaml
vendored
|
|
@ -3,23 +3,47 @@ name: Build
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
branches: ['main']
|
branches: ['main']
|
||||||
|
pull_request:
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
build:
|
build:
|
||||||
name: build
|
name: build
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v2
|
- uses: actions/checkout@v4
|
||||||
- uses: actions/setup-go@v2
|
|
||||||
|
- uses: actions/setup-go@v5
|
||||||
with:
|
with:
|
||||||
go-version: '1.17.x'
|
go-version: 'stable'
|
||||||
- uses: engineerd/setup-kind@v0.5.0
|
|
||||||
- run: |
|
|
||||||
go install golang.org/dl/gotip@latest
|
|
||||||
gotip download
|
|
||||||
|
|
||||||
gotip build ./
|
- run: go build ./...
|
||||||
gotip run ./
|
- run: go vet ./...
|
||||||
gotip run ./
|
|
||||||
|
- run: |
|
||||||
|
if [ "$(gofmt -s -l . | wc -l)" -gt 0 ]; then
|
||||||
|
echo "Code is not formatted. Run 'gofmt -s -w .'"
|
||||||
|
gofmt -s -d .
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
- run: |
|
||||||
|
go mod tidy
|
||||||
|
git diff --exit-code go.mod go.sum
|
||||||
|
|
||||||
|
- name: Run unit tests
|
||||||
|
run: go test ./... -v -race
|
||||||
|
|
||||||
|
- uses: chainguard-dev/actions/setup-kind@main
|
||||||
|
with:
|
||||||
|
k8s-version: v1.33.0
|
||||||
|
|
||||||
|
- run: |
|
||||||
|
kubectl cluster-info
|
||||||
|
kubectl get nodes
|
||||||
|
|
||||||
|
- name: Run e2e tests
|
||||||
|
run: go test ./... -v -tags=e2e
|
||||||
|
|
||||||
|
- name: Run example
|
||||||
|
run: go run ./main.go
|
||||||
|
|
|
||||||
36
.gitignore
vendored
Normal file
36
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,36 @@
|
||||||
|
# Binaries
|
||||||
|
*.exe
|
||||||
|
*.exe~
|
||||||
|
*.dll
|
||||||
|
*.so
|
||||||
|
*.dylib
|
||||||
|
client-go2
|
||||||
|
|
||||||
|
# Test binary, built with `go test -c`
|
||||||
|
*.test
|
||||||
|
|
||||||
|
# Output of the go coverage tool
|
||||||
|
*.out
|
||||||
|
coverage.txt
|
||||||
|
|
||||||
|
# Go workspace file
|
||||||
|
go.work
|
||||||
|
go.work.sum
|
||||||
|
|
||||||
|
# Dependency directories
|
||||||
|
vendor/
|
||||||
|
|
||||||
|
# IDE specific files
|
||||||
|
.idea/
|
||||||
|
.vscode/
|
||||||
|
*.swp
|
||||||
|
*.swo
|
||||||
|
*~
|
||||||
|
|
||||||
|
# OS specific files
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
|
||||||
|
# Temporary files
|
||||||
|
*.tmp
|
||||||
|
*.bak
|
||||||
85
README.md
85
README.md
|
|
@ -4,31 +4,74 @@
|
||||||
|
|
||||||
This is an experimental type-parameter-aware client that wraps [`k8s.io/client-go/dynamic`](https://pkg.go.dev/k8s.io/client-go/dynamic) _(...for now)_.
|
This is an experimental type-parameter-aware client that wraps [`k8s.io/client-go/dynamic`](https://pkg.go.dev/k8s.io/client-go/dynamic) _(...for now)_.
|
||||||
|
|
||||||
Assuming you've got a working kubeconfig (does `kubectl get pods` work?), you can run this code:
|
## Features
|
||||||
|
|
||||||
|
- **Type-safe generic client** - Work with strongly-typed Kubernetes objects instead of `unstructured.Unstructured`
|
||||||
|
- **Automatic GVR inference** - No need to manually specify GroupVersionResource for standard Kubernetes types
|
||||||
|
- **Full CRUD operations** - List, Get, Create, Update, Delete, and Patch support
|
||||||
|
- **Informer support** - Watch for changes with type-safe event handlers
|
||||||
|
- **Zero code generation** - Uses Go generics instead of code generation
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
```go
|
||||||
|
// Create a client that automatically infers the GVR from the type
|
||||||
|
podClient, err := generic.NewClient[*corev1.Pod](config)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// List pods in a namespace
|
||||||
|
pods, err := podClient.List(ctx, "kube-system")
|
||||||
|
if err != nil {
|
||||||
|
log.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create a ConfigMap
|
||||||
|
cmClient, err := generic.NewClient[*corev1.ConfigMap](config)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
err = cmClient.Create(ctx, "default", &corev1.ConfigMap{
|
||||||
|
ObjectMeta: metav1.ObjectMeta{
|
||||||
|
Name: "my-config",
|
||||||
|
},
|
||||||
|
Data: map[string]string{
|
||||||
|
"key": "value",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
// If you need to specify a custom GVR, use NewClientGVR
|
||||||
|
customClient := generic.NewClientGVR[*corev1.Pod](customGVR, config)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Running the Example
|
||||||
|
|
||||||
```
|
```
|
||||||
$ go run ./
|
$ go run ./
|
||||||
2021/12/15 12:08:11 LISTING PODS
|
2025/07/23 11:23:10 LISTING PODS
|
||||||
2021/12/15 12:08:11 - coredns-558bd4d5db-hjs27
|
2025/07/23 11:23:10 - coredns-674b8bbfcf-ddcww
|
||||||
2021/12/15 12:08:11 - coredns-558bd4d5db-vhrtd
|
2025/07/23 11:23:10 - coredns-674b8bbfcf-dqqx5
|
||||||
2021/12/15 12:08:11 - etcd-kind-control-plane
|
2025/07/23 11:23:10 - etcd-kind-control-plane
|
||||||
2021/12/15 12:08:11 - kindnet-c977m
|
2025/07/23 11:23:10 - kindnet-tkf6l
|
||||||
2021/12/15 12:08:11 - kube-apiserver-kind-control-plane
|
2025/07/23 11:23:10 - kube-apiserver-kind-control-plane
|
||||||
2021/12/15 12:08:11 - kube-controller-manager-kind-control-plane
|
2025/07/23 11:23:10 - kube-controller-manager-kind-control-plane
|
||||||
2021/12/15 12:08:11 - kube-proxy-fgpfd
|
2025/07/23 11:23:10 - kube-proxy-76tcd
|
||||||
2021/12/15 12:08:11 - kube-scheduler-kind-control-plane
|
2025/07/23 11:23:10 - kube-scheduler-kind-control-plane
|
||||||
I1215 12:08:11.086322 32526 shared_informer.go:240] Waiting for caches to sync for /v1, Resource=configmaps
|
|
||||||
2021/12/15 12:08:11 --> ADD kube-public/cluster-info
|
|
||||||
2021/12/15 12:08:11 --> ADD kube-system/extension-apiserver-authentication
|
|
||||||
2021/12/15 12:08:11 --> ADD tekton-pipelines/config-logging
|
|
||||||
...
|
...
|
||||||
2021/12/15 12:08:11 LISTING CONFIGMAPS
|
```
|
||||||
2021/12/15 12:08:11 - coredns
|
|
||||||
2021/12/15 12:08:11 - extension-apiserver-authentication
|
## Testing
|
||||||
2021/12/15 12:08:11 - kube-proxy
|
|
||||||
2021/12/15 12:08:11 - kube-root-ca.crt
|
Run unit tests:
|
||||||
2021/12/15 12:08:11 - kubeadm-config
|
```bash
|
||||||
2021/12/15 12:08:11 - kubelet-config-1.21
|
go test ./generic
|
||||||
|
```
|
||||||
|
|
||||||
|
Run e2e tests (requires a Kubernetes cluster):
|
||||||
|
```bash
|
||||||
|
go test ./generic -tags=e2e
|
||||||
```
|
```
|
||||||
|
|
||||||
# THIS IS AN EXPERIMENT
|
# THIS IS AN EXPERIMENT
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,9 @@ import (
|
||||||
"bytes"
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
"log"
|
"log"
|
||||||
|
"reflect"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||||
|
|
@ -12,28 +14,38 @@ import (
|
||||||
"k8s.io/apimachinery/pkg/runtime"
|
"k8s.io/apimachinery/pkg/runtime"
|
||||||
"k8s.io/apimachinery/pkg/runtime/schema"
|
"k8s.io/apimachinery/pkg/runtime/schema"
|
||||||
"k8s.io/apimachinery/pkg/types"
|
"k8s.io/apimachinery/pkg/types"
|
||||||
|
"k8s.io/client-go/discovery"
|
||||||
"k8s.io/client-go/dynamic"
|
"k8s.io/client-go/dynamic"
|
||||||
"k8s.io/client-go/dynamic/dynamicinformer"
|
"k8s.io/client-go/dynamic/dynamicinformer"
|
||||||
|
"k8s.io/client-go/kubernetes/scheme"
|
||||||
"k8s.io/client-go/rest"
|
"k8s.io/client-go/rest"
|
||||||
|
"k8s.io/client-go/restmapper"
|
||||||
"k8s.io/client-go/tools/cache"
|
"k8s.io/client-go/tools/cache"
|
||||||
)
|
)
|
||||||
|
|
||||||
const resyncPeriod = time.Hour
|
const resyncPeriod = time.Hour
|
||||||
|
|
||||||
// Ideally, this wouldn't need to take a GVR, and it could just be inferred
|
// NewClient creates a new generic client by automatically inferring
|
||||||
// from the runtime.Object type given.
|
// the GroupVersionResource from the type parameter T.
|
||||||
|
// This uses the global Kubernetes scheme to look up the GVK for the type,
|
||||||
|
// then uses discovery to map that to a GVR.
|
||||||
//
|
//
|
||||||
// In practice, this doesn't seem to be straightforward. :(
|
// Note: T must be a pointer type (e.g., *corev1.Pod) as required by runtime.Object.
|
||||||
|
// Non-pointer types will fail at compile time.
|
||||||
|
func NewClient[T runtime.Object](config *rest.Config) (client[T], error) {
|
||||||
|
gvr, err := inferGVR[T](config)
|
||||||
|
if err != nil {
|
||||||
|
return client[T]{}, err
|
||||||
|
}
|
||||||
|
return NewClientGVR[T](gvr, config), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewClientGVR creates a new generic client with an explicit GroupVersionResource.
|
||||||
|
// This is useful when you need to specify a custom GVR or when the type isn't
|
||||||
|
// registered in the global scheme.
|
||||||
//
|
//
|
||||||
// Things that implement runtime.Object tend to be pointer types (e.g.,
|
// Most users should prefer NewClient which automatically infers the GVR.
|
||||||
// *corev1.Pod), which means T is nil, and we can't call GetObjectKind() on it
|
func NewClientGVR[T runtime.Object](gvr schema.GroupVersionResource, config *rest.Config) client[T] {
|
||||||
// to start to guess at the GVR.
|
|
||||||
//
|
|
||||||
// It might be possible to use schemes to lookup the GVR for a given Go type
|
|
||||||
// (assuming it's been registered), which could let us get rid of this.
|
|
||||||
// In the meantime, taking a GVR removes ambiguity at the cost of verbosity.
|
|
||||||
// /shrug
|
|
||||||
func NewClient[T runtime.Object](gvr schema.GroupVersionResource, config *rest.Config) client[T] {
|
|
||||||
dyn := dynamic.NewForConfigOrDie(config)
|
dyn := dynamic.NewForConfigOrDie(config)
|
||||||
return client[T]{
|
return client[T]{
|
||||||
gvr: gvr,
|
gvr: gvr,
|
||||||
|
|
@ -42,6 +54,68 @@ func NewClient[T runtime.Object](gvr schema.GroupVersionResource, config *rest.C
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// inferGVR attempts to determine the GroupVersionResource for a given type T
|
||||||
|
// by using the Kubernetes scheme and discovery client.
|
||||||
|
func inferGVR[T runtime.Object](config *rest.Config) (schema.GroupVersionResource, error) {
|
||||||
|
// Create a zero-value instance of T to inspect
|
||||||
|
var zero T
|
||||||
|
typ := reflect.TypeOf(zero)
|
||||||
|
|
||||||
|
// Require pointer types - Kubernetes objects should always be pointers
|
||||||
|
if typ.Kind() != reflect.Ptr {
|
||||||
|
return schema.GroupVersionResource{}, fmt.Errorf("type %T must be a pointer type (e.g., *corev1.Pod, not corev1.Pod)", zero)
|
||||||
|
}
|
||||||
|
|
||||||
|
typ = typ.Elem()
|
||||||
|
// Create a new instance of the underlying type
|
||||||
|
instance := reflect.New(typ).Interface()
|
||||||
|
|
||||||
|
// Try to convert to runtime.Object
|
||||||
|
obj, ok := instance.(runtime.Object)
|
||||||
|
if !ok {
|
||||||
|
return schema.GroupVersionResource{}, fmt.Errorf("type %T does not implement runtime.Object", instance)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get the GVKs for this object from the scheme
|
||||||
|
gvks, _, err := scheme.Scheme.ObjectKinds(obj)
|
||||||
|
if err != nil {
|
||||||
|
return schema.GroupVersionResource{}, fmt.Errorf("failed to get GVK for type %T: %w", zero, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(gvks) == 0 {
|
||||||
|
return schema.GroupVersionResource{}, fmt.Errorf("no GVK registered for type %T", zero)
|
||||||
|
}
|
||||||
|
|
||||||
|
// If multiple match, return an error.
|
||||||
|
if len(gvks) > 1 {
|
||||||
|
return schema.GroupVersionResource{}, fmt.Errorf("multiple GVKs registered for type %T: %v", zero, gvks)
|
||||||
|
}
|
||||||
|
gvk := gvks[0]
|
||||||
|
|
||||||
|
// Create a discovery client to get the REST mapping
|
||||||
|
discoveryClient, err := discovery.NewDiscoveryClientForConfig(config)
|
||||||
|
if err != nil {
|
||||||
|
return schema.GroupVersionResource{}, fmt.Errorf("failed to create discovery client: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get the API group resources
|
||||||
|
groupResources, err := restmapper.GetAPIGroupResources(discoveryClient)
|
||||||
|
if err != nil {
|
||||||
|
return schema.GroupVersionResource{}, fmt.Errorf("failed to get API group resources: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create a REST mapper
|
||||||
|
mapper := restmapper.NewDiscoveryRESTMapper(groupResources)
|
||||||
|
|
||||||
|
// Get the resource mapping for the GVK
|
||||||
|
mapping, err := mapper.RESTMapping(gvk.GroupKind(), gvk.Version)
|
||||||
|
if err != nil {
|
||||||
|
return schema.GroupVersionResource{}, fmt.Errorf("failed to get REST mapping for %v: %w", gvk, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return mapping.Resource, nil
|
||||||
|
}
|
||||||
|
|
||||||
type client[T runtime.Object] struct {
|
type client[T runtime.Object] struct {
|
||||||
gvr schema.GroupVersionResource
|
gvr schema.GroupVersionResource
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -12,10 +12,9 @@ import (
|
||||||
"k8s.io/apimachinery/pkg/types"
|
"k8s.io/apimachinery/pkg/types"
|
||||||
"k8s.io/client-go/dynamic/fake"
|
"k8s.io/client-go/dynamic/fake"
|
||||||
"k8s.io/client-go/rest"
|
"k8s.io/client-go/rest"
|
||||||
"k8s.io/client-go/tools/cache"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestNewClient(t *testing.T) {
|
func TestNewClientGVR(t *testing.T) {
|
||||||
gvr := schema.GroupVersionResource{
|
gvr := schema.GroupVersionResource{
|
||||||
Group: "",
|
Group: "",
|
||||||
Version: "v1",
|
Version: "v1",
|
||||||
|
|
@ -23,7 +22,7 @@ func TestNewClient(t *testing.T) {
|
||||||
}
|
}
|
||||||
|
|
||||||
config := &rest.Config{}
|
config := &rest.Config{}
|
||||||
client := NewClient[*corev1.Pod](gvr, config)
|
client := NewClientGVR[*corev1.Pod](gvr, config)
|
||||||
|
|
||||||
if client.gvr != gvr {
|
if client.gvr != gvr {
|
||||||
t.Errorf("expected GVR %v, got %v", gvr, client.gvr)
|
t.Errorf("expected GVR %v, got %v", gvr, client.gvr)
|
||||||
|
|
@ -347,64 +346,9 @@ func TestPatch(t *testing.T) {
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestInform(t *testing.T) {
|
func TestInform(t *testing.T) {
|
||||||
ctx, cancel := context.WithCancel(context.Background())
|
// Skip this test as it requires complex informer setup that doesn't work well with fake clients
|
||||||
defer cancel()
|
// The informer functionality is better tested with e2e tests against a real cluster
|
||||||
|
t.Skip("Informer testing requires real cluster - see e2e tests")
|
||||||
namespace := "test-namespace"
|
|
||||||
|
|
||||||
pod := &corev1.Pod{
|
|
||||||
ObjectMeta: metav1.ObjectMeta{
|
|
||||||
Name: "inform-pod",
|
|
||||||
Namespace: namespace,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
scheme := runtime.NewScheme()
|
|
||||||
if err := corev1.AddToScheme(scheme); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
dynClient := fake.NewSimpleDynamicClient(scheme, pod)
|
|
||||||
|
|
||||||
gvr := schema.GroupVersionResource{
|
|
||||||
Group: "",
|
|
||||||
Version: "v1",
|
|
||||||
Resource: "pods",
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create client with informer factory
|
|
||||||
config := &rest.Config{}
|
|
||||||
client := NewClient[*corev1.Pod](gvr, config)
|
|
||||||
client.dyn = dynClient // Override with fake client for testing
|
|
||||||
|
|
||||||
// Track events
|
|
||||||
events := make(chan string, 10)
|
|
||||||
|
|
||||||
handler := cache.ResourceEventHandlerFuncs{
|
|
||||||
AddFunc: func(obj interface{}) {
|
|
||||||
events <- "add"
|
|
||||||
},
|
|
||||||
UpdateFunc: func(oldObj, newObj interface{}) {
|
|
||||||
events <- "update"
|
|
||||||
},
|
|
||||||
DeleteFunc: func(obj interface{}) {
|
|
||||||
events <- "delete"
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
// Start informer
|
|
||||||
client.Start(ctx)
|
|
||||||
client.Inform(ctx, handler)
|
|
||||||
|
|
||||||
// Wait for initial sync
|
|
||||||
select {
|
|
||||||
case event := <-events:
|
|
||||||
if event != "add" {
|
|
||||||
t.Errorf("expected add event, got %s", event)
|
|
||||||
}
|
|
||||||
case <-ctx.Done():
|
|
||||||
t.Fatal("context cancelled before receiving event")
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Test with ConfigMap to verify generic behavior
|
// Test with ConfigMap to verify generic behavior
|
||||||
|
|
@ -455,3 +399,20 @@ func TestGenericWithConfigMap(t *testing.T) {
|
||||||
t.Errorf("expected key1=value1, got %s", configs[0].Data["key1"])
|
t.Errorf("expected key1=value1, got %s", configs[0].Data["key1"])
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestNewClientGVRCustomResource tests using NewClientGVR with a custom GVR
|
||||||
|
func TestNewClientGVRCustomResource(t *testing.T) {
|
||||||
|
// Example: Using NewClientGVR for a custom resource that might not be in the scheme
|
||||||
|
customGVR := schema.GroupVersionResource{
|
||||||
|
Group: "custom.io",
|
||||||
|
Version: "v1",
|
||||||
|
Resource: "myresources",
|
||||||
|
}
|
||||||
|
|
||||||
|
config := &rest.Config{}
|
||||||
|
client := NewClientGVR[*corev1.Pod](customGVR, config) // Using Pod type as placeholder
|
||||||
|
|
||||||
|
if client.gvr != customGVR {
|
||||||
|
t.Errorf("expected custom GVR %v, got %v", customGVR, client.gvr)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
300
generic/e2e_test.go
Normal file
300
generic/e2e_test.go
Normal file
|
|
@ -0,0 +1,300 @@
|
||||||
|
//go:build e2e
|
||||||
|
// +build e2e
|
||||||
|
|
||||||
|
package generic
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
corev1 "k8s.io/api/core/v1"
|
||||||
|
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||||
|
"k8s.io/apimachinery/pkg/runtime/schema"
|
||||||
|
"k8s.io/client-go/rest"
|
||||||
|
"k8s.io/client-go/tools/cache"
|
||||||
|
"k8s.io/client-go/tools/clientcmd"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestInferGVRE2E tests GVR inference against a real Kubernetes cluster
|
||||||
|
func TestInferGVRE2E(t *testing.T) {
|
||||||
|
config, err := clientcmd.NewNonInteractiveDeferredLoadingClientConfig(
|
||||||
|
clientcmd.NewDefaultClientConfigLoadingRules(),
|
||||||
|
&clientcmd.ConfigOverrides{},
|
||||||
|
).ClientConfig()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to load kubeconfig: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
inferFunc func() (schema.GroupVersionResource, error)
|
||||||
|
expectedGVR schema.GroupVersionResource
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "Pod",
|
||||||
|
inferFunc: func() (schema.GroupVersionResource, error) {
|
||||||
|
return inferGVR[*corev1.Pod](config)
|
||||||
|
},
|
||||||
|
expectedGVR: schema.GroupVersionResource{
|
||||||
|
Group: "",
|
||||||
|
Version: "v1",
|
||||||
|
Resource: "pods",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "ConfigMap",
|
||||||
|
inferFunc: func() (schema.GroupVersionResource, error) {
|
||||||
|
return inferGVR[*corev1.ConfigMap](config)
|
||||||
|
},
|
||||||
|
expectedGVR: schema.GroupVersionResource{
|
||||||
|
Group: "",
|
||||||
|
Version: "v1",
|
||||||
|
Resource: "configmaps",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Service",
|
||||||
|
inferFunc: func() (schema.GroupVersionResource, error) {
|
||||||
|
return inferGVR[*corev1.Service](config)
|
||||||
|
},
|
||||||
|
expectedGVR: schema.GroupVersionResource{
|
||||||
|
Group: "",
|
||||||
|
Version: "v1",
|
||||||
|
Resource: "services",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Secret",
|
||||||
|
inferFunc: func() (schema.GroupVersionResource, error) {
|
||||||
|
return inferGVR[*corev1.Secret](config)
|
||||||
|
},
|
||||||
|
expectedGVR: schema.GroupVersionResource{
|
||||||
|
Group: "",
|
||||||
|
Version: "v1",
|
||||||
|
Resource: "secrets",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Namespace",
|
||||||
|
inferFunc: func() (schema.GroupVersionResource, error) {
|
||||||
|
return inferGVR[*corev1.Namespace](config)
|
||||||
|
},
|
||||||
|
expectedGVR: schema.GroupVersionResource{
|
||||||
|
Group: "",
|
||||||
|
Version: "v1",
|
||||||
|
Resource: "namespaces",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
gvr, err := tt.inferFunc()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to infer GVR: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if gvr != tt.expectedGVR {
|
||||||
|
t.Errorf("expected GVR %v, got %v", tt.expectedGVR, gvr)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestNewClientE2E tests client creation with inferred GVR against a real cluster
|
||||||
|
func TestNewClientE2E(t *testing.T) {
|
||||||
|
config, err := clientcmd.NewNonInteractiveDeferredLoadingClientConfig(
|
||||||
|
clientcmd.NewDefaultClientConfigLoadingRules(),
|
||||||
|
&clientcmd.ConfigOverrides{},
|
||||||
|
).ClientConfig()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to load kubeconfig: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
t.Run("Pod client operations", func(t *testing.T) {
|
||||||
|
client, err := NewClient[*corev1.Pod](config)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to create pod client: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try to list pods in default namespace
|
||||||
|
pods, err := client.List(ctx, "default")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to list pods: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Logf("Successfully listed %d pods in default namespace", len(pods))
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("ConfigMap client operations", func(t *testing.T) {
|
||||||
|
client, err := NewClient[*corev1.ConfigMap](config)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to create configmap client: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try to list configmaps in default namespace
|
||||||
|
cms, err := client.List(ctx, "default")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to list configmaps: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Logf("Successfully listed %d configmaps in default namespace", len(cms))
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("Service client operations", func(t *testing.T) {
|
||||||
|
client, err := NewClient[*corev1.Service](config)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to create service client: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try to list services in default namespace
|
||||||
|
svcs, err := client.List(ctx, "default")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to list services: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Logf("Successfully listed %d services in default namespace", len(svcs))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestInferGVRErrorCases tests error cases for GVR inference
|
||||||
|
func TestInferGVRErrorCases(t *testing.T) {
|
||||||
|
config := &rest.Config{
|
||||||
|
Host: "http://localhost:8080",
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Run("Unregistered type", func(t *testing.T) {
|
||||||
|
// This should fail because no scheme is registered for this type
|
||||||
|
type UnregisteredType struct {
|
||||||
|
*corev1.Pod
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err := inferGVR[*UnregisteredType](config)
|
||||||
|
if err == nil {
|
||||||
|
t.Error("expected error for unregistered type, got nil")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestInformE2E tests informer functionality against a real cluster
|
||||||
|
func TestInformE2E(t *testing.T) {
|
||||||
|
config, err := clientcmd.NewNonInteractiveDeferredLoadingClientConfig(
|
||||||
|
clientcmd.NewDefaultClientConfigLoadingRules(),
|
||||||
|
&clientcmd.ConfigOverrides{},
|
||||||
|
).ClientConfig()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to load kubeconfig: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
// Create a ConfigMap client with inferred GVR
|
||||||
|
client, err := NewClient[*corev1.ConfigMap](config)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to create configmap client: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start the informer
|
||||||
|
client.Start(ctx)
|
||||||
|
|
||||||
|
// Track events
|
||||||
|
events := make(chan string, 100)
|
||||||
|
|
||||||
|
handler := cache.ResourceEventHandlerFuncs{
|
||||||
|
AddFunc: func(obj interface{}) {
|
||||||
|
// The dynamic informer returns unstructured objects
|
||||||
|
meta, err := cache.MetaNamespaceKeyFunc(obj)
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
namespace, name, _ := cache.SplitMetaNamespaceKey(meta)
|
||||||
|
if namespace == "default" {
|
||||||
|
events <- "add:" + name
|
||||||
|
}
|
||||||
|
},
|
||||||
|
UpdateFunc: func(oldObj, newObj interface{}) {
|
||||||
|
meta, err := cache.MetaNamespaceKeyFunc(newObj)
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
namespace, name, _ := cache.SplitMetaNamespaceKey(meta)
|
||||||
|
if namespace == "default" {
|
||||||
|
events <- "update:" + name
|
||||||
|
}
|
||||||
|
},
|
||||||
|
DeleteFunc: func(obj interface{}) {
|
||||||
|
meta, err := cache.MetaNamespaceKeyFunc(obj)
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
namespace, name, _ := cache.SplitMetaNamespaceKey(meta)
|
||||||
|
if namespace == "default" {
|
||||||
|
events <- "delete:" + name
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
client.Inform(ctx, handler)
|
||||||
|
|
||||||
|
// Create a test ConfigMap
|
||||||
|
testCM := &corev1.ConfigMap{
|
||||||
|
ObjectMeta: metav1.ObjectMeta{
|
||||||
|
Name: "test-inform-" + time.Now().Format("20060102-150405"),
|
||||||
|
Namespace: "default",
|
||||||
|
},
|
||||||
|
Data: map[string]string{
|
||||||
|
"test": "data",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := client.Create(ctx, "default", testCM); err != nil {
|
||||||
|
t.Fatalf("failed to create test configmap: %v", err)
|
||||||
|
}
|
||||||
|
defer func() {
|
||||||
|
// Clean up
|
||||||
|
if err := client.Delete(ctx, "default", testCM.Name); err != nil {
|
||||||
|
t.Logf("failed to delete test configmap: %v", err)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
// Wait for add event for our specific ConfigMap
|
||||||
|
deadline := time.After(10 * time.Second)
|
||||||
|
foundAdd := false
|
||||||
|
for !foundAdd {
|
||||||
|
select {
|
||||||
|
case event := <-events:
|
||||||
|
t.Logf("Received event: %s", event)
|
||||||
|
if event == "add:"+testCM.Name {
|
||||||
|
foundAdd = true
|
||||||
|
}
|
||||||
|
case <-deadline:
|
||||||
|
t.Fatal("timeout waiting for add event")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update the ConfigMap
|
||||||
|
testCM.Data["test"] = "updated"
|
||||||
|
if err := client.Update(ctx, "default", testCM); err != nil {
|
||||||
|
t.Fatalf("failed to update test configmap: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wait for update event
|
||||||
|
deadline = time.After(10 * time.Second)
|
||||||
|
foundUpdate := false
|
||||||
|
for !foundUpdate {
|
||||||
|
select {
|
||||||
|
case event := <-events:
|
||||||
|
t.Logf("Received event: %s", event)
|
||||||
|
if event == "update:"+testCM.Name {
|
||||||
|
foundUpdate = true
|
||||||
|
}
|
||||||
|
case <-deadline:
|
||||||
|
t.Fatal("timeout waiting for update event")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
28
main.go
28
main.go
|
|
@ -7,24 +7,10 @@ import (
|
||||||
"github.com/imjasonh/client-go2/generic"
|
"github.com/imjasonh/client-go2/generic"
|
||||||
corev1 "k8s.io/api/core/v1"
|
corev1 "k8s.io/api/core/v1"
|
||||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||||
"k8s.io/apimachinery/pkg/runtime/schema"
|
|
||||||
"k8s.io/client-go/tools/cache"
|
"k8s.io/client-go/tools/cache"
|
||||||
"k8s.io/client-go/tools/clientcmd"
|
"k8s.io/client-go/tools/clientcmd"
|
||||||
)
|
)
|
||||||
|
|
||||||
var (
|
|
||||||
podGVR = schema.GroupVersionResource{
|
|
||||||
Group: "",
|
|
||||||
Version: "v1",
|
|
||||||
Resource: "pods",
|
|
||||||
}
|
|
||||||
cmGVR = schema.GroupVersionResource{
|
|
||||||
Group: "",
|
|
||||||
Version: "v1",
|
|
||||||
Resource: "configmaps",
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
config, err := clientcmd.NewNonInteractiveDeferredLoadingClientConfig(clientcmd.NewDefaultClientConfigLoadingRules(), &clientcmd.ConfigOverrides{}).ClientConfig()
|
config, err := clientcmd.NewNonInteractiveDeferredLoadingClientConfig(clientcmd.NewDefaultClientConfigLoadingRules(), &clientcmd.ConfigOverrides{}).ClientConfig()
|
||||||
|
|
@ -32,8 +18,12 @@ func main() {
|
||||||
log.Fatalf("ClientConfig: %v", err)
|
log.Fatalf("ClientConfig: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// List pods in kube-system.
|
// List pods in kube-system using automatic GVR inference.
|
||||||
pods, err := generic.NewClient[*corev1.Pod](podGVR, config).List(ctx, "kube-system")
|
podClient, err := generic.NewClient[*corev1.Pod](config)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatal("creating pod client:", err)
|
||||||
|
}
|
||||||
|
pods, err := podClient.List(ctx, "kube-system")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatal("listing pods:", err)
|
log.Fatal("listing pods:", err)
|
||||||
}
|
}
|
||||||
|
|
@ -42,7 +32,11 @@ func main() {
|
||||||
log.Println("-", p.Name)
|
log.Println("-", p.Name)
|
||||||
}
|
}
|
||||||
|
|
||||||
cmc := generic.NewClient[*corev1.ConfigMap](cmGVR, config)
|
// For ConfigMaps, we'll also use automatic GVR inference
|
||||||
|
cmc, err := generic.NewClient[*corev1.ConfigMap](config)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatal("creating configmap client:", err)
|
||||||
|
}
|
||||||
cmc.Start(ctx)
|
cmc.Start(ctx)
|
||||||
|
|
||||||
// Start an informer to log all adds/updates/deletes for ConfigMaps.
|
// Start an informer to log all adds/updates/deletes for ConfigMaps.
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue