From 74aace1a2dfd4b789028fb10e942b1001550bcec Mon Sep 17 00:00:00 2001 From: Jason Hall Date: Wed, 23 Jul 2025 11:41:20 -0400 Subject: [PATCH] Update to use stable Go and add comprehensive CI/CD MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- .github/workflows/build.yaml | 46 ++++-- .gitignore | 36 +++++ README.md | 85 +++++++--- generic/client.go | 98 ++++++++++-- generic/client_test.go | 83 +++------- generic/e2e_test.go | 300 +++++++++++++++++++++++++++++++++++ main.go | 28 ++-- 7 files changed, 554 insertions(+), 122 deletions(-) create mode 100644 .gitignore create mode 100644 generic/e2e_test.go diff --git a/.github/workflows/build.yaml b/.github/workflows/build.yaml index c112216..ab732c2 100644 --- a/.github/workflows/build.yaml +++ b/.github/workflows/build.yaml @@ -3,23 +3,47 @@ name: Build on: push: branches: ['main'] + pull_request: workflow_dispatch: jobs: build: name: build runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v2 - - uses: actions/setup-go@v2 + - uses: actions/checkout@v4 + + - uses: actions/setup-go@v5 with: - go-version: '1.17.x' - - uses: engineerd/setup-kind@v0.5.0 - - run: | - go install golang.org/dl/gotip@latest - gotip download + go-version: 'stable' - gotip build ./ - gotip run ./ - gotip run ./ + - run: go build ./... + - run: go vet ./... + + - 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 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..3f91cef --- /dev/null +++ b/.gitignore @@ -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 \ No newline at end of file diff --git a/README.md b/README.md index 31c7587..aad3c70 100644 --- a/README.md +++ b/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)_. -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 ./ -2021/12/15 12:08:11 LISTING PODS -2021/12/15 12:08:11 - coredns-558bd4d5db-hjs27 -2021/12/15 12:08:11 - coredns-558bd4d5db-vhrtd -2021/12/15 12:08:11 - etcd-kind-control-plane -2021/12/15 12:08:11 - kindnet-c977m -2021/12/15 12:08:11 - kube-apiserver-kind-control-plane -2021/12/15 12:08:11 - kube-controller-manager-kind-control-plane -2021/12/15 12:08:11 - kube-proxy-fgpfd -2021/12/15 12:08:11 - 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 +2025/07/23 11:23:10 LISTING PODS +2025/07/23 11:23:10 - coredns-674b8bbfcf-ddcww +2025/07/23 11:23:10 - coredns-674b8bbfcf-dqqx5 +2025/07/23 11:23:10 - etcd-kind-control-plane +2025/07/23 11:23:10 - kindnet-tkf6l +2025/07/23 11:23:10 - kube-apiserver-kind-control-plane +2025/07/23 11:23:10 - kube-controller-manager-kind-control-plane +2025/07/23 11:23:10 - kube-proxy-76tcd +2025/07/23 11:23:10 - kube-scheduler-kind-control-plane ... -2021/12/15 12:08:11 LISTING CONFIGMAPS -2021/12/15 12:08:11 - coredns -2021/12/15 12:08:11 - extension-apiserver-authentication -2021/12/15 12:08:11 - kube-proxy -2021/12/15 12:08:11 - kube-root-ca.crt -2021/12/15 12:08:11 - kubeadm-config -2021/12/15 12:08:11 - kubelet-config-1.21 +``` + +## Testing + +Run unit tests: +```bash +go test ./generic +``` + +Run e2e tests (requires a Kubernetes cluster): +```bash +go test ./generic -tags=e2e ``` # THIS IS AN EXPERIMENT diff --git a/generic/client.go b/generic/client.go index 0cce2d7..958e41d 100644 --- a/generic/client.go +++ b/generic/client.go @@ -4,7 +4,9 @@ import ( "bytes" "context" "encoding/json" + "fmt" "log" + "reflect" "time" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" @@ -12,28 +14,38 @@ import ( "k8s.io/apimachinery/pkg/runtime" "k8s.io/apimachinery/pkg/runtime/schema" "k8s.io/apimachinery/pkg/types" + "k8s.io/client-go/discovery" "k8s.io/client-go/dynamic" "k8s.io/client-go/dynamic/dynamicinformer" + "k8s.io/client-go/kubernetes/scheme" "k8s.io/client-go/rest" + "k8s.io/client-go/restmapper" "k8s.io/client-go/tools/cache" ) const resyncPeriod = time.Hour -// Ideally, this wouldn't need to take a GVR, and it could just be inferred -// from the runtime.Object type given. +// NewClient creates a new generic client by automatically inferring +// 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., -// *corev1.Pod), which means T is nil, and we can't call GetObjectKind() on it -// 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] { +// Most users should prefer NewClient which automatically infers the GVR. +func NewClientGVR[T runtime.Object](gvr schema.GroupVersionResource, config *rest.Config) client[T] { dyn := dynamic.NewForConfigOrDie(config) return client[T]{ 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 { gvr schema.GroupVersionResource diff --git a/generic/client_test.go b/generic/client_test.go index 641e709..3645026 100644 --- a/generic/client_test.go +++ b/generic/client_test.go @@ -12,10 +12,9 @@ import ( "k8s.io/apimachinery/pkg/types" "k8s.io/client-go/dynamic/fake" "k8s.io/client-go/rest" - "k8s.io/client-go/tools/cache" ) -func TestNewClient(t *testing.T) { +func TestNewClientGVR(t *testing.T) { gvr := schema.GroupVersionResource{ Group: "", Version: "v1", @@ -23,7 +22,7 @@ func TestNewClient(t *testing.T) { } config := &rest.Config{} - client := NewClient[*corev1.Pod](gvr, config) + client := NewClientGVR[*corev1.Pod](gvr, config) if client.gvr != 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) { - ctx, cancel := context.WithCancel(context.Background()) - defer cancel() - - 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") - } + // Skip this test as it requires complex informer setup that doesn't work well with fake clients + // The informer functionality is better tested with e2e tests against a real cluster + t.Skip("Informer testing requires real cluster - see e2e tests") } // 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"]) } } + +// 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) + } +} diff --git a/generic/e2e_test.go b/generic/e2e_test.go new file mode 100644 index 0000000..c2b9851 --- /dev/null +++ b/generic/e2e_test.go @@ -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") + } + } +} diff --git a/main.go b/main.go index f044144..28698fb 100644 --- a/main.go +++ b/main.go @@ -7,24 +7,10 @@ import ( "github.com/imjasonh/client-go2/generic" corev1 "k8s.io/api/core/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/clientcmd" ) -var ( - podGVR = schema.GroupVersionResource{ - Group: "", - Version: "v1", - Resource: "pods", - } - cmGVR = schema.GroupVersionResource{ - Group: "", - Version: "v1", - Resource: "configmaps", - } -) - func main() { ctx := context.Background() config, err := clientcmd.NewNonInteractiveDeferredLoadingClientConfig(clientcmd.NewDefaultClientConfigLoadingRules(), &clientcmd.ConfigOverrides{}).ClientConfig() @@ -32,8 +18,12 @@ func main() { log.Fatalf("ClientConfig: %v", err) } - // List pods in kube-system. - pods, err := generic.NewClient[*corev1.Pod](podGVR, config).List(ctx, "kube-system") + // List pods in kube-system using automatic GVR inference. + 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 { log.Fatal("listing pods:", err) } @@ -42,7 +32,11 @@ func main() { 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) // Start an informer to log all adds/updates/deletes for ConfigMaps.