diff --git a/api/versioned/clientset_test.go b/api/versioned/clientset_test.go new file mode 100644 index 0000000000..ae97c58953 --- /dev/null +++ b/api/versioned/clientset_test.go @@ -0,0 +1,432 @@ +/** +# Copyright (c) NVIDIA CORPORATION. All rights reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +**/ + +package versioned + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "path/filepath" + "sync" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime/schema" + "k8s.io/client-go/discovery" + "k8s.io/client-go/rest" + "k8s.io/client-go/util/flowcontrol" + + nvidiav1 "github.com/NVIDIA/gpu-operator/api/nvidia/v1" + nvidiav1alpha1 "github.com/NVIDIA/gpu-operator/api/nvidia/v1alpha1" + "github.com/NVIDIA/gpu-operator/api/versioned/scheme" + typednvidiav1 "github.com/NVIDIA/gpu-operator/api/versioned/typed/nvidia/v1" + typednvidiav1alpha1 "github.com/NVIDIA/gpu-operator/api/versioned/typed/nvidia/v1alpha1" +) + +// *Clientset must satisfy the generated Interface. This is a compile-time check. +var _ Interface = (*Clientset)(nil) + +const testHost = "https://gpu-operator.test:6443" + +// pathRecorder records the request paths observed by an httptest handler. The +// handler runs on the server goroutine while the assertions run on the test +// goroutine, so all access is serialized by the embedded mutex. It never calls +// into testify, because require/assert may only be used from the test +// goroutine. +type pathRecorder struct { + mu sync.Mutex + paths []string +} + +func (r *pathRecorder) record(path string) { + r.mu.Lock() + defer r.mu.Unlock() + r.paths = append(r.paths, path) +} + +// snapshot returns a copy of the recorded paths and must be called from the +// test goroutine once the client calls have completed. +func (r *pathRecorder) snapshot() []string { + r.mu.Lock() + defer r.mu.Unlock() + return append([]string(nil), r.paths...) +} + +// goodConfig returns a minimal *rest.Config that every constructor under test +// should accept. +func goodConfig() *rest.Config { + return &rest.Config{Host: testHost} +} + +// badConfig returns a *rest.Config that makes rest.HTTPClientFor fail, because +// the referenced CA bundle does not exist on disk. +func badConfig(t *testing.T) *rest.Config { + t.Helper() + return &rest.Config{ + Host: testHost, + TLSClientConfig: rest.TLSClientConfig{ + CAFile: filepath.Join(t.TempDir(), "does-not-exist-ca.crt"), + }, + } +} + +// newTestRESTClient builds a real *rest.RESTClient so that New() can be +// verified against a concrete rest.Interface implementation. +func newTestRESTClient(t *testing.T) *rest.RESTClient { + t.Helper() + gv := schema.GroupVersion{Group: "nvidia.com", Version: "v1"} + restClient, err := rest.RESTClientFor(&rest.Config{ + Host: testHost, + APIPath: "/apis", + ContentConfig: rest.ContentConfig{ + GroupVersion: &gv, + NegotiatedSerializer: scheme.Codecs.WithoutConversion(), + }, + }) + require.NoError(t, err) + return restClient +} + +func TestNewForConfig(t *testing.T) { + t.Run("succeeds on a minimal config and wires every client", func(t *testing.T) { + cs, err := NewForConfig(goodConfig()) + require.NoError(t, err) + require.NotNil(t, cs) + + require.NotNil(t, cs.nvidiaV1) + require.NotNil(t, cs.nvidiaV1alpha1) + require.NotNil(t, cs.DiscoveryClient) + + // All group clients share a single REST transport-backed client per group, + // each configured for its own group/version. + assert.Equal(t, nvidiav1.SchemeGroupVersion, cs.NvidiaV1().RESTClient().APIVersion()) + assert.Equal(t, nvidiav1alpha1.SchemeGroupVersion, cs.NvidiaV1alpha1().RESTClient().APIVersion()) + }) + + t.Run("returns the transport error when the CA bundle cannot be loaded", func(t *testing.T) { + cs, err := NewForConfig(badConfig(t)) + require.Error(t, err) + assert.Nil(t, cs) + assert.Contains(t, err.Error(), "does-not-exist-ca.crt") + }) + + t.Run("propagates the burst validation error from NewForConfigAndClient", func(t *testing.T) { + cfg := goodConfig() + cfg.QPS = 10 + cfg.Burst = 0 + + cs, err := NewForConfig(cfg) + require.Error(t, err) + assert.Nil(t, cs) + assert.Contains(t, err.Error(), "burst is required to be greater than 0") + }) +} + +func TestNewForConfigUserAgent(t *testing.T) { + tests := []struct { + name string + userAgent string + expected string + }{ + { + name: "empty user agent is defaulted", + userAgent: "", + expected: rest.DefaultKubernetesUserAgent(), + }, + { + name: "caller supplied user agent is preserved", + userAgent: "gpu-operator-test/1.2.3", + expected: "gpu-operator-test/1.2.3", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // The handler runs on the server goroutine, so the recorded header is + // guarded by a mutex and only read back on the test goroutine. + var ( + mu sync.Mutex + gotUserAgent string + ) + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + mu.Lock() + gotUserAgent = r.Header.Get("User-Agent") + mu.Unlock() + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(&nvidiav1.ClusterPolicyList{}) + })) + defer server.Close() + + cfg := &rest.Config{Host: server.URL, UserAgent: tt.userAgent} + cs, err := NewForConfig(cfg) + require.NoError(t, err) + + _, err = cs.NvidiaV1().ClusterPolicies().List(t.Context(), metav1.ListOptions{}) + require.NoError(t, err) + + mu.Lock() + recordedUserAgent := gotUserAgent + mu.Unlock() + assert.Equal(t, tt.expected, recordedUserAgent) + + // The caller's config must never be mutated: NewForConfig works on a + // shallow copy. + assert.Equal(t, tt.userAgent, cfg.UserAgent) + }) + } +} + +func TestNewForConfigDoesNotMutateCallerConfig(t *testing.T) { + cfg := goodConfig() + cfg.QPS = 25 + cfg.Burst = 50 + + cs, err := NewForConfig(cfg) + require.NoError(t, err) + require.NotNil(t, cs) + + // UserAgent defaulting and rate-limiter construction happen on shallow + // copies, so the caller's config is untouched. + assert.Empty(t, cfg.UserAgent) + assert.Nil(t, cfg.RateLimiter) + assert.Equal(t, float32(25), cfg.QPS) + assert.Equal(t, 50, cfg.Burst) +} + +func TestNewForConfigAndClient(t *testing.T) { + t.Run("QPS set with non-positive burst is rejected", func(t *testing.T) { + for _, burst := range []int{0, -1} { + cfg := goodConfig() + cfg.QPS = 5 + cfg.Burst = burst + + cs, err := NewForConfigAndClient(cfg, &http.Client{}) + require.Error(t, err) + assert.Nil(t, cs) + assert.EqualError(t, err, + "burst is required to be greater than 0 when RateLimiter is not set and QPS is set to greater than 0") + } + }) + + t.Run("returns the error from the first group client that fails to build", func(t *testing.T) { + cfg := &rest.Config{Host: "http://[::1]:not-a-port"} + + cs, err := NewForConfigAndClient(cfg, &http.Client{}) + require.Error(t, err) + assert.Nil(t, cs) + }) + + t.Run("QPS with a positive burst installs a rate limiter on every client", func(t *testing.T) { + cfg := goodConfig() + cfg.QPS = 5 + cfg.Burst = 10 + + cs, err := NewForConfigAndClient(cfg, &http.Client{}) + require.NoError(t, err) + require.NotNil(t, cs) + + v1Limiter := cs.NvidiaV1().RESTClient().GetRateLimiter() + v1alpha1Limiter := cs.NvidiaV1alpha1().RESTClient().GetRateLimiter() + require.NotNil(t, v1Limiter) + require.NotNil(t, v1alpha1Limiter) + // A single limiter is generated in the shallow copy and shared by all + // group clients built from it. + assert.Same(t, v1Limiter, v1alpha1Limiter) + + // The caller's config keeps its nil RateLimiter. + assert.Nil(t, cfg.RateLimiter) + }) + + t.Run("a pre-set rate limiter is left alone", func(t *testing.T) { + limiter := flowcontrol.NewTokenBucketRateLimiter(1, 1) + cfg := goodConfig() + cfg.RateLimiter = limiter + // Burst is invalid, but must not be validated because RateLimiter is set. + cfg.QPS = 5 + cfg.Burst = 0 + + cs, err := NewForConfigAndClient(cfg, &http.Client{}) + require.NoError(t, err) + require.NotNil(t, cs) + + assert.Same(t, limiter, cs.NvidiaV1().RESTClient().GetRateLimiter()) + assert.Same(t, limiter, cs.NvidiaV1alpha1().RESTClient().GetRateLimiter()) + assert.Same(t, limiter, cfg.RateLimiter) + }) + + t.Run("no shared rate limiter is generated when QPS is zero", func(t *testing.T) { + cfg := goodConfig() + cfg.QPS = 0 + cfg.Burst = 0 + + cs, err := NewForConfigAndClient(cfg, &http.Client{}) + require.NoError(t, err) + require.NotNil(t, cs) + assert.Nil(t, cfg.RateLimiter) + + // Nothing is installed in the shallow copy, so each group client falls + // back to the rest package's own per-client default limiter rather than + // sharing one. + assert.NotSame(t, + cs.NvidiaV1().RESTClient().GetRateLimiter(), + cs.NvidiaV1alpha1().RESTClient().GetRateLimiter()) + }) +} + +func TestNewForConfigOrDie(t *testing.T) { + t.Run("returns a clientset for a good config", func(t *testing.T) { + var cs *Clientset + require.NotPanics(t, func() { + cs = NewForConfigOrDie(goodConfig()) + }) + require.NotNil(t, cs) + assert.NotNil(t, cs.NvidiaV1()) + assert.NotNil(t, cs.NvidiaV1alpha1()) + assert.NotNil(t, cs.Discovery()) + }) + + t.Run("panics for a bad config", func(t *testing.T) { + cfg := badConfig(t) + assert.Panics(t, func() { + _ = NewForConfigOrDie(cfg) + }) + }) + + t.Run("panics when burst validation fails", func(t *testing.T) { + cfg := goodConfig() + cfg.QPS = 10 + cfg.Burst = -5 + assert.Panics(t, func() { + _ = NewForConfigOrDie(cfg) + }) + }) +} + +func TestNew(t *testing.T) { + restClient := newTestRESTClient(t) + + cs := New(restClient) + require.NotNil(t, cs) + + require.NotNil(t, cs.nvidiaV1) + require.NotNil(t, cs.nvidiaV1alpha1) + require.NotNil(t, cs.DiscoveryClient) + + // Every group client, and discovery, must be backed by the exact RESTClient + // that was handed to New. + assert.Same(t, restClient, cs.NvidiaV1().RESTClient()) + assert.Same(t, restClient, cs.NvidiaV1alpha1().RESTClient()) + assert.Same(t, restClient, cs.Discovery().RESTClient()) +} + +func TestDiscovery(t *testing.T) { + t.Run("returns the embedded discovery client", func(t *testing.T) { + cs := New(newTestRESTClient(t)) + got := cs.Discovery() + require.NotNil(t, got) + assert.Same(t, cs.DiscoveryClient, got) + assert.IsType(t, &discovery.DiscoveryClient{}, got) + }) + + t.Run("nil receiver returns a nil interface", func(t *testing.T) { + var cs *Clientset + got := cs.Discovery() + assert.Nil(t, got) + // The explicit nil check must return an untyped nil, not a typed nil + // wrapped in the interface. + assert.True(t, got == nil) + }) +} + +func TestGroupClientAccessors(t *testing.T) { + cs := New(newTestRESTClient(t)) + + v1Client := cs.NvidiaV1() + require.NotNil(t, v1Client) + assert.IsType(t, &typednvidiav1.NvidiaV1Client{}, v1Client) + assert.Same(t, cs.nvidiaV1, v1Client) + assert.NotNil(t, v1Client.ClusterPolicies()) + + v1alpha1Client := cs.NvidiaV1alpha1() + require.NotNil(t, v1alpha1Client) + assert.IsType(t, &typednvidiav1alpha1.NvidiaV1alpha1Client{}, v1alpha1Client) + assert.Same(t, cs.nvidiaV1alpha1, v1alpha1Client) + assert.NotNil(t, v1alpha1Client.GPUClusters()) + assert.NotNil(t, v1alpha1Client.NVIDIADrivers()) +} + +func TestClientsetImplementsInterface(t *testing.T) { + assert.Implements(t, (*Interface)(nil), &Clientset{}) + assert.Implements(t, (*discovery.DiscoveryInterface)(nil), New(newTestRESTClient(t)).Discovery()) +} + +// TestClientsetRoundTrip exercises the wiring end to end: a clientset built by +// NewForConfig must reach the right API paths for discovery and for each group. +func TestClientsetRoundTrip(t *testing.T) { + // pathRecorder collects the paths seen by the httptest handler. The handler + // runs on the server goroutine, so every access is taken under the mutex and + // the snapshot is read back on the test goroutine. + var recorder pathRecorder + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + recorder.record(r.URL.Path) + w.Header().Set("Content-Type", "application/json") + switch r.URL.Path { + case "/apis/nvidia.com/v1/clusterpolicies": + _ = json.NewEncoder(w).Encode(&nvidiav1.ClusterPolicyList{ + Items: []nvidiav1.ClusterPolicy{{ObjectMeta: metav1.ObjectMeta{Name: "cluster-policy"}}}, + }) + case "/apis/nvidia.com/v1alpha1/nvidiadrivers": + _ = json.NewEncoder(w).Encode(&nvidiav1alpha1.NVIDIADriverList{ + Items: []nvidiav1alpha1.NVIDIADriver{{ObjectMeta: metav1.ObjectMeta{Name: "driver"}}}, + }) + case "/apis/nvidia.com/v1": + _ = json.NewEncoder(w).Encode(&metav1.APIResourceList{ + GroupVersion: "nvidia.com/v1", + APIResources: []metav1.APIResource{{Name: "clusterpolicies", Kind: "ClusterPolicy"}}, + }) + default: + w.WriteHeader(http.StatusNotFound) + } + })) + defer server.Close() + + cs, err := NewForConfig(&rest.Config{Host: server.URL}) + require.NoError(t, err) + + policies, err := cs.NvidiaV1().ClusterPolicies().List(t.Context(), metav1.ListOptions{}) + require.NoError(t, err) + require.Len(t, policies.Items, 1) + assert.Equal(t, "cluster-policy", policies.Items[0].Name) + + drivers, err := cs.NvidiaV1alpha1().NVIDIADrivers().List(t.Context(), metav1.ListOptions{}) + require.NoError(t, err) + require.Len(t, drivers.Items, 1) + assert.Equal(t, "driver", drivers.Items[0].Name) + + resources, err := cs.Discovery().ServerResourcesForGroupVersion("nvidia.com/v1") + require.NoError(t, err) + require.Len(t, resources.APIResources, 1) + assert.Equal(t, "clusterpolicies", resources.APIResources[0].Name) + + assert.Equal(t, []string{ + "/apis/nvidia.com/v1/clusterpolicies", + "/apis/nvidia.com/v1alpha1/nvidiadrivers", + "/apis/nvidia.com/v1", + }, recorder.snapshot()) +} diff --git a/api/versioned/fake/clientset_generated_test.go b/api/versioned/fake/clientset_generated_test.go new file mode 100644 index 0000000000..0e7773c6d6 --- /dev/null +++ b/api/versioned/fake/clientset_generated_test.go @@ -0,0 +1,803 @@ +/** +# Copyright (c) NVIDIA CORPORATION. All rights reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +**/ + +package fake + +import ( + "encoding/json" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + apierrors "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/runtime/schema" + "k8s.io/apimachinery/pkg/types" + "k8s.io/apimachinery/pkg/version" + "k8s.io/apimachinery/pkg/watch" + fakediscovery "k8s.io/client-go/discovery/fake" + k8stesting "k8s.io/client-go/testing" + + nvidiav1 "github.com/NVIDIA/gpu-operator/api/nvidia/v1" + nvidiav1alpha1 "github.com/NVIDIA/gpu-operator/api/nvidia/v1alpha1" + clientset "github.com/NVIDIA/gpu-operator/api/versioned" + fakenvidiav1 "github.com/NVIDIA/gpu-operator/api/versioned/typed/nvidia/v1/fake" + fakenvidiav1alpha1 "github.com/NVIDIA/gpu-operator/api/versioned/typed/nvidia/v1alpha1/fake" +) + +// eventTimeout bounds how long a test waits for a watch event. The tracker is +// entirely in-process, so events are delivered immediately; a short timeout keeps +// a regression from failing slowly. +const eventTimeout = 2 * time.Second + +var ( + clusterPolicyGVR = schema.GroupVersionResource{Group: "nvidia.com", Version: "v1", Resource: "clusterpolicies"} + nvidiaDriverGVR = schema.GroupVersionResource{Group: "nvidia.com", Version: "v1alpha1", Resource: "nvidiadrivers"} +) + +// unregisteredTestType is a runtime.Object that is deliberately never added to +// the package scheme. Using a private type here, rather than borrowing a real +// type such as corev1.Pod, keeps the "unregistered kind" tests meaningful even +// if unrelated types are legitimately registered into the scheme later. +type unregisteredTestType struct { + metav1.TypeMeta `json:",inline"` + metav1.ObjectMeta `json:"metadata,omitempty"` +} + +// DeepCopyObject implements runtime.Object. +func (u *unregisteredTestType) DeepCopyObject() runtime.Object { + out := &unregisteredTestType{TypeMeta: u.TypeMeta} + u.DeepCopyInto(&out.ObjectMeta) + return out +} + +// receiveEvent reads a single event from a watch channel, failing the test if the +// channel closes or nothing arrives within eventTimeout. +func receiveEvent(t *testing.T, ch <-chan watch.Event) watch.Event { + t.Helper() + timer := time.NewTimer(eventTimeout) + defer timer.Stop() + select { + case event, ok := <-ch: + require.True(t, ok, "watch channel closed unexpectedly") + return event + case <-timer.C: + t.Fatal("timed out waiting for watch event") + return watch.Event{} + } +} + +func newClusterPolicy(name string) *nvidiav1.ClusterPolicy { + return &nvidiav1.ClusterPolicy{ + ObjectMeta: metav1.ObjectMeta{ + Name: name, + Labels: map[string]string{"app": "gpu-operator"}, + }, + Spec: nvidiav1.ClusterPolicySpec{ + Operator: nvidiav1.OperatorSpec{ + RuntimeClass: "nvidia", + }, + }, + } +} + +func newNVIDIADriver(name string) *nvidiav1alpha1.NVIDIADriver { + return &nvidiav1alpha1.NVIDIADriver{ + ObjectMeta: metav1.ObjectMeta{Name: name}, + Spec: nvidiav1alpha1.NVIDIADriverSpec{ + DriverType: nvidiav1alpha1.GPU, + }, + } +} + +// TestNewSimpleClientsetEmpty verifies a clientset built with no seed objects is +// immediately usable and reports empty lists for both API groups. +func TestNewSimpleClientsetEmpty(t *testing.T) { + ctx := t.Context() + cs := NewSimpleClientset() + + require.NotNil(t, cs) + require.NotNil(t, cs.Tracker()) + + cpList, err := cs.NvidiaV1().ClusterPolicies().List(ctx, metav1.ListOptions{}) + require.NoError(t, err) + assert.Empty(t, cpList.Items) + + drvList, err := cs.NvidiaV1alpha1().NVIDIADrivers().List(ctx, metav1.ListOptions{}) + require.NoError(t, err) + assert.Empty(t, drvList.Items) + + // Nothing exists, so a Get must be a genuine NotFound. + _, err = cs.NvidiaV1().ClusterPolicies().Get(ctx, "missing", metav1.GetOptions{}) + require.Error(t, err) + assert.True(t, apierrors.IsNotFound(err), "expected NotFound, got %v", err) +} + +// TestNewSimpleClientsetSeedsTracker verifies objects handed to NewSimpleClientset +// are readable through the typed clients of both groups. +func TestNewSimpleClientsetSeedsTracker(t *testing.T) { + ctx := t.Context() + cs := NewSimpleClientset( + newClusterPolicy("cp-a"), + newClusterPolicy("cp-b"), + newNVIDIADriver("drv-a"), + ) + + cp, err := cs.NvidiaV1().ClusterPolicies().Get(ctx, "cp-a", metav1.GetOptions{}) + require.NoError(t, err) + assert.Equal(t, "cp-a", cp.Name) + assert.Equal(t, "nvidia", cp.Spec.Operator.RuntimeClass) + + cpList, err := cs.NvidiaV1().ClusterPolicies().List(ctx, metav1.ListOptions{}) + require.NoError(t, err) + names := make([]string, 0, len(cpList.Items)) + for _, item := range cpList.Items { + names = append(names, item.Name) + } + assert.ElementsMatch(t, []string{"cp-a", "cp-b"}, names) + + drv, err := cs.NvidiaV1alpha1().NVIDIADrivers().Get(ctx, "drv-a", metav1.GetOptions{}) + require.NoError(t, err) + assert.Equal(t, "drv-a", drv.Name) + assert.Equal(t, nvidiav1alpha1.GPU, drv.Spec.DriverType) + + drvList, err := cs.NvidiaV1alpha1().NVIDIADrivers().List(ctx, metav1.ListOptions{}) + require.NoError(t, err) + require.Len(t, drvList.Items, 1) + assert.Equal(t, "drv-a", drvList.Items[0].Name) + + // Seeding one group must not leak into the other. + _, err = cs.NvidiaV1alpha1().NVIDIADrivers().Get(ctx, "cp-a", metav1.GetOptions{}) + assert.True(t, apierrors.IsNotFound(err), "expected NotFound, got %v", err) +} + +// TestNewSimpleClientsetSeedListSelector verifies list label selectors are applied +// client-side by the generated fake lister. +func TestNewSimpleClientsetSeedListSelector(t *testing.T) { + ctx := t.Context() + other := newClusterPolicy("cp-other") + other.Labels = map[string]string{"app": "something-else"} + cs := NewSimpleClientset(newClusterPolicy("cp-a"), other) + + list, err := cs.NvidiaV1().ClusterPolicies().List(ctx, metav1.ListOptions{LabelSelector: "app=gpu-operator"}) + require.NoError(t, err) + require.Len(t, list.Items, 1) + assert.Equal(t, "cp-a", list.Items[0].Name) +} + +// TestClusterPolicyCRUDRoundTrip exercises Create/Get/Update/UpdateStatus/List/ +// Delete plus the NotFound and AlreadyExists error paths against the tracker. +func TestClusterPolicyCRUDRoundTrip(t *testing.T) { + ctx := t.Context() + cs := NewSimpleClientset() + client := cs.NvidiaV1().ClusterPolicies() + + created, err := client.Create(ctx, newClusterPolicy("cp"), metav1.CreateOptions{}) + require.NoError(t, err) + assert.Equal(t, "cp", created.Name) + + // Duplicate create must be rejected by the tracker. + _, err = client.Create(ctx, newClusterPolicy("cp"), metav1.CreateOptions{}) + require.Error(t, err) + assert.True(t, apierrors.IsAlreadyExists(err), "expected AlreadyExists, got %v", err) + + got, err := client.Get(ctx, "cp", metav1.GetOptions{}) + require.NoError(t, err) + assert.Equal(t, "nvidia", got.Spec.Operator.RuntimeClass) + + // Update the spec and read it back. + got.Spec.Operator.RuntimeClass = "nvidia-crio" + updated, err := client.Update(ctx, got, metav1.UpdateOptions{}) + require.NoError(t, err) + assert.Equal(t, "nvidia-crio", updated.Spec.Operator.RuntimeClass) + + got, err = client.Get(ctx, "cp", metav1.GetOptions{}) + require.NoError(t, err) + assert.Equal(t, "nvidia-crio", got.Spec.Operator.RuntimeClass) + + // UpdateStatus persists the status subresource. + got.SetStatus(nvidiav1.Ready, "gpu-operator") + statusUpdated, err := client.UpdateStatus(ctx, got, metav1.UpdateOptions{}) + require.NoError(t, err) + assert.Equal(t, nvidiav1.Ready, statusUpdated.Status.State) + + got, err = client.Get(ctx, "cp", metav1.GetOptions{}) + require.NoError(t, err) + assert.Equal(t, nvidiav1.Ready, got.Status.State) + assert.Equal(t, "gpu-operator", got.Status.Namespace) + + list, err := client.List(ctx, metav1.ListOptions{}) + require.NoError(t, err) + require.Len(t, list.Items, 1) + + // Update of a non-existent object is NotFound. + _, err = client.Update(ctx, newClusterPolicy("ghost"), metav1.UpdateOptions{}) + assert.True(t, apierrors.IsNotFound(err), "expected NotFound, got %v", err) + + require.NoError(t, client.Delete(ctx, "cp", metav1.DeleteOptions{})) + + _, err = client.Get(ctx, "cp", metav1.GetOptions{}) + assert.True(t, apierrors.IsNotFound(err), "expected NotFound after delete, got %v", err) + + // Deleting twice is NotFound too. + err = client.Delete(ctx, "cp", metav1.DeleteOptions{}) + assert.True(t, apierrors.IsNotFound(err), "expected NotFound on repeat delete, got %v", err) + + list, err = client.List(ctx, metav1.ListOptions{}) + require.NoError(t, err) + assert.Empty(t, list.Items) +} + +// TestNVIDIADriverCRUDRoundTrip mirrors the ClusterPolicy round trip for the +// v1alpha1 group so both generated group clients are exercised end to end. +func TestNVIDIADriverCRUDRoundTrip(t *testing.T) { + ctx := t.Context() + cs := NewSimpleClientset() + client := cs.NvidiaV1alpha1().NVIDIADrivers() + + _, err := client.Create(ctx, newNVIDIADriver("drv"), metav1.CreateOptions{}) + require.NoError(t, err) + + _, err = client.Create(ctx, newNVIDIADriver("drv"), metav1.CreateOptions{}) + assert.True(t, apierrors.IsAlreadyExists(err), "expected AlreadyExists, got %v", err) + + got, err := client.Get(ctx, "drv", metav1.GetOptions{}) + require.NoError(t, err) + + got.Spec.Default = true + updated, err := client.Update(ctx, got, metav1.UpdateOptions{}) + require.NoError(t, err) + assert.True(t, updated.IsDefault()) + + updated.Status.State = nvidiav1alpha1.NotReady + statusUpdated, err := client.UpdateStatus(ctx, updated, metav1.UpdateOptions{}) + require.NoError(t, err) + assert.Equal(t, nvidiav1alpha1.NotReady, statusUpdated.Status.State) + + require.NoError(t, client.Delete(ctx, "drv", metav1.DeleteOptions{})) + _, err = client.Get(ctx, "drv", metav1.GetOptions{}) + assert.True(t, apierrors.IsNotFound(err), "expected NotFound after delete, got %v", err) +} + +// TestPatch covers the patch types the object tracker knows how to apply, and the +// NotFound / unsupported-patch-type failure modes. +func TestPatch(t *testing.T) { + ctx := t.Context() + + tests := []struct { + name string + patchType types.PatchType + patch string + wantErr func(t *testing.T, err error) + verify func(t *testing.T, cp *nvidiav1.ClusterPolicy) + }{ + { + name: "json merge patch updates labels", + patchType: types.MergePatchType, + patch: `{"metadata":{"labels":{"patched":"yes"}}}`, + verify: func(t *testing.T, cp *nvidiav1.ClusterPolicy) { + assert.Equal(t, "yes", cp.Labels["patched"]) + assert.Equal(t, "gpu-operator", cp.Labels["app"]) + }, + }, + { + name: "strategic merge patch updates spec", + patchType: types.StrategicMergePatchType, + patch: `{"spec":{"operator":{"runtimeClass":"nvidia-crio"}}}`, + verify: func(t *testing.T, cp *nvidiav1.ClusterPolicy) { + assert.Equal(t, "nvidia-crio", cp.Spec.Operator.RuntimeClass) + }, + }, + { + name: "json patch replaces a label", + patchType: types.JSONPatchType, + patch: `[{"op":"replace","path":"/metadata/labels/app","value":"patched"}]`, + verify: func(t *testing.T, cp *nvidiav1.ClusterPolicy) { + assert.Equal(t, "patched", cp.Labels["app"]) + }, + }, + { + name: "unsupported patch type is rejected", + patchType: types.PatchType("application/unknown"), + patch: `{}`, + wantErr: func(t *testing.T, err error) { + assert.ErrorContains(t, err, "is not supported") + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cs := NewSimpleClientset(newClusterPolicy("cp")) + client := cs.NvidiaV1().ClusterPolicies() + + patched, err := client.Patch(ctx, "cp", tt.patchType, []byte(tt.patch), metav1.PatchOptions{}) + if tt.wantErr != nil { + require.Error(t, err) + tt.wantErr(t, err) + return + } + require.NoError(t, err) + tt.verify(t, patched) + + // The patch must have been persisted, not just returned. + stored, err := client.Get(ctx, "cp", metav1.GetOptions{}) + require.NoError(t, err) + tt.verify(t, stored) + }) + } + + t.Run("patching a missing object is NotFound", func(t *testing.T) { + cs := NewSimpleClientset() + _, err := cs.NvidiaV1().ClusterPolicies().Patch(ctx, "ghost", types.MergePatchType, []byte(`{}`), metav1.PatchOptions{}) + require.Error(t, err) + assert.True(t, apierrors.IsNotFound(err), "expected NotFound, got %v", err) + }) +} + +// TestDeleteCollection covers the generated client's DeleteCollection contract: the +// call is turned into a single "delete-collection" action that carries the target +// GroupVersionResource, the (empty, because cluster scoped) namespace, and both the +// DeleteOptions and the ListOptions the caller supplied. +// +// Note: with the default reaction chain installed by NewSimpleClientset nothing is +// actually removed, because testing.ObjectReaction currently has no branch for +// DeleteCollectionActionImpl. That is an upstream client-go implementation detail +// which may change, so it is deliberately observed here rather than asserted. +func TestDeleteCollection(t *testing.T) { + ctx := t.Context() + cs := NewSimpleClientset(newClusterPolicy("cp-a"), newClusterPolicy("cp-b")) + client := cs.NvidiaV1().ClusterPolicies() + + gracePeriod := int64(30) + deleteOpts := metav1.DeleteOptions{GracePeriodSeconds: &gracePeriod} + listOpts := metav1.ListOptions{LabelSelector: "app=gpu-operator"} + + require.NoError(t, client.DeleteCollection(ctx, deleteOpts, listOpts)) + + var found k8stesting.DeleteCollectionActionImpl + var ok bool + for _, action := range cs.Actions() { + if dc, isDC := action.(k8stesting.DeleteCollectionActionImpl); isDC { + found, ok = dc, true + } + } + require.True(t, ok, "expected a delete-collection action to be recorded") + assert.Equal(t, "delete-collection", found.GetVerb()) + assert.Equal(t, clusterPolicyGVR, found.GetResource()) + assert.Empty(t, found.GetNamespace(), "clusterpolicies are cluster scoped") + assert.Equal(t, deleteOpts, found.GetDeleteOptions()) + assert.Equal(t, listOpts, found.GetListOptions()) + assert.Equal(t, "app=gpu-operator", found.GetListRestrictions().Labels.String()) +} + +// TestDeleteCollectionWithReactor shows that a reactor of our own, layered onto the +// generated client, can implement real collection deletion on top of the same +// delete-collection action. +func TestDeleteCollectionWithReactor(t *testing.T) { + ctx := t.Context() + cs := NewSimpleClientset(newClusterPolicy("cp-a"), newClusterPolicy("cp-b")) + client := cs.NvidiaV1().ClusterPolicies() + + cs.PrependReactor("delete-collection", "clusterpolicies", func(action k8stesting.Action) (bool, runtime.Object, error) { + dc, isDC := action.(k8stesting.DeleteCollectionActionImpl) + if !isDC { + return false, nil, nil + } + objs, err := cs.Tracker().List(dc.GetResource(), nvidiav1.SchemeGroupVersion.WithKind("ClusterPolicy"), dc.GetNamespace()) + if err != nil { + return true, nil, err + } + list, isList := objs.(*nvidiav1.ClusterPolicyList) + if !isList { + return true, nil, nil + } + for i := range list.Items { + if err := cs.Tracker().Delete(dc.GetResource(), dc.GetNamespace(), list.Items[i].Name); err != nil { + return true, nil, err + } + } + return true, nil, nil + }) + + require.NoError(t, client.DeleteCollection(ctx, metav1.DeleteOptions{}, metav1.ListOptions{})) + + list, err := client.List(ctx, metav1.ListOptions{}) + require.NoError(t, err) + assert.Empty(t, list.Items) +} + +// TestWatchReactorDeliversEvents verifies the default watch reactor wires the typed +// client's Watch to the tracker so create/update/delete produce watch events. +func TestWatchReactorDeliversEvents(t *testing.T) { + ctx := t.Context() + cs := NewSimpleClientset() + client := cs.NvidiaV1().ClusterPolicies() + + w, err := client.Watch(ctx, metav1.ListOptions{}) + require.NoError(t, err) + defer w.Stop() + + created, err := client.Create(ctx, newClusterPolicy("cp"), metav1.CreateOptions{}) + require.NoError(t, err) + ev := receiveEvent(t, w.ResultChan()) + assert.Equal(t, watch.Added, ev.Type) + addedObj, ok := ev.Object.(*nvidiav1.ClusterPolicy) + require.True(t, ok, "expected *ClusterPolicy, got %T", ev.Object) + assert.Equal(t, "cp", addedObj.Name) + + created.Spec.Operator.RuntimeClass = "nvidia-crio" + _, err = client.Update(ctx, created, metav1.UpdateOptions{}) + require.NoError(t, err) + ev = receiveEvent(t, w.ResultChan()) + assert.Equal(t, watch.Modified, ev.Type) + modifiedObj, ok := ev.Object.(*nvidiav1.ClusterPolicy) + require.True(t, ok, "expected *ClusterPolicy, got %T", ev.Object) + assert.Equal(t, "nvidia-crio", modifiedObj.Spec.Operator.RuntimeClass) + + require.NoError(t, client.Delete(ctx, "cp", metav1.DeleteOptions{})) + ev = receiveEvent(t, w.ResultChan()) + assert.Equal(t, watch.Deleted, ev.Type) + deletedObj, ok := ev.Object.(*nvidiav1.ClusterPolicy) + require.True(t, ok, "expected *ClusterPolicy, got %T", ev.Object) + assert.Equal(t, "cp", deletedObj.Name) +} + +// TestWatchForwardsListOptions verifies the generated client always forwards the +// caller's ListOptions into the watch action, and therefore into the watch reactor +// and tracker.Watch. +func TestWatchForwardsListOptions(t *testing.T) { + ctx := t.Context() + cs := NewSimpleClientset(newNVIDIADriver("drv")) + + opts := metav1.ListOptions{ResourceVersion: "0", LabelSelector: "app=gpu-operator"} + w, err := cs.NvidiaV1alpha1().NVIDIADrivers().Watch(ctx, opts) + require.NoError(t, err) + defer w.Stop() + + var found k8stesting.WatchActionImpl + var ok bool + for _, action := range cs.Actions() { + if wa, isWatch := action.(k8stesting.WatchActionImpl); isWatch { + found, ok = wa, true + } + } + require.True(t, ok, "expected a watch action to be recorded") + assert.Equal(t, "watch", found.GetVerb()) + assert.Equal(t, nvidiaDriverGVR, found.GetResource()) + assert.Empty(t, found.GetNamespace(), "nvidiadrivers are cluster scoped") + wantOpts := opts + wantOpts.Watch = true // the only field the generated client sets itself + assert.Equal(t, wantOpts, found.GetListOptions(), "the generated client must forward the caller's ListOptions") + assert.Equal(t, "0", found.GetWatchRestrictions().ResourceVersion) + assert.Equal(t, "app=gpu-operator", found.GetWatchRestrictions().Labels.String()) + + // Deliberately no assertion on replayed events. Whether tracker.Watch replays + // already-known objects, and whether it applies the label selector when it + // does, is client-go's business rather than part of the generated client's + // contract. Event delivery for create/update/delete is covered by + // TestWatchReactorDeliversEvents. +} + +// TestWatchReactorPropagatesTrackerError covers the error branch of the default +// watch reactor: when the tracker rejects the ListOptions the reactor reports the +// action as unhandled and the watch fails instead of returning a live channel. +func TestWatchReactorPropagatesTrackerError(t *testing.T) { + ctx := t.Context() + cs := NewSimpleClientset() + + // Only the observable contract is asserted: no watcher, and an error. The + // wording ("unhandled watch") belongs to client-go's testing package and can + // change on a dependency bump without the behaviour changing. + w, err := cs.NvidiaV1().ClusterPolicies().Watch(ctx, metav1.ListOptions{ResourceVersion: "not-an-int"}) + require.Error(t, err) + assert.Nil(t, w) +} + +// TestRecordedActions asserts that verbs, resources and subresources land on the +// shared Fake in order, and that ClearActions resets the log. +func TestRecordedActions(t *testing.T) { + ctx := t.Context() + cs := NewSimpleClientset() + + cp, err := cs.NvidiaV1().ClusterPolicies().Create(ctx, newClusterPolicy("cp"), metav1.CreateOptions{}) + require.NoError(t, err) + _, err = cs.NvidiaV1().ClusterPolicies().Get(ctx, "cp", metav1.GetOptions{}) + require.NoError(t, err) + _, err = cs.NvidiaV1().ClusterPolicies().List(ctx, metav1.ListOptions{}) + require.NoError(t, err) + _, err = cs.NvidiaV1().ClusterPolicies().UpdateStatus(ctx, cp, metav1.UpdateOptions{}) + require.NoError(t, err) + require.NoError(t, cs.NvidiaV1().ClusterPolicies().Delete(ctx, "cp", metav1.DeleteOptions{})) + + actions := cs.Actions() + require.Len(t, actions, 5) + + type want struct { + verb string + subresource string + } + expected := []want{ + {verb: "create"}, + {verb: "get"}, + {verb: "list"}, + {verb: "update", subresource: "status"}, + {verb: "delete"}, + } + for i, exp := range expected { + assert.Equal(t, exp.verb, actions[i].GetVerb(), "action %d verb", i) + assert.Equal(t, exp.subresource, actions[i].GetSubresource(), "action %d subresource", i) + assert.Equal(t, clusterPolicyGVR, actions[i].GetResource(), "action %d resource", i) + // Both resources are cluster scoped. + assert.Empty(t, actions[i].GetNamespace(), "action %d namespace", i) + } + + cs.ClearActions() + assert.Empty(t, cs.Actions()) + + // After clearing, new actions are recorded from scratch. + _, err = cs.NvidiaV1alpha1().NVIDIADrivers().List(ctx, metav1.ListOptions{}) + require.NoError(t, err) + actions = cs.Actions() + require.Len(t, actions, 1) + assert.Equal(t, "list", actions[0].GetVerb()) + assert.Equal(t, nvidiaDriverGVR, actions[0].GetResource()) +} + +// TestPrependReactorInterceptsChain proves the reaction chain is live: a prepended +// reactor wins over the default ObjectReaction installed by NewSimpleClientset. +func TestPrependReactorInterceptsChain(t *testing.T) { + ctx := t.Context() + cs := NewSimpleClientset(newClusterPolicy("cp")) + + canned := apierrors.NewInternalError(assert.AnError) + cs.PrependReactor("get", "clusterpolicies", func(action k8stesting.Action) (bool, runtime.Object, error) { + return true, nil, canned + }) + + _, err := cs.NvidiaV1().ClusterPolicies().Get(ctx, "cp", metav1.GetOptions{}) + require.Error(t, err) + assert.True(t, apierrors.IsInternalError(err), "expected the canned internal error, got %v", err) + + // Only "get" on clusterpolicies is intercepted; everything else falls through + // to the tracker-backed reactor. + list, err := cs.NvidiaV1().ClusterPolicies().List(ctx, metav1.ListOptions{}) + require.NoError(t, err) + assert.Len(t, list.Items, 1) + + _, err = cs.NvidiaV1alpha1().NVIDIADrivers().Get(ctx, "drv", metav1.GetOptions{}) + assert.True(t, apierrors.IsNotFound(err), "expected NotFound from the tracker, got %v", err) +} + +// TestPrependReactorUnhandledFallsThrough verifies a reactor returning handled=false +// delegates to the next reactor in the chain rather than short circuiting. +func TestPrependReactorUnhandledFallsThrough(t *testing.T) { + ctx := t.Context() + cs := NewSimpleClientset(newClusterPolicy("cp")) + + var called bool + cs.PrependReactor("*", "*", func(action k8stesting.Action) (bool, runtime.Object, error) { + called = true + return false, nil, assert.AnError + }) + + got, err := cs.NvidiaV1().ClusterPolicies().Get(ctx, "cp", metav1.GetOptions{}) + require.NoError(t, err) + assert.Equal(t, "cp", got.Name) + assert.True(t, called, "the prepended reactor should have been consulted") +} + +// TestNewSimpleClientsetPanicsOnUnregisteredType verifies the constructor panics +// when a seed object has no kind in the fake scheme. +func TestNewSimpleClientsetPanicsOnUnregisteredType(t *testing.T) { + var recovered any + func() { + defer func() { recovered = recover() }() + NewSimpleClientset(&unregisteredTestType{ObjectMeta: metav1.ObjectMeta{Name: "p", Namespace: "default"}}) + }() + require.NotNil(t, recovered, "seeding an unregistered type must panic") + err, ok := recovered.(error) + require.True(t, ok, "expected the panic value to be an error, got %T", recovered) + assert.True(t, runtime.IsNotRegisteredError(err), "expected a not-registered error, got %v", err) + + // A registered type must not panic. + assert.NotPanics(t, func() { + NewSimpleClientset(newClusterPolicy("cp")) + }) +} + +// TestTrackerIsShared verifies Tracker() hands back the very tracker the typed +// clients read from and write to. +func TestTrackerIsShared(t *testing.T) { + ctx := t.Context() + cs := NewSimpleClientset() + + // Mutating through the tracker is visible through the typed client... + require.NoError(t, cs.Tracker().Add(newClusterPolicy("cp"))) + got, err := cs.NvidiaV1().ClusterPolicies().Get(ctx, "cp", metav1.GetOptions{}) + require.NoError(t, err) + assert.Equal(t, "cp", got.Name) + + // ...and writing through the typed client is visible in the tracker. + _, err = cs.NvidiaV1alpha1().NVIDIADrivers().Create(ctx, newNVIDIADriver("drv"), metav1.CreateOptions{}) + require.NoError(t, err) + obj, err := cs.Tracker().Get(nvidiaDriverGVR, "", "drv") + require.NoError(t, err) + drv, ok := obj.(*nvidiav1alpha1.NVIDIADriver) + require.True(t, ok, "expected *NVIDIADriver, got %T", obj) + assert.Equal(t, "drv", drv.Name) + + // Deleting via the tracker removes it from the typed client's view. + require.NoError(t, cs.Tracker().Delete(clusterPolicyGVR, "", "cp")) + _, err = cs.NvidiaV1().ClusterPolicies().Get(ctx, "cp", metav1.GetOptions{}) + assert.True(t, apierrors.IsNotFound(err), "expected NotFound, got %v", err) + + // Tracker() is stable across calls. + assert.Same(t, cs.Tracker(), cs.Tracker()) + assert.Equal(t, cs.tracker, cs.Tracker()) +} + +// TestDiscovery verifies Discovery() returns a FakeDiscovery bound to the same +// embedded Fake, so data set on the clientset is served through discovery. +func TestDiscovery(t *testing.T) { + cs := NewSimpleClientset() + + disco := cs.Discovery() + require.NotNil(t, disco) + + fd, ok := disco.(*fakediscovery.FakeDiscovery) + require.True(t, ok, "expected *fakediscovery.FakeDiscovery, got %T", disco) + assert.Same(t, &cs.Fake, fd.Fake, "discovery must be wired to the clientset's Fake") + + // Resources set on the shared Fake are served by discovery. + cs.Resources = []*metav1.APIResourceList{ + { + GroupVersion: nvidiav1.SchemeGroupVersion.String(), + APIResources: []metav1.APIResource{ + {Name: "clusterpolicies", Kind: "ClusterPolicy", Namespaced: false}, + }, + }, + } + rl, err := disco.ServerResourcesForGroupVersion(nvidiav1.SchemeGroupVersion.String()) + require.NoError(t, err) + require.Len(t, rl.APIResources, 1) + assert.Equal(t, "ClusterPolicy", rl.APIResources[0].Kind) + + // And a faked server version is read back through the interface. + fd.FakedServerVersion = &version.Info{GitVersion: "v1.31.0", Major: "1", Minor: "31"} + v, err := disco.ServerVersion() + require.NoError(t, err) + assert.Equal(t, "v1.31.0", v.GitVersion) + + // Discovery calls are recorded on the shared Fake too. + assert.NotEmpty(t, cs.Actions()) +} + +// TestIsWatchListSemanticsUnSupported documents that this fake opts out of +// WatchList semantics for the reflector's optional interface check. +func TestIsWatchListSemanticsUnSupported(t *testing.T) { + cs := NewSimpleClientset() + assert.True(t, cs.IsWatchListSemanticsUnSupported()) + + var fc k8stesting.FakeClient = cs + require.NotNil(t, fc.Tracker()) +} + +// TestClientsetInterfaceAssertions mirrors the compile-time var block in +// clientset_generated.go as runtime assertions. +func TestClientsetInterfaceAssertions(t *testing.T) { + cs := NewSimpleClientset() + + var _ clientset.Interface = cs + var _ k8stesting.FakeClient = cs + + assert.Implements(t, (*clientset.Interface)(nil), cs) + assert.Implements(t, (*k8stesting.FakeClient)(nil), cs) + + require.NotNil(t, cs.NvidiaV1()) + require.NotNil(t, cs.NvidiaV1alpha1()) + require.NotNil(t, cs.NvidiaV1().ClusterPolicies()) + require.NotNil(t, cs.NvidiaV1alpha1().NVIDIADrivers()) + require.NotNil(t, cs.NvidiaV1alpha1().GPUClusters()) +} + +// TestGroupClientsShareTheSameFake verifies both group accessors point at the one +// embedded Fake, so their actions and reactors are shared. +func TestGroupClientsShareTheSameFake(t *testing.T) { + cs := NewSimpleClientset() + + v1Group, ok := cs.NvidiaV1().(*fakenvidiav1.FakeNvidiaV1) + require.True(t, ok, "expected *FakeNvidiaV1, got %T", cs.NvidiaV1()) + assert.Same(t, &cs.Fake, v1Group.Fake) + + v1alpha1Group, ok := cs.NvidiaV1alpha1().(*fakenvidiav1alpha1.FakeNvidiaV1alpha1) + require.True(t, ok, "expected *FakeNvidiaV1alpha1, got %T", cs.NvidiaV1alpha1()) + assert.Same(t, &cs.Fake, v1alpha1Group.Fake) +} + +// TestActionsFromBothGroupsLandOnOneFake asserts operations issued through the two +// group clients are recorded on a single shared action log. +func TestActionsFromBothGroupsLandOnOneFake(t *testing.T) { + ctx := t.Context() + cs := NewSimpleClientset() + + _, err := cs.NvidiaV1().ClusterPolicies().List(ctx, metav1.ListOptions{}) + require.NoError(t, err) + _, err = cs.NvidiaV1alpha1().NVIDIADrivers().List(ctx, metav1.ListOptions{}) + require.NoError(t, err) + _, err = cs.NvidiaV1alpha1().GPUClusters().List(ctx, metav1.ListOptions{}) + require.NoError(t, err) + + actions := cs.Actions() + require.Len(t, actions, 3, "all three group clients must record onto the same Fake") + assert.Equal(t, clusterPolicyGVR, actions[0].GetResource()) + assert.Equal(t, nvidiaDriverGVR, actions[1].GetResource()) + assert.Equal(t, "gpuclusters", actions[2].GetResource().Resource) + + // A single reactor registered on the clientset affects both groups. + cs.PrependReactor("list", "*", func(action k8stesting.Action) (bool, runtime.Object, error) { + return true, nil, apierrors.NewServiceUnavailable("down") + }) + _, err = cs.NvidiaV1().ClusterPolicies().List(ctx, metav1.ListOptions{}) + assert.Error(t, err) + _, err = cs.NvidiaV1alpha1().NVIDIADrivers().List(ctx, metav1.ListOptions{}) + assert.Error(t, err) + + // Separate clientsets are fully isolated from one another. + other := NewSimpleClientset() + _, err = other.NvidiaV1().ClusterPolicies().List(ctx, metav1.ListOptions{}) + assert.NoError(t, err, "a reactor on one clientset must not affect another") +} + +// TestSeededObjectsAreDeepCopied verifies the tracker stores copies, so mutating +// the seed object after construction does not corrupt the fake's state. +func TestSeededObjectsAreDeepCopied(t *testing.T) { + ctx := t.Context() + seed := newClusterPolicy("cp") + cs := NewSimpleClientset(seed) + + seed.Spec.Operator.RuntimeClass = "nvidia-crio" + seed.Labels["app"] = "mutated" + + got, err := cs.NvidiaV1().ClusterPolicies().Get(ctx, "cp", metav1.GetOptions{}) + require.NoError(t, err) + assert.Equal(t, "nvidia", got.Spec.Operator.RuntimeClass) + assert.Equal(t, "gpu-operator", got.Labels["app"]) + + // Mutating a returned object does not affect the tracker either. + got.Labels["app"] = "mutated-again" + fresh, err := cs.NvidiaV1().ClusterPolicies().Get(ctx, "cp", metav1.GetOptions{}) + require.NoError(t, err) + assert.Equal(t, "gpu-operator", fresh.Labels["app"]) +} + +// TestJSONRoundTripThroughTracker sanity checks that objects served by the fake +// serialize with the expected apiVersion/kind once the tracker has stamped them. +func TestJSONRoundTripThroughTracker(t *testing.T) { + ctx := t.Context() + cs := NewSimpleClientset(newClusterPolicy("cp")) + + got, err := cs.NvidiaV1().ClusterPolicies().Get(ctx, "cp", metav1.GetOptions{}) + require.NoError(t, err) + + got.TypeMeta = metav1.TypeMeta{APIVersion: nvidiav1.SchemeGroupVersion.String(), Kind: "ClusterPolicy"} + data, err := json.Marshal(got) + require.NoError(t, err) + assert.Contains(t, string(data), `"apiVersion":"nvidia.com/v1"`) + assert.Contains(t, string(data), `"kind":"ClusterPolicy"`) +} diff --git a/api/versioned/fake/register_test.go b/api/versioned/fake/register_test.go new file mode 100644 index 0000000000..330edea6f8 --- /dev/null +++ b/api/versioned/fake/register_test.go @@ -0,0 +1,204 @@ +/** +# Copyright (c) NVIDIA CORPORATION. All rights reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +**/ + +package fake + +import ( + "encoding/json" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/runtime/schema" + + nvidiav1 "github.com/NVIDIA/gpu-operator/api/nvidia/v1" + nvidiav1alpha1 "github.com/NVIDIA/gpu-operator/api/nvidia/v1alpha1" +) + +// TestPackageSchemeRecognizesKinds asserts the package-level scheme built in +// init() knows every kind the fake clientset needs, including the metav1 helper +// types registered under GroupVersion{Version: "v1"}. +func TestPackageSchemeRecognizesKinds(t *testing.T) { + tests := []struct { + name string + gvk schema.GroupVersionKind + }{ + {"ClusterPolicy", nvidiav1.SchemeGroupVersion.WithKind("ClusterPolicy")}, + {"ClusterPolicyList", nvidiav1.SchemeGroupVersion.WithKind("ClusterPolicyList")}, + {"NVIDIADriver", nvidiav1alpha1.SchemeGroupVersion.WithKind("NVIDIADriver")}, + {"NVIDIADriverList", nvidiav1alpha1.SchemeGroupVersion.WithKind("NVIDIADriverList")}, + {"GPUCluster", nvidiav1alpha1.SchemeGroupVersion.WithKind("GPUCluster")}, + {"GPUClusterList", nvidiav1alpha1.SchemeGroupVersion.WithKind("GPUClusterList")}, + // Added by init() via metav1.AddToGroupVersion(scheme, {Version: "v1"}). + {"core v1 ListOptions", schema.GroupVersionKind{Version: "v1", Kind: "ListOptions"}}, + {"core v1 GetOptions", schema.GroupVersionKind{Version: "v1", Kind: "GetOptions"}}, + {"core v1 DeleteOptions", schema.GroupVersionKind{Version: "v1", Kind: "DeleteOptions"}}, + {"core v1 WatchEvent", schema.GroupVersionKind{Version: "v1", Kind: "WatchEvent"}}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert.True(t, scheme.Recognizes(tt.gvk), "scheme should recognize %s", tt.gvk) + obj, err := scheme.New(tt.gvk) + require.NoError(t, err) + assert.NotNil(t, obj) + }) + } + + // Types outside this clientset must not be registered. + assert.False(t, scheme.Recognizes(schema.GroupVersionKind{Version: "v1", Kind: "Pod"})) + assert.False(t, scheme.Recognizes(nvidiav1.SchemeGroupVersion.WithKind("NVIDIADriver"))) +} + +// TestPackageSchemeObjectKinds verifies concrete Go types map back to the +// expected GroupVersionKind through the package scheme. +func TestPackageSchemeObjectKinds(t *testing.T) { + tests := []struct { + name string + obj runtime.Object + want schema.GroupVersionKind + }{ + {"ClusterPolicy", &nvidiav1.ClusterPolicy{}, nvidiav1.SchemeGroupVersion.WithKind("ClusterPolicy")}, + {"ClusterPolicyList", &nvidiav1.ClusterPolicyList{}, nvidiav1.SchemeGroupVersion.WithKind("ClusterPolicyList")}, + {"NVIDIADriver", &nvidiav1alpha1.NVIDIADriver{}, nvidiav1alpha1.SchemeGroupVersion.WithKind("NVIDIADriver")}, + {"NVIDIADriverList", &nvidiav1alpha1.NVIDIADriverList{}, nvidiav1alpha1.SchemeGroupVersion.WithKind("NVIDIADriverList")}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + gvks, unversioned, err := scheme.ObjectKinds(tt.obj) + require.NoError(t, err) + assert.False(t, unversioned) + assert.Contains(t, gvks, tt.want) + }) + } +} + +// TestAddToSchemeOnFreshScheme verifies the exported AddToScheme registers the +// clientset's kinds into a caller-supplied scheme. +func TestAddToSchemeOnFreshScheme(t *testing.T) { + fresh := runtime.NewScheme() + + // Nothing is known before AddToScheme. + require.False(t, fresh.Recognizes(nvidiav1.SchemeGroupVersion.WithKind("ClusterPolicy"))) + + require.NoError(t, AddToScheme(fresh)) + + for _, gvk := range []schema.GroupVersionKind{ + nvidiav1.SchemeGroupVersion.WithKind("ClusterPolicy"), + nvidiav1.SchemeGroupVersion.WithKind("ClusterPolicyList"), + nvidiav1alpha1.SchemeGroupVersion.WithKind("NVIDIADriver"), + nvidiav1alpha1.SchemeGroupVersion.WithKind("NVIDIADriverList"), + nvidiav1alpha1.SchemeGroupVersion.WithKind("GPUCluster"), + nvidiav1alpha1.SchemeGroupVersion.WithKind("GPUClusterList"), + } { + assert.True(t, fresh.Recognizes(gvk), "fresh scheme should recognize %s", gvk) + } + + // AddToScheme only registers metav1 helpers under the nvidia.com group versions; + // the GroupVersion{Version: "v1"} registration is done by this package's init(). + assert.True(t, fresh.Recognizes(nvidiav1.SchemeGroupVersion.WithKind("ListOptions"))) + assert.False(t, fresh.Recognizes(schema.GroupVersionKind{Version: "v1", Kind: "ListOptions"})) + + // Applying it twice is a no-op rather than an error. + assert.NoError(t, AddToScheme(fresh)) +} + +// TestLocalSchemeBuilderMembership asserts the builder wires up exactly the two +// API groups this clientset serves. +func TestLocalSchemeBuilderMembership(t *testing.T) { + require.Len(t, localSchemeBuilder, 2) + + fresh := runtime.NewScheme() + require.NoError(t, localSchemeBuilder.AddToScheme(fresh)) + assert.True(t, fresh.Recognizes(nvidiav1.SchemeGroupVersion.WithKind("ClusterPolicy"))) + assert.True(t, fresh.Recognizes(nvidiav1alpha1.SchemeGroupVersion.WithKind("NVIDIADriver"))) +} + +// TestCodecsUniversalDecoderDecodesClusterPolicy verifies the codec factory built +// over the package scheme round-trips a serialized ClusterPolicy. +func TestCodecsUniversalDecoderDecodesClusterPolicy(t *testing.T) { + original := &nvidiav1.ClusterPolicy{ + TypeMeta: metav1.TypeMeta{ + APIVersion: nvidiav1.SchemeGroupVersion.String(), + Kind: "ClusterPolicy", + }, + ObjectMeta: metav1.ObjectMeta{ + Name: "cluster-policy", + Labels: map[string]string{"app": "gpu-operator"}, + }, + Spec: nvidiav1.ClusterPolicySpec{ + Operator: nvidiav1.OperatorSpec{RuntimeClass: "nvidia"}, + }, + Status: nvidiav1.ClusterPolicyStatus{ + State: nvidiav1.Ready, + Namespace: "gpu-operator", + }, + } + + data, err := json.Marshal(original) + require.NoError(t, err) + + decoder := codecs.UniversalDecoder() + into := &nvidiav1.ClusterPolicy{} + obj, gvk, err := decoder.Decode(data, nil, into) + require.NoError(t, err) + require.NotNil(t, gvk) + assert.Equal(t, nvidiav1.SchemeGroupVersion.WithKind("ClusterPolicy"), *gvk) + + decoded, ok := obj.(*nvidiav1.ClusterPolicy) + require.True(t, ok, "expected *ClusterPolicy, got %T", obj) + assert.Equal(t, "cluster-policy", decoded.Name) + assert.Equal(t, "gpu-operator", decoded.Labels["app"]) + assert.Equal(t, "nvidia", decoded.Spec.Operator.RuntimeClass) + assert.Equal(t, nvidiav1.Ready, decoded.Status.State) + assert.Equal(t, "gpu-operator", decoded.Status.Namespace) +} + +// TestCodecsUniversalDecoderRejectsUnknownKind verifies decoding an object whose +// kind is absent from the fake scheme fails instead of silently succeeding. +func TestCodecsUniversalDecoderRejectsUnknownKind(t *testing.T) { + data := []byte(`{"apiVersion":"v1","kind":"Pod","metadata":{"name":"p"}}`) + _, _, err := codecs.UniversalDecoder().Decode(data, nil, nil) + require.Error(t, err) + assert.True(t, runtime.IsNotRegisteredError(err), "expected a not-registered error, got %v", err) +} + +// TestCodecsUniversalDeserializerDecodesNVIDIADriver covers the v1alpha1 group +// through the same codec factory. +func TestCodecsUniversalDeserializerDecodesNVIDIADriver(t *testing.T) { + data := []byte(`{ + "apiVersion":"nvidia.com/v1alpha1", + "kind":"NVIDIADriver", + "metadata":{"name":"gpu-driver"}, + "spec":{"driverType":"gpu","default":true}, + "status":{"state":"ready"} + }`) + + obj, gvk, err := codecs.UniversalDeserializer().Decode(data, nil, nil) + require.NoError(t, err) + require.NotNil(t, gvk) + assert.Equal(t, nvidiav1alpha1.SchemeGroupVersion.WithKind("NVIDIADriver"), *gvk) + + drv, ok := obj.(*nvidiav1alpha1.NVIDIADriver) + require.True(t, ok, "expected *NVIDIADriver, got %T", obj) + assert.Equal(t, "gpu-driver", drv.Name) + assert.Equal(t, nvidiav1alpha1.GPU, drv.Spec.DriverType) + assert.True(t, drv.IsDefault()) + assert.Equal(t, nvidiav1alpha1.Ready, drv.Status.State) +} diff --git a/api/versioned/scheme/register_test.go b/api/versioned/scheme/register_test.go new file mode 100644 index 0000000000..f89b434051 --- /dev/null +++ b/api/versioned/scheme/register_test.go @@ -0,0 +1,558 @@ +/** +# Copyright (c) NVIDIA CORPORATION. All rights reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +**/ + +package scheme + +import ( + "net/url" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/runtime/schema" + + nvidiav1 "github.com/NVIDIA/gpu-operator/api/nvidia/v1" + nvidiav1alpha1 "github.com/NVIDIA/gpu-operator/api/nvidia/v1alpha1" +) + +// metaV1GV is the group version that register.go passes to metav1.AddToGroupVersion. +var metaV1GV = schema.GroupVersion{Version: "v1"} + +func boolPtr(b bool) *bool { return &b } + +// newClusterPolicy returns a ClusterPolicy with enough populated fields to make a +// serialization round trip meaningful. +func newClusterPolicy() *nvidiav1.ClusterPolicy { + return &nvidiav1.ClusterPolicy{ + ObjectMeta: metav1.ObjectMeta{ + Name: "cluster-policy", + Labels: map[string]string{"app": "gpu-operator"}, + }, + Spec: nvidiav1.ClusterPolicySpec{ + Operator: nvidiav1.OperatorSpec{ + RuntimeClass: "nvidia", + }, + Driver: nvidiav1.DriverSpec{ + Enabled: boolPtr(true), + Repository: "nvcr.io/nvidia", + }, + }, + Status: nvidiav1.ClusterPolicyStatus{ + State: nvidiav1.Ready, + Namespace: "gpu-operator", + }, + } +} + +// newNVIDIADriver returns an NVIDIADriver with enough populated fields to make a +// serialization round trip meaningful. +func newNVIDIADriver() *nvidiav1alpha1.NVIDIADriver { + return &nvidiav1alpha1.NVIDIADriver{ + ObjectMeta: metav1.ObjectMeta{ + Name: "nvidia-driver", + Labels: map[string]string{"app": "nvidia-driver"}, + }, + Spec: nvidiav1alpha1.NVIDIADriverSpec{ + Default: true, + DriverType: nvidiav1alpha1.GPU, + Image: "driver", + Repository: "nvcr.io/nvidia", + Version: "550.54.14", + NodeSelector: map[string]string{"nvidia.com/gpu.present": "true"}, + }, + Status: nvidiav1alpha1.NVIDIADriverStatus{ + State: nvidiav1alpha1.Ready, + Namespace: "gpu-operator", + }, + } +} + +func newGPUCluster() *nvidiav1alpha1.GPUCluster { + return &nvidiav1alpha1.GPUCluster{ + ObjectMeta: metav1.ObjectMeta{ + Name: "gpu-cluster", + Labels: map[string]string{"app": "gpu-cluster"}, + }, + Spec: nvidiav1alpha1.GPUClusterSpec{ + DRADriver: nvidiav1alpha1.DRADriverSpec{ + Repository: "nvcr.io/nvidia/cloud-native", + Image: "k8s-dra-driver-gpu", + Version: "v25.3.0", + FeatureGates: map[string]bool{"ComputeDomains": true}, + }, + }, + } +} + +// TestSchemeRecognizesNVIDIAKinds asserts that the package level Scheme, populated by +// init(), maps every NVIDIA type to its expected GroupVersionKind. +func TestSchemeRecognizesNVIDIAKinds(t *testing.T) { + tests := []struct { + name string + obj runtime.Object + gvk schema.GroupVersionKind + }{ + { + name: "ClusterPolicy", + obj: &nvidiav1.ClusterPolicy{}, + gvk: nvidiav1.SchemeGroupVersion.WithKind("ClusterPolicy"), + }, + { + name: "ClusterPolicyList", + obj: &nvidiav1.ClusterPolicyList{}, + gvk: nvidiav1.SchemeGroupVersion.WithKind("ClusterPolicyList"), + }, + { + name: "NVIDIADriver", + obj: &nvidiav1alpha1.NVIDIADriver{}, + gvk: nvidiav1alpha1.SchemeGroupVersion.WithKind("NVIDIADriver"), + }, + { + name: "NVIDIADriverList", + obj: &nvidiav1alpha1.NVIDIADriverList{}, + gvk: nvidiav1alpha1.SchemeGroupVersion.WithKind("NVIDIADriverList"), + }, + { + name: "GPUCluster", + obj: &nvidiav1alpha1.GPUCluster{}, + gvk: nvidiav1alpha1.SchemeGroupVersion.WithKind("GPUCluster"), + }, + { + name: "GPUClusterList", + obj: &nvidiav1alpha1.GPUClusterList{}, + gvk: nvidiav1alpha1.SchemeGroupVersion.WithKind("GPUClusterList"), + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Go type -> GVK + gvks, unversioned, err := Scheme.ObjectKinds(tt.obj) + require.NoError(t, err) + assert.False(t, unversioned, "NVIDIA types must not be registered as unversioned") + assert.Equal(t, []schema.GroupVersionKind{tt.gvk}, gvks) + + // GVK -> Go type + out, err := Scheme.New(tt.gvk) + require.NoError(t, err) + assert.IsType(t, tt.obj, out) + assert.True(t, Scheme.Recognizes(tt.gvk)) + }) + } +} + +// TestSchemeRejectsUnknownKind asserts that kinds that were never registered are not +// silently accepted by the Scheme. +func TestSchemeRejectsUnknownKind(t *testing.T) { + tests := []schema.GroupVersionKind{ + nvidiav1.SchemeGroupVersion.WithKind("NotAThing"), + // NVIDIADriver only exists in v1alpha1, not in v1. + nvidiav1.SchemeGroupVersion.WithKind("NVIDIADriver"), + // ClusterPolicy only exists in v1, not in v1alpha1. + nvidiav1alpha1.SchemeGroupVersion.WithKind("ClusterPolicy"), + {Group: "other.com", Version: "v1", Kind: "ClusterPolicy"}, + } + + for _, gvk := range tests { + t.Run(gvk.String(), func(t *testing.T) { + assert.False(t, Scheme.Recognizes(gvk)) + _, err := Scheme.New(gvk) + require.Error(t, err) + assert.True(t, runtime.IsNotRegisteredError(err), "expected a not-registered error, got %v", err) + }) + } +} + +// TestMetaV1TypesRegistered asserts the effects of metav1.AddToGroupVersion: the shared +// meta types are resolvable under the bare "v1" group version. +func TestMetaV1TypesRegistered(t *testing.T) { + tests := []struct { + name string + kind string + obj runtime.Object + }{ + {name: "ListOptions", kind: "ListOptions", obj: &metav1.ListOptions{}}, + {name: "GetOptions", kind: "GetOptions", obj: &metav1.GetOptions{}}, + {name: "DeleteOptions", kind: "DeleteOptions", obj: &metav1.DeleteOptions{}}, + {name: "CreateOptions", kind: "CreateOptions", obj: &metav1.CreateOptions{}}, + {name: "UpdateOptions", kind: "UpdateOptions", obj: &metav1.UpdateOptions{}}, + {name: "PatchOptions", kind: "PatchOptions", obj: &metav1.PatchOptions{}}, + {name: "WatchEvent", kind: "WatchEvent", obj: &metav1.WatchEvent{}}, + {name: "Status", kind: "Status", obj: &metav1.Status{}}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + gvk := metaV1GV.WithKind(tt.kind) + require.True(t, Scheme.Recognizes(gvk), "%s should be registered under %s", tt.kind, metaV1GV) + + out, err := Scheme.New(gvk) + require.NoError(t, err) + assert.IsType(t, tt.obj, out) + + gvks, _, err := Scheme.ObjectKinds(tt.obj) + require.NoError(t, err) + assert.Contains(t, gvks, gvk) + }) + } +} + +// TestStatusIsUnversioned asserts that metav1.Status is registered as an unversioned type, +// which is what allows API error responses to decode regardless of the request group version. +func TestStatusIsUnversioned(t *testing.T) { + _, unversioned, err := Scheme.ObjectKinds(&metav1.Status{}) + require.NoError(t, err) + assert.True(t, unversioned) +} + +// TestCodecsRoundTrip encodes each NVIDIA object with the legacy codec for its own group +// version and decodes it back, asserting both the TypeMeta stamped on the wire format and +// full fidelity of the payload. +func TestCodecsRoundTrip(t *testing.T) { + tests := []struct { + name string + gv schema.GroupVersion + obj runtime.Object + gvk schema.GroupVersionKind + }{ + { + name: "ClusterPolicy", + gv: nvidiav1.SchemeGroupVersion, + obj: newClusterPolicy(), + gvk: nvidiav1.SchemeGroupVersion.WithKind("ClusterPolicy"), + }, + { + name: "ClusterPolicyList", + gv: nvidiav1.SchemeGroupVersion, + obj: &nvidiav1.ClusterPolicyList{ + Items: []nvidiav1.ClusterPolicy{*newClusterPolicy()}, + }, + gvk: nvidiav1.SchemeGroupVersion.WithKind("ClusterPolicyList"), + }, + { + name: "NVIDIADriver", + gv: nvidiav1alpha1.SchemeGroupVersion, + obj: newNVIDIADriver(), + gvk: nvidiav1alpha1.SchemeGroupVersion.WithKind("NVIDIADriver"), + }, + { + name: "NVIDIADriverList", + gv: nvidiav1alpha1.SchemeGroupVersion, + obj: &nvidiav1alpha1.NVIDIADriverList{ + Items: []nvidiav1alpha1.NVIDIADriver{*newNVIDIADriver()}, + }, + gvk: nvidiav1alpha1.SchemeGroupVersion.WithKind("NVIDIADriverList"), + }, + { + name: "GPUCluster", + gv: nvidiav1alpha1.SchemeGroupVersion, + obj: newGPUCluster(), + gvk: nvidiav1alpha1.SchemeGroupVersion.WithKind(nvidiav1alpha1.GPUClusterCRDName), + }, + { + name: "GPUClusterList", + gv: nvidiav1alpha1.SchemeGroupVersion, + obj: &nvidiav1alpha1.GPUClusterList{ + Items: []nvidiav1alpha1.GPUCluster{*newGPUCluster()}, + }, + gvk: nvidiav1alpha1.SchemeGroupVersion.WithKind("GPUClusterList"), + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + original := tt.obj.DeepCopyObject() + + data, err := runtime.Encode(Codecs.LegacyCodec(tt.gv), tt.obj) + require.NoError(t, err) + assert.Contains(t, string(data), `"apiVersion":"`+tt.gv.String()+`"`) + assert.Contains(t, string(data), `"kind":"`+tt.gvk.Kind+`"`) + + // Encoding must not mutate the object handed to the codec. + assert.Equal(t, original, tt.obj) + + decoded, gvk, err := Codecs.UniversalDeserializer().Decode(data, nil, nil) + require.NoError(t, err) + require.NotNil(t, gvk) + assert.Equal(t, tt.gvk, *gvk) + assert.IsType(t, tt.obj, decoded) + + // The decoded object carries the TypeMeta it was serialized with. + assert.Equal(t, tt.gvk, decoded.GetObjectKind().GroupVersionKind()) + + // Once TypeMeta is stripped the decoded object is identical to the input. + decoded.GetObjectKind().SetGroupVersionKind(schema.GroupVersionKind{}) + assert.Equal(t, original, decoded) + }) + } +} + +// TestCodecsUniversalDecoderIntoTypedObject asserts decoding into a caller supplied, +// already typed object works through the versioning decoder. +func TestCodecsUniversalDecoderIntoTypedObject(t *testing.T) { + original := newNVIDIADriver() + + data, err := runtime.Encode(Codecs.LegacyCodec(nvidiav1alpha1.SchemeGroupVersion), original) + require.NoError(t, err) + + into := &nvidiav1alpha1.NVIDIADriver{} + decoder := Codecs.UniversalDecoder(nvidiav1alpha1.SchemeGroupVersion) + decoded, gvk, err := decoder.Decode(data, nil, into) + require.NoError(t, err) + require.NotNil(t, gvk) + assert.Equal(t, nvidiav1alpha1.SchemeGroupVersion.WithKind("NVIDIADriver"), *gvk) + assert.Same(t, into, decoded) + assert.Equal(t, original.Spec, into.Spec) + assert.Equal(t, original.Status, into.Status) + assert.Equal(t, original.ObjectMeta, into.ObjectMeta) +} + +// TestCodecsDecodeUnknownKind asserts the serializer refuses payloads whose apiVersion/kind +// are not part of this clientset's scheme. +func TestCodecsDecodeUnknownKind(t *testing.T) { + tests := []struct { + name string + data string + }{ + { + name: "unknown kind in a known group version", + data: `{"apiVersion":"nvidia.com/v1","kind":"NotAThing","metadata":{"name":"x"}}`, + }, + { + name: "known kind in an unknown group", + data: `{"apiVersion":"example.com/v1","kind":"ClusterPolicy","metadata":{"name":"x"}}`, + }, + { + name: "kind registered only in another version", + data: `{"apiVersion":"nvidia.com/v1","kind":"NVIDIADriver","metadata":{"name":"x"}}`, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + _, _, err := Codecs.UniversalDeserializer().Decode([]byte(tt.data), nil, nil) + require.Error(t, err) + assert.True(t, runtime.IsNotRegisteredError(err), "expected a not-registered error, got %v", err) + }) + } +} + +// TestCodecsDecodeMissingKind asserts payloads without apiVersion/kind cannot be decoded +// without a default GVK. +func TestCodecsDecodeMissingKind(t *testing.T) { + _, _, err := Codecs.UniversalDeserializer().Decode([]byte(`{"metadata":{"name":"x"}}`), nil, nil) + require.Error(t, err) + assert.True(t, runtime.IsMissingKind(err), "expected a missing-kind error, got %v", err) +} + +// TestParameterCodecEncodeParameters asserts ListOptions are converted to the query string +// form the generated clients rely on. +func TestParameterCodecEncodeParameters(t *testing.T) { + timeout := int64(42) + opts := &metav1.ListOptions{ + LabelSelector: "app=gpu-operator", + FieldSelector: "metadata.name=cluster-policy", + ResourceVersion: "1234", + TimeoutSeconds: &timeout, + Watch: true, + Limit: 10, + Continue: "token", + } + + values, err := ParameterCodec.EncodeParameters(opts, metaV1GV) + require.NoError(t, err) + + expected := url.Values{ + "labelSelector": []string{"app=gpu-operator"}, + "fieldSelector": []string{"metadata.name=cluster-policy"}, + "resourceVersion": []string{"1234"}, + "timeoutSeconds": []string{"42"}, + "watch": []string{"true"}, + "limit": []string{"10"}, + "continue": []string{"token"}, + } + assert.Equal(t, expected, values) +} + +// TestParameterCodecEncodeParametersOmitsEmpty asserts that unset optional fields are not +// emitted as empty query parameters. +func TestParameterCodecEncodeParametersOmitsEmpty(t *testing.T) { + values, err := ParameterCodec.EncodeParameters(&metav1.ListOptions{}, metaV1GV) + require.NoError(t, err) + assert.Empty(t, values) + + values, err = ParameterCodec.EncodeParameters(&metav1.GetOptions{ResourceVersion: "0"}, metaV1GV) + require.NoError(t, err) + assert.Equal(t, url.Values{"resourceVersion": []string{"0"}}, values) +} + +// TestParameterCodecRoundTrip asserts EncodeParameters/DecodeParameters are inverses. +func TestParameterCodecRoundTrip(t *testing.T) { + timeout := int64(7) + original := &metav1.ListOptions{ + LabelSelector: "nvidia.com/gpu.present=true", + FieldSelector: "status.phase=Running", + ResourceVersion: "99", + TimeoutSeconds: &timeout, + Watch: true, + } + + values, err := ParameterCodec.EncodeParameters(original, metaV1GV) + require.NoError(t, err) + + decoded := &metav1.ListOptions{} + require.NoError(t, ParameterCodec.DecodeParameters(values, metaV1GV, decoded)) + + assert.Equal(t, original.LabelSelector, decoded.LabelSelector) + assert.Equal(t, original.FieldSelector, decoded.FieldSelector) + assert.Equal(t, original.ResourceVersion, decoded.ResourceVersion) + assert.Equal(t, original.Watch, decoded.Watch) + require.NotNil(t, decoded.TimeoutSeconds) + assert.Equal(t, timeout, *decoded.TimeoutSeconds) +} + +// TestParameterCodecUnregisteredType asserts the ParameterCodec is backed by this package's +// Scheme and therefore rejects types it does not know. +func TestParameterCodecUnregisteredType(t *testing.T) { + _, err := ParameterCodec.EncodeParameters(&unregisteredOptions{}, metaV1GV) + require.Error(t, err) + assert.True(t, runtime.IsNotRegisteredError(err), "expected a not-registered error, got %v", err) +} + +type unregisteredOptions struct { + metav1.TypeMeta +} + +func (o *unregisteredOptions) DeepCopyObject() runtime.Object { + out := *o + return &out +} + +// TestAddToSchemeComposition covers the documented composition use case: adding this +// clientset's types into a scheme owned by somebody else. +func TestAddToSchemeComposition(t *testing.T) { + target := runtime.NewScheme() + + // The fresh scheme knows nothing beforehand. + require.False(t, target.Recognizes(nvidiav1.SchemeGroupVersion.WithKind("ClusterPolicy"))) + require.False(t, target.Recognizes(nvidiav1alpha1.SchemeGroupVersion.WithKind("NVIDIADriver"))) + + require.NoError(t, AddToScheme(target)) + + for _, gvk := range []schema.GroupVersionKind{ + nvidiav1.SchemeGroupVersion.WithKind("ClusterPolicy"), + nvidiav1.SchemeGroupVersion.WithKind("ClusterPolicyList"), + nvidiav1alpha1.SchemeGroupVersion.WithKind("NVIDIADriver"), + nvidiav1alpha1.SchemeGroupVersion.WithKind("NVIDIADriverList"), + nvidiav1alpha1.SchemeGroupVersion.WithKind("GPUCluster"), + nvidiav1alpha1.SchemeGroupVersion.WithKind("GPUClusterList"), + } { + assert.True(t, target.Recognizes(gvk), "expected %s to be registered", gvk) + } + + // The composed scheme is usable for encoding, not just lookups. + cp := newClusterPolicy() + gvks, _, err := target.ObjectKinds(cp) + require.NoError(t, err) + assert.Equal(t, []schema.GroupVersionKind{nvidiav1.SchemeGroupVersion.WithKind("ClusterPolicy")}, gvks) +} + +// TestAddToSchemeIsIdempotent asserts repeated registration into the same scheme neither +// errors nor panics, since generated clientsets are frequently composed more than once. +func TestAddToSchemeIsIdempotent(t *testing.T) { + target := runtime.NewScheme() + + require.NoError(t, AddToScheme(target)) + before := len(target.AllKnownTypes()) + + assert.NotPanics(t, func() { + require.NoError(t, AddToScheme(target)) + require.NoError(t, AddToScheme(target)) + }) + + assert.Equal(t, before, len(target.AllKnownTypes()), "re-registration must not add new kinds") + + // Registering again into the package level Scheme is also safe. + assert.NotPanics(t, func() { + require.NoError(t, AddToScheme(Scheme)) + }) +} + +// TestLocalSchemeBuilderContents asserts the generated builder wires up exactly the two +// NVIDIA API groups that make up this clientset. +func TestLocalSchemeBuilderContents(t *testing.T) { + // Asserted through the public AddToScheme result rather than by indexing + // localSchemeBuilder: the order in which client-gen emits the registration + // functions, and how many there are, is not a contract. Adding another API + // group or reordering the generated slice should only fail this test if the + // resulting scheme is wrong. + target := runtime.NewScheme() + require.NoError(t, AddToScheme(target)) + + for _, gvk := range []schema.GroupVersionKind{ + nvidiav1.SchemeGroupVersion.WithKind("ClusterPolicy"), + nvidiav1.SchemeGroupVersion.WithKind("ClusterPolicyList"), + nvidiav1alpha1.SchemeGroupVersion.WithKind("NVIDIADriver"), + nvidiav1alpha1.SchemeGroupVersion.WithKind("NVIDIADriverList"), + nvidiav1alpha1.SchemeGroupVersion.WithKind(nvidiav1alpha1.GPUClusterCRDName), + nvidiav1alpha1.SchemeGroupVersion.WithKind("GPUClusterList"), + } { + assert.True(t, target.Recognizes(gvk), "AddToScheme must register %s", gvk) + } +} + +// TestSchemeObservedGroupVersions asserts both NVIDIA group versions, plus the meta "v1" +// group version added by init(), are part of the scheme's known versions. +func TestSchemeObservedGroupVersions(t *testing.T) { + gvs := Scheme.PrioritizedVersionsAllGroups() + + assert.Contains(t, gvs, nvidiav1.SchemeGroupVersion) + assert.Contains(t, gvs, nvidiav1alpha1.SchemeGroupVersion) + assert.Contains(t, gvs, metaV1GV) + + assert.Equal(t, []string{nvidiav1.SchemeGroupVersion.Version}, versionsForGroup(gvs, "nvidia.com", "v1")) + assert.Equal(t, []string{nvidiav1alpha1.SchemeGroupVersion.Version}, versionsForGroup(gvs, "nvidia.com", "v1alpha1")) +} + +// versionsForGroup returns the versions observed for group that match want. +func versionsForGroup(gvs []schema.GroupVersion, group, want string) []string { + var out []string + for _, gv := range gvs { + if gv.Group == group && gv.Version == want { + out = append(out, gv.Version) + } + } + return out +} + +// TestCodecsIsBackedByScheme asserts Codecs is wired to this package's Scheme by driving a +// full encode/decode through the top level runtime helpers the generated clients use. +func TestCodecsIsBackedByScheme(t *testing.T) { + data, err := runtime.Encode(Codecs.LegacyCodec(nvidiav1.SchemeGroupVersion), newClusterPolicy()) + require.NoError(t, err) + + obj, err := runtime.Decode(Codecs.UniversalDeserializer(), data) + require.NoError(t, err) + + cp, ok := obj.(*nvidiav1.ClusterPolicy) + require.True(t, ok) + assert.Equal(t, "cluster-policy", cp.Name) + assert.Equal(t, "nvidia", cp.Spec.Operator.RuntimeClass) + require.NotNil(t, cp.Spec.Driver.Enabled) + assert.True(t, *cp.Spec.Driver.Enabled) +} diff --git a/api/versioned/typed/nvidia/v1/clusterpolicy_test.go b/api/versioned/typed/nvidia/v1/clusterpolicy_test.go new file mode 100644 index 0000000000..9597a2647d --- /dev/null +++ b/api/versioned/typed/nvidia/v1/clusterpolicy_test.go @@ -0,0 +1,572 @@ +/** +# Copyright (c) NVIDIA CORPORATION. All rights reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +**/ + +package v1 + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/http/httptest" + "net/url" + "strings" + "sync" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + apierrors "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + types "k8s.io/apimachinery/pkg/types" + watch "k8s.io/apimachinery/pkg/watch" + rest "k8s.io/client-go/rest" + + nvidiav1 "github.com/NVIDIA/gpu-operator/api/nvidia/v1" +) + +// eventTimeout bounds every blocking operation in this file. The test server is +// in-process, so anything that has not completed within a couple of seconds is a +// regression rather than a slow machine. +const eventTimeout = 2 * time.Second + +// receiveEvent reads a single event from ch, failing the test rather than +// blocking forever if the channel stays empty or is closed. +func receiveEvent(t *testing.T, ch <-chan watch.Event) watch.Event { + t.Helper() + timer := time.NewTimer(eventTimeout) + defer timer.Stop() + select { + case event, ok := <-ch: + require.True(t, ok, "watch channel closed unexpectedly") + return event + case <-timer.C: + t.Fatal("timed out waiting for watch event") + return watch.Event{} + } +} + +// collectionPath is the cluster-scoped collection path derived from the real +// SchemeGroupVersion, e.g. /apis/nvidia.com/v1/clusterpolicies. +func collectionPath() string { + gv := nvidiav1.SchemeGroupVersion + return fmt.Sprintf("/apis/%s/%s/clusterpolicies", gv.Group, gv.Version) +} + +func namedPath(name string) string { + return collectionPath() + "/" + name +} + +// capturedRequest is a snapshot of a request as it arrived at the test server. +type capturedRequest struct { + method string + path string + query url.Values + contentType string + accept string + userAgent string + body []byte +} + +// recorder is an http.Handler that records every request it serves and then +// delegates to the test supplied handler. +type recorder struct { + mu sync.Mutex + requests []capturedRequest + handler http.HandlerFunc +} + +func (rec *recorder) ServeHTTP(w http.ResponseWriter, r *http.Request) { + body, _ := io.ReadAll(r.Body) + // Hand the handler an intact body: the recorder has already drained it. + r.Body = io.NopCloser(bytes.NewReader(body)) + + rec.mu.Lock() + rec.requests = append(rec.requests, capturedRequest{ + method: r.Method, + path: r.URL.Path, + query: r.URL.Query(), + contentType: r.Header.Get("Content-Type"), + accept: r.Header.Get("Accept"), + userAgent: r.Header.Get("User-Agent"), + body: body, + }) + rec.mu.Unlock() + + rec.handler(w, r) +} + +// only returns the single request the server is expected to have received. +func (rec *recorder) only(t *testing.T) capturedRequest { + t.Helper() + rec.mu.Lock() + defer rec.mu.Unlock() + require.Len(t, rec.requests, 1, "expected exactly one request to the test server") + return rec.requests[0] +} + +// newTestClient stands up a real HTTP server and points a generated client at it. +func newTestClient(t *testing.T, handler http.HandlerFunc) (ClusterPolicyInterface, *recorder) { + t.Helper() + + rec := &recorder{handler: handler} + srv := httptest.NewServer(rec) + t.Cleanup(srv.Close) + + client, err := NewForConfig(&rest.Config{Host: srv.URL}) + require.NoError(t, err) + + return client.ClusterPolicies(), rec +} + +// writeJSON marshals obj as the response body, as the API server would. +// +// It deliberately takes no *testing.T and asserts nothing: it runs on the +// httptest server's goroutine, and testify's require calls t.FailNow, which +// the testing package only permits from the goroutine running the test. A +// marshal failure is surfaced to the client as a 500 so the assertion fails in +// the test goroutine instead. +func writeJSON(w http.ResponseWriter, code int, obj any) { + raw, err := json.Marshal(obj) + if err != nil { + http.Error(w, "test handler: marshal failed: "+err.Error(), http.StatusInternalServerError) + return + } + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(code) + _, _ = w.Write(raw) +} + +func newClusterPolicy(name string) *nvidiav1.ClusterPolicy { + cp := &nvidiav1.ClusterPolicy{ + ObjectMeta: metav1.ObjectMeta{ + Name: name, + Labels: map[string]string{"app": "gpu-operator"}, + }, + } + cp.APIVersion = nvidiav1.SchemeGroupVersion.String() + cp.Kind = "ClusterPolicy" + cp.Spec.Operator.RuntimeClass = "nvidia" + cp.Status.State = nvidiav1.Ready + return cp +} + +func newClusterPolicyList(names ...string) *nvidiav1.ClusterPolicyList { + list := &nvidiav1.ClusterPolicyList{ + ListMeta: metav1.ListMeta{ResourceVersion: "4242"}, + } + list.APIVersion = nvidiav1.SchemeGroupVersion.String() + list.Kind = "ClusterPolicyList" + for _, name := range names { + list.Items = append(list.Items, *newClusterPolicy(name)) + } + return list +} + +// assertClusterScoped guards the empty-namespace wiring in newClusterPolicies. +func assertClusterScoped(t *testing.T, path string) { + t.Helper() + assert.NotContains(t, path, "/namespaces/", "ClusterPolicy is cluster scoped") + assert.True(t, strings.HasPrefix(path, collectionPath()), "unexpected path %q", path) +} + +func TestClusterPoliciesGet(t *testing.T) { + t.Run("decodes the returned object", func(t *testing.T) { + want := newClusterPolicy("cluster-policy") + client, rec := newTestClient(t, func(w http.ResponseWriter, _ *http.Request) { + writeJSON(w, http.StatusOK, want) + }) + + ctx, cancel := context.WithTimeout(t.Context(), eventTimeout) + defer cancel() + + got, err := client.Get(ctx, "cluster-policy", metav1.GetOptions{ResourceVersion: "0"}) + require.NoError(t, err) + require.NotNil(t, got) + assert.Equal(t, "cluster-policy", got.Name) + assert.Equal(t, "nvidia", got.Spec.Operator.RuntimeClass) + assert.Equal(t, nvidiav1.Ready, got.Status.State) + + req := rec.only(t) + assert.Equal(t, http.MethodGet, req.method) + assert.Equal(t, namedPath("cluster-policy"), req.path) + assertClusterScoped(t, req.path) + assert.Equal(t, "0", req.query.Get("resourceVersion")) + assert.NotEmpty(t, req.userAgent) + assert.Contains(t, req.accept, "application/json", "negotiated serializer must ask for JSON") + }) + + t.Run("maps a 404 status body to IsNotFound", func(t *testing.T) { + client, rec := newTestClient(t, func(w http.ResponseWriter, _ *http.Request) { + writeJSON(w, http.StatusNotFound, &metav1.Status{ + TypeMeta: metav1.TypeMeta{APIVersion: "v1", Kind: "Status"}, + Status: metav1.StatusFailure, + Code: http.StatusNotFound, + Reason: metav1.StatusReasonNotFound, + Message: `clusterpolicies.nvidia.com "missing" not found`, + Details: &metav1.StatusDetails{ + Group: nvidiav1.SchemeGroupVersion.Group, + Kind: "clusterpolicies", + Name: "missing", + }, + }) + }) + + ctx, cancel := context.WithTimeout(t.Context(), eventTimeout) + defer cancel() + + got, err := client.Get(ctx, "missing", metav1.GetOptions{}) + require.Error(t, err) + assert.True(t, apierrors.IsNotFound(err), "expected NotFound, got %v", err) + // The generated client returns a non-nil zero value alongside the error. + require.NotNil(t, got) + assert.Empty(t, got.Name) + + assert.Equal(t, namedPath("missing"), rec.only(t).path) + }) +} + +func TestClusterPoliciesList(t *testing.T) { + tests := map[string]struct { + opts metav1.ListOptions + wantQuery map[string]string + }{ + "no options": { + opts: metav1.ListOptions{}, + wantQuery: map[string]string{}, + }, + "selectors, resourceVersion and limit": { + opts: metav1.ListOptions{ + LabelSelector: "app=gpu-operator", + FieldSelector: "metadata.name=cluster-policy", + ResourceVersion: "1234", + Limit: 50, + }, + wantQuery: map[string]string{ + "labelSelector": "app=gpu-operator", + "fieldSelector": "metadata.name=cluster-policy", + "resourceVersion": "1234", + "limit": "50", + }, + }, + "timeout is propagated": { + opts: metav1.ListOptions{TimeoutSeconds: ptr(int64(7))}, + wantQuery: map[string]string{ + "timeoutSeconds": "7", + "timeout": "7s", + }, + }, + } + + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + want := newClusterPolicyList("policy-a", "policy-b") + client, rec := newTestClient(t, func(w http.ResponseWriter, _ *http.Request) { + writeJSON(w, http.StatusOK, want) + }) + + ctx, cancel := context.WithTimeout(t.Context(), eventTimeout) + defer cancel() + + got, err := client.List(ctx, tc.opts) + require.NoError(t, err) + require.NotNil(t, got) + require.Len(t, got.Items, 2) + assert.Equal(t, "policy-a", got.Items[0].Name) + assert.Equal(t, "policy-b", got.Items[1].Name) + assert.Equal(t, "4242", got.ResourceVersion) + + req := rec.only(t) + assert.Equal(t, http.MethodGet, req.method) + assert.Equal(t, collectionPath(), req.path) + assertClusterScoped(t, req.path) + for k, v := range tc.wantQuery { + assert.Equal(t, v, req.query.Get(k), "query param %q", k) + } + if len(tc.wantQuery) == 0 { + assert.Empty(t, req.query, "expected no query params") + } + }) + } +} + +func TestClusterPoliciesCreate(t *testing.T) { + send := newClusterPolicy("new-policy") + + // The response is built here, on the test goroutine, rather than by + // echoing the request inside the handler. The request body is asserted + // below from the recorder, so the handler needs no test state at all. + created := send.DeepCopy() + created.ResourceVersion = "1" + + client, rec := newTestClient(t, func(w http.ResponseWriter, _ *http.Request) { + writeJSON(w, http.StatusCreated, created) + }) + + ctx, cancel := context.WithTimeout(t.Context(), eventTimeout) + defer cancel() + + got, err := client.Create(ctx, send, metav1.CreateOptions{FieldManager: "gpu-operator"}) + require.NoError(t, err) + require.NotNil(t, got) + assert.Equal(t, "new-policy", got.Name) + assert.Equal(t, "1", got.ResourceVersion) + + req := rec.only(t) + assert.Equal(t, http.MethodPost, req.method) + assert.Equal(t, collectionPath(), req.path) + assertClusterScoped(t, req.path) + assert.Equal(t, "gpu-operator", req.query.Get("fieldManager")) + assert.Equal(t, "application/json", req.contentType) + + // The object must round-trip through the request body. + var sent nvidiav1.ClusterPolicy + require.NoError(t, json.Unmarshal(req.body, &sent)) + assert.Equal(t, "new-policy", sent.Name) + assert.Equal(t, map[string]string{"app": "gpu-operator"}, sent.Labels) + assert.Equal(t, "nvidia", sent.Spec.Operator.RuntimeClass) +} + +func TestClusterPoliciesUpdate(t *testing.T) { + tests := map[string]struct { + call func(context.Context, ClusterPolicyInterface, *nvidiav1.ClusterPolicy) (*nvidiav1.ClusterPolicy, error) + wantPath string + }{ + "Update": { + call: func(ctx context.Context, c ClusterPolicyInterface, cp *nvidiav1.ClusterPolicy) (*nvidiav1.ClusterPolicy, error) { + return c.Update(ctx, cp, metav1.UpdateOptions{FieldManager: "gpu-operator"}) + }, + wantPath: namedPath("cluster-policy"), + }, + "UpdateStatus": { + call: func(ctx context.Context, c ClusterPolicyInterface, cp *nvidiav1.ClusterPolicy) (*nvidiav1.ClusterPolicy, error) { + return c.UpdateStatus(ctx, cp, metav1.UpdateOptions{FieldManager: "gpu-operator"}) + }, + wantPath: namedPath("cluster-policy") + "/status", + }, + } + + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + send := newClusterPolicy("cluster-policy") + send.ResourceVersion = "9" + client, rec := newTestClient(t, func(w http.ResponseWriter, _ *http.Request) { + writeJSON(w, http.StatusOK, send) + }) + + ctx, cancel := context.WithTimeout(t.Context(), eventTimeout) + defer cancel() + + got, err := tc.call(ctx, client, send) + require.NoError(t, err) + require.NotNil(t, got) + assert.Equal(t, "cluster-policy", got.Name) + assert.Equal(t, "9", got.ResourceVersion) + + req := rec.only(t) + assert.Equal(t, http.MethodPut, req.method) + assert.Equal(t, tc.wantPath, req.path) + assertClusterScoped(t, req.path) + assert.Equal(t, "gpu-operator", req.query.Get("fieldManager")) + + var sent nvidiav1.ClusterPolicy + require.NoError(t, json.Unmarshal(req.body, &sent)) + assert.Equal(t, "cluster-policy", sent.Name) + assert.Equal(t, nvidiav1.Ready, sent.Status.State) + }) + } +} + +func TestClusterPoliciesDelete(t *testing.T) { + client, rec := newTestClient(t, func(w http.ResponseWriter, _ *http.Request) { + writeJSON(w, http.StatusOK, &metav1.Status{ + TypeMeta: metav1.TypeMeta{APIVersion: "v1", Kind: "Status"}, + Status: metav1.StatusSuccess, + }) + }) + + ctx, cancel := context.WithTimeout(t.Context(), eventTimeout) + defer cancel() + + policy := metav1.DeletePropagationForeground + require.NoError(t, client.Delete(ctx, "cluster-policy", metav1.DeleteOptions{ + PropagationPolicy: &policy, + })) + + req := rec.only(t) + assert.Equal(t, http.MethodDelete, req.method) + assert.Equal(t, namedPath("cluster-policy"), req.path) + assertClusterScoped(t, req.path) + + var opts metav1.DeleteOptions + require.NoError(t, json.Unmarshal(req.body, &opts)) + require.NotNil(t, opts.PropagationPolicy) + assert.Equal(t, metav1.DeletePropagationForeground, *opts.PropagationPolicy) +} + +func TestClusterPoliciesDeleteCollection(t *testing.T) { + client, rec := newTestClient(t, func(w http.ResponseWriter, _ *http.Request) { + writeJSON(w, http.StatusOK, &metav1.Status{ + TypeMeta: metav1.TypeMeta{APIVersion: "v1", Kind: "Status"}, + Status: metav1.StatusSuccess, + }) + }) + + ctx, cancel := context.WithTimeout(t.Context(), eventTimeout) + defer cancel() + + grace := int64(30) + require.NoError(t, client.DeleteCollection(ctx, + metav1.DeleteOptions{GracePeriodSeconds: &grace}, + metav1.ListOptions{LabelSelector: "app=gpu-operator", Limit: 10}, + )) + + req := rec.only(t) + assert.Equal(t, http.MethodDelete, req.method) + assert.Equal(t, collectionPath(), req.path, "DeleteCollection must target the collection, not a named resource") + assertClusterScoped(t, req.path) + assert.Equal(t, "app=gpu-operator", req.query.Get("labelSelector")) + assert.Equal(t, "10", req.query.Get("limit")) + + var opts metav1.DeleteOptions + require.NoError(t, json.Unmarshal(req.body, &opts)) + require.NotNil(t, opts.GracePeriodSeconds) + assert.Equal(t, int64(30), *opts.GracePeriodSeconds) +} + +func TestClusterPoliciesPatch(t *testing.T) { + tests := map[string]struct { + patchType types.PatchType + subresources []string + wantPath string + wantContentType string + }{ + "merge patch": { + patchType: types.MergePatchType, + wantPath: namedPath("cluster-policy"), + wantContentType: "application/merge-patch+json", + }, + "json patch": { + patchType: types.JSONPatchType, + wantPath: namedPath("cluster-policy"), + wantContentType: "application/json-patch+json", + }, + "strategic merge patch on the status subresource": { + patchType: types.StrategicMergePatchType, + subresources: []string{"status"}, + wantPath: namedPath("cluster-policy") + "/status", + wantContentType: "application/strategic-merge-patch+json", + }, + } + + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + patch := []byte(`{"spec":{"operator":{"runtimeClass":"nvidia-crio"}}}`) + client, rec := newTestClient(t, func(w http.ResponseWriter, _ *http.Request) { + patched := newClusterPolicy("cluster-policy") + patched.Spec.Operator.RuntimeClass = "nvidia-crio" + writeJSON(w, http.StatusOK, patched) + }) + + ctx, cancel := context.WithTimeout(t.Context(), eventTimeout) + defer cancel() + + got, err := client.Patch(ctx, "cluster-policy", tc.patchType, patch, + metav1.PatchOptions{FieldManager: "gpu-operator"}, tc.subresources...) + require.NoError(t, err) + require.NotNil(t, got) + assert.Equal(t, "nvidia-crio", got.Spec.Operator.RuntimeClass) + + req := rec.only(t) + assert.Equal(t, http.MethodPatch, req.method) + assert.Equal(t, tc.wantPath, req.path) + assertClusterScoped(t, req.path) + assert.Equal(t, tc.wantContentType, req.contentType) + assert.Equal(t, patch, req.body) + assert.Equal(t, "gpu-operator", req.query.Get("fieldManager")) + }) + } +} + +func TestClusterPoliciesWatch(t *testing.T) { + want := newClusterPolicy("watched-policy") + + // Marshal the frame on the test goroutine so the handler carries no + // assertions; require may not be called from the server's goroutine. + raw, err := json.Marshal(want) + require.NoError(t, err) + event, err := json.Marshal(map[string]any{ + "type": string(watch.Added), + "object": json.RawMessage(raw), + }) + require.NoError(t, err) + + client, rec := newTestClient(t, func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + _, _ = w.Write(event) + w.(http.Flusher).Flush() + // Returning closes the response body, which terminates the watch. + }) + + ctx, cancel := context.WithTimeout(t.Context(), eventTimeout) + defer cancel() + + watcher, err := client.Watch(ctx, metav1.ListOptions{ + LabelSelector: "app=gpu-operator", + ResourceVersion: "77", + }) + require.NoError(t, err) + defer watcher.Stop() + + got := receiveEvent(t, watcher.ResultChan()) + assert.Equal(t, watch.Added, got.Type) + cp, ok := got.Object.(*nvidiav1.ClusterPolicy) + require.True(t, ok, "unexpected watch object type %T", got.Object) + assert.Equal(t, "watched-policy", cp.Name) + + req := rec.only(t) + assert.Equal(t, http.MethodGet, req.method) + assert.Equal(t, collectionPath(), req.path) + assertClusterScoped(t, req.path) + assert.Equal(t, "true", req.query.Get("watch")) + assert.Equal(t, "app=gpu-operator", req.query.Get("labelSelector")) + assert.Equal(t, "77", req.query.Get("resourceVersion")) +} + +func TestClusterPoliciesServerError(t *testing.T) { + client, _ := newTestClient(t, func(w http.ResponseWriter, _ *http.Request) { + writeJSON(w, http.StatusInternalServerError, &metav1.Status{ + TypeMeta: metav1.TypeMeta{APIVersion: "v1", Kind: "Status"}, + Status: metav1.StatusFailure, + Code: http.StatusInternalServerError, + Reason: metav1.StatusReasonInternalError, + Message: "boom", + }) + }) + + ctx, cancel := context.WithTimeout(t.Context(), eventTimeout) + defer cancel() + + _, err := client.List(ctx, metav1.ListOptions{}) + require.Error(t, err) + assert.True(t, apierrors.IsInternalError(err), "expected InternalError, got %v", err) +} + +func ptr[T any](v T) *T { return &v } diff --git a/api/versioned/typed/nvidia/v1/fake/fake_clusterpolicy_test.go b/api/versioned/typed/nvidia/v1/fake/fake_clusterpolicy_test.go new file mode 100644 index 0000000000..691b5daa8e --- /dev/null +++ b/api/versioned/typed/nvidia/v1/fake/fake_clusterpolicy_test.go @@ -0,0 +1,765 @@ +/** +# Copyright (c) NVIDIA CORPORATION. All rights reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +**/ + +package fake + +import ( + "context" + "errors" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + apierrors "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/runtime/schema" + "k8s.io/apimachinery/pkg/types" + "k8s.io/apimachinery/pkg/watch" + k8stesting "k8s.io/client-go/testing" + + v1 "github.com/NVIDIA/gpu-operator/api/nvidia/v1" + clientsetscheme "github.com/NVIDIA/gpu-operator/api/versioned/scheme" + nvidiav1 "github.com/NVIDIA/gpu-operator/api/versioned/typed/nvidia/v1" +) + +// fakeClusterPolicies must implement the generated typed interface. +var _ nvidiav1.ClusterPolicyInterface = &fakeClusterPolicies{} + +// eventTimeout bounds every channel read so a broken watch fails the test +// instead of hanging the suite. Everything here is in-process, so a generous +// timeout would only make a regression fail slowly. +const eventTimeout = 2 * time.Second + +func clusterPolicyGVR() schema.GroupVersionResource { + return v1.SchemeGroupVersion.WithResource("clusterpolicies") +} + +func clusterPolicyGVK() schema.GroupVersionKind { + return v1.SchemeGroupVersion.WithKind("ClusterPolicy") +} + +// fixture wires a bare testing.Fake to an ObjectTracker exactly the way the +// generated top-level fake clientset does, without importing it (that would +// create an import cycle). +type fixture struct { + fake *k8stesting.Fake + tracker k8stesting.ObjectTracker + group *FakeNvidiaV1 + client nvidiav1.ClusterPolicyInterface +} + +func newFixture(t *testing.T, objects ...runtime.Object) *fixture { + t.Helper() + + tracker := k8stesting.NewObjectTracker(clientsetscheme.Scheme, clientsetscheme.Codecs.UniversalDecoder()) + for _, obj := range objects { + require.NoError(t, tracker.Add(obj)) + } + + f := &k8stesting.Fake{} + f.AddReactor("*", "*", k8stesting.ObjectReaction(tracker)) + f.AddWatchReactor("*", func(action k8stesting.Action) (bool, watch.Interface, error) { + var opts metav1.ListOptions + if watchAction, ok := action.(k8stesting.WatchActionImpl); ok { + opts = watchAction.ListOptions + } + w, err := tracker.Watch(action.GetResource(), action.GetNamespace(), opts) + if err != nil { + return false, nil, err + } + return true, w, nil + }) + + group := &FakeNvidiaV1{Fake: f} + return &fixture{ + fake: f, + tracker: tracker, + group: group, + client: newFakeClusterPolicies(group), + } +} + +func newClusterPolicy(name string, labels map[string]string) *v1.ClusterPolicy { + return &v1.ClusterPolicy{ + ObjectMeta: metav1.ObjectMeta{ + Name: name, + Labels: labels, + }, + Spec: v1.ClusterPolicySpec{ + Operator: v1.OperatorSpec{ + RuntimeClass: "nvidia", + }, + }, + } +} + +func policyNames(list *v1.ClusterPolicyList) []string { + names := make([]string, 0, len(list.Items)) + for i := range list.Items { + names = append(names, list.Items[i].Name) + } + return names +} + +// TestNewFakeClusterPoliciesWiring asserts the GVR/GVK/namespace baked into the +// generated constructor, comparing against SchemeGroupVersion rather than +// hardcoded group strings. +func TestNewFakeClusterPoliciesWiring(t *testing.T) { + group := &FakeNvidiaV1{Fake: &k8stesting.Fake{}} + + client := newFakeClusterPolicies(group) + require.NotNil(t, client) + + impl, ok := client.(*fakeClusterPolicies) + require.True(t, ok) + + assert.Equal(t, clusterPolicyGVR(), impl.Resource()) + assert.Equal(t, "nvidia.com", impl.Resource().Group) + assert.Equal(t, "v1", impl.Resource().Version) + assert.Equal(t, "clusterpolicies", impl.Resource().Resource) + + assert.Equal(t, clusterPolicyGVK(), impl.Kind()) + assert.Equal(t, "ClusterPolicy", impl.Kind().Kind) + + // ClusterPolicy is a cluster-scoped resource: the generated constructor + // passes "" as the namespace. + assert.Empty(t, impl.Namespace()) +} + +func TestCreateAndGet(t *testing.T) { + f := newFixture(t) + ctx := t.Context() + + created, err := f.client.Create(ctx, newClusterPolicy("gpu-cluster-policy", nil), metav1.CreateOptions{}) + require.NoError(t, err) + require.NotNil(t, created) + assert.Equal(t, "gpu-cluster-policy", created.Name) + assert.Equal(t, "nvidia", created.Spec.Operator.RuntimeClass) + + got, err := f.client.Get(ctx, "gpu-cluster-policy", metav1.GetOptions{}) + require.NoError(t, err) + assert.Equal(t, created, got) + + // The tracker really holds it. + tracked, err := f.tracker.Get(clusterPolicyGVR(), "", "gpu-cluster-policy") + require.NoError(t, err) + assert.Equal(t, "gpu-cluster-policy", tracked.(*v1.ClusterPolicy).Name) +} + +func TestCreateDuplicateReturnsAlreadyExists(t *testing.T) { + f := newFixture(t, newClusterPolicy("dup", nil)) + ctx := t.Context() + + _, err := f.client.Create(ctx, newClusterPolicy("dup", nil), metav1.CreateOptions{}) + require.Error(t, err) + assert.True(t, apierrors.IsAlreadyExists(err), "expected AlreadyExists, got %v", err) +} + +func TestGetMissingReturnsNotFound(t *testing.T) { + f := newFixture(t) + ctx := t.Context() + + got, err := f.client.Get(ctx, "does-not-exist", metav1.GetOptions{}) + require.Error(t, err) + assert.True(t, apierrors.IsNotFound(err), "expected NotFound, got %v", err) + // gentype returns the zero object (never a typed-nil surprise) alongside the error. + assert.Equal(t, &v1.ClusterPolicy{}, got) + + // The NotFound status carries the GroupResource wired into the fake client. + var statusErr *apierrors.StatusError + require.True(t, errors.As(err, &statusErr)) + require.NotNil(t, statusErr.ErrStatus.Details) + assert.Equal(t, clusterPolicyGVR().Group, statusErr.ErrStatus.Details.Group) + assert.Equal(t, clusterPolicyGVR().Resource, statusErr.ErrStatus.Details.Kind) + assert.Equal(t, "does-not-exist", statusErr.ErrStatus.Details.Name) +} + +func TestUpdate(t *testing.T) { + f := newFixture(t, newClusterPolicy("cp", map[string]string{"tier": "gold"})) + ctx := t.Context() + + current, err := f.client.Get(ctx, "cp", metav1.GetOptions{}) + require.NoError(t, err) + + current.Spec.Operator.RuntimeClass = "nvidia-crio" + current.Labels["tier"] = "silver" + + updated, err := f.client.Update(ctx, current, metav1.UpdateOptions{}) + require.NoError(t, err) + assert.Equal(t, "nvidia-crio", updated.Spec.Operator.RuntimeClass) + assert.Equal(t, "silver", updated.Labels["tier"]) + + // The mutation is durable in the tracker. + got, err := f.client.Get(ctx, "cp", metav1.GetOptions{}) + require.NoError(t, err) + assert.Equal(t, "nvidia-crio", got.Spec.Operator.RuntimeClass) + assert.Equal(t, "silver", got.Labels["tier"]) +} + +// TestUpdateStatus asserts the generated-client contract for UpdateStatus: the +// call is recorded as an "update" on the "status" subresource of the ClusterPolicy +// GVR, and the status the caller sent is what a subsequent Get returns. +// +// Note: the current testing.ObjectTracker has no spec/status separation, so the +// tracked object is replaced wholesale by an UpdateStatus. That is an upstream +// implementation detail rather than a contract of this generated client, so it is +// deliberately not asserted here. +func TestUpdateStatus(t *testing.T) { + f := newFixture(t, newClusterPolicy("cp", nil)) + ctx := t.Context() + + current, err := f.client.Get(ctx, "cp", metav1.GetOptions{}) + require.NoError(t, err) + require.Empty(t, current.Status.State) + + current.SetStatus(v1.Ready, "gpu-operator") + + updated, err := f.client.UpdateStatus(ctx, current, metav1.UpdateOptions{}) + require.NoError(t, err) + assert.Equal(t, v1.Ready, updated.Status.State) + assert.Equal(t, "gpu-operator", updated.Status.Namespace) + + got, err := f.client.Get(ctx, "cp", metav1.GetOptions{}) + require.NoError(t, err) + assert.Equal(t, v1.Ready, got.Status.State) + + // UpdateStatus is recorded as an update on the "status" subresource. + actions := f.fake.Actions() + last := actions[len(actions)-2] // -1 is the trailing Get + assert.Equal(t, "update", last.GetVerb()) + assert.Equal(t, "status", last.GetSubresource()) + assert.Equal(t, clusterPolicyGVR(), last.GetResource()) +} + +func TestUpdateMissingReturnsNotFound(t *testing.T) { + f := newFixture(t) + ctx := t.Context() + + _, err := f.client.Update(ctx, newClusterPolicy("ghost", nil), metav1.UpdateOptions{}) + require.Error(t, err) + assert.True(t, apierrors.IsNotFound(err), "expected NotFound, got %v", err) +} + +func TestList(t *testing.T) { + f := newFixture(t, + newClusterPolicy("cp-a", map[string]string{"tier": "gold"}), + newClusterPolicy("cp-b", map[string]string{"tier": "silver"}), + newClusterPolicy("cp-c", nil), + ) + ctx := t.Context() + + list, err := f.client.List(ctx, metav1.ListOptions{}) + require.NoError(t, err) + assert.ElementsMatch(t, []string{"cp-a", "cp-b", "cp-c"}, policyNames(list)) + + action := f.fake.Actions()[0] + listAction, ok := action.(k8stesting.ListActionImpl) + require.True(t, ok) + assert.Equal(t, "list", listAction.GetVerb()) + assert.Equal(t, clusterPolicyGVR(), listAction.GetResource()) + assert.Equal(t, clusterPolicyGVK(), listAction.GetKind()) + assert.Empty(t, listAction.GetNamespace()) +} + +func TestListEmpty(t *testing.T) { + f := newFixture(t) + + list, err := f.client.List(t.Context(), metav1.ListOptions{}) + require.NoError(t, err) + require.NotNil(t, list) + assert.Empty(t, list.Items) +} + +// TestListCopiesListMeta exercises the generated +// `func(dst, src *v1.ClusterPolicyList) { dst.ListMeta = src.ListMeta }` hook. +// gentype only builds a brand new list (and therefore only calls copyListMeta) +// when a label selector is supplied, so the ResourceVersion seeded by the +// tracker must survive that rebuild. +func TestListCopiesListMeta(t *testing.T) { + f := newFixture(t, + newClusterPolicy("cp-a", map[string]string{"tier": "gold"}), + newClusterPolicy("cp-b", map[string]string{"tier": "silver"}), + ) + ctx := t.Context() + + // ResourceVersion as seeded by the tracker for this GVR. + raw, err := f.tracker.List(clusterPolicyGVR(), clusterPolicyGVK(), "") + require.NoError(t, err) + seededRV := raw.(*v1.ClusterPolicyList).ResourceVersion + require.NotEmpty(t, seededRV) + + // Unfiltered list: gentype hands the tracker's list straight back. + unfiltered, err := f.client.List(ctx, metav1.ListOptions{}) + require.NoError(t, err) + assert.Equal(t, seededRV, unfiltered.ResourceVersion) + + // Filtered list: gentype allocates a fresh list and calls copyListMeta. + filtered, err := f.client.List(ctx, metav1.ListOptions{LabelSelector: "tier=gold"}) + require.NoError(t, err) + assert.Equal(t, seededRV, filtered.ResourceVersion, + "copyListMeta must carry ListMeta over to the freshly allocated list") + assert.Equal(t, []string{"cp-a"}, policyNames(filtered)) +} + +func TestListLabelSelectorFiltering(t *testing.T) { + f := newFixture(t, + newClusterPolicy("cp-a", map[string]string{"tier": "gold", "env": "prod"}), + newClusterPolicy("cp-b", map[string]string{"tier": "silver", "env": "prod"}), + newClusterPolicy("cp-c", map[string]string{"tier": "gold", "env": "dev"}), + newClusterPolicy("cp-none", nil), + ) + ctx := t.Context() + + for _, tc := range []struct { + name string + selector string + want []string + }{ + {"no selector matches everything", "", []string{"cp-a", "cp-b", "cp-c", "cp-none"}}, + {"single equality", "tier=gold", []string{"cp-a", "cp-c"}}, + {"conjunction", "tier=gold,env=prod", []string{"cp-a"}}, + {"inequality", "tier!=gold", []string{"cp-b", "cp-none"}}, + {"key existence", "tier", []string{"cp-a", "cp-b", "cp-c"}}, + {"set membership", "env in (dev)", []string{"cp-c"}}, + {"no match", "tier=bronze", nil}, + } { + t.Run(tc.name, func(t *testing.T) { + list, err := f.client.List(ctx, metav1.ListOptions{LabelSelector: tc.selector}) + require.NoError(t, err) + assert.ElementsMatch(t, tc.want, policyNames(list)) + + // The selector is recorded on the action for assertions in user tests. + actions := f.fake.Actions() + listAction := actions[len(actions)-1].(k8stesting.ListActionImpl) + assert.Equal(t, tc.selector, listAction.GetListOptions().LabelSelector) + }) + } +} + +func TestDelete(t *testing.T) { + f := newFixture(t, newClusterPolicy("cp", nil)) + ctx := t.Context() + + require.NoError(t, f.client.Delete(ctx, "cp", metav1.DeleteOptions{})) + + _, err := f.client.Get(ctx, "cp", metav1.GetOptions{}) + require.Error(t, err) + assert.True(t, apierrors.IsNotFound(err), "expected NotFound after delete, got %v", err) + + deleteAction, ok := f.fake.Actions()[0].(k8stesting.DeleteActionImpl) + require.True(t, ok) + assert.Equal(t, "delete", deleteAction.GetVerb()) + assert.Equal(t, "cp", deleteAction.GetName()) + assert.Empty(t, deleteAction.GetNamespace()) +} + +func TestDeleteMissingReturnsNotFound(t *testing.T) { + f := newFixture(t) + + err := f.client.Delete(t.Context(), "ghost", metav1.DeleteOptions{}) + require.Error(t, err) + assert.True(t, apierrors.IsNotFound(err), "expected NotFound, got %v", err) +} + +// TestDeleteCollectionRecordsAction asserts the generated-client contract for +// DeleteCollection: the call is recorded as a cluster-scoped "delete-collection" +// action against the ClusterPolicy GVR, and it carries both the DeleteOptions +// and the ListOptions the caller supplied so user reactors can act on them. +// +// Note: today's testing.ObjectReaction has no DeleteCollectionActionImpl case, +// so no reactor handles the action and the tracker is left untouched. That is an +// upstream implementation detail, not a contract of this generated client, so it +// is deliberately not asserted here — see TestDeleteCollectionRemovesAll for the +// behavior once a delete-collection reactor is installed. +func TestDeleteCollectionRecordsAction(t *testing.T) { + f := newFixture(t, newClusterPolicy("cp-a", nil), newClusterPolicy("cp-b", nil)) + + gracePeriod := int64(30) + propagation := metav1.DeletePropagationForeground + deleteOpts := metav1.DeleteOptions{ + GracePeriodSeconds: &gracePeriod, + PropagationPolicy: &propagation, + } + listOpts := metav1.ListOptions{LabelSelector: "tier=gold"} + + require.NoError(t, f.client.DeleteCollection(t.Context(), deleteOpts, listOpts)) + + require.Len(t, f.fake.Actions(), 1) + action, ok := f.fake.Actions()[0].(k8stesting.DeleteCollectionActionImpl) + require.True(t, ok, "expected a DeleteCollectionActionImpl, got %T", f.fake.Actions()[0]) + + assert.Equal(t, "delete-collection", action.GetVerb()) + assert.Equal(t, clusterPolicyGVR(), action.GetResource()) + assert.Empty(t, action.GetSubresource()) + // ClusterPolicy is cluster scoped, so the action carries no namespace. + assert.Empty(t, action.GetNamespace()) + assert.True(t, action.Matches("delete-collection", "clusterpolicies")) + + assert.Equal(t, deleteOpts, action.GetDeleteOptions()) + assert.Equal(t, listOpts, action.GetListOptions()) + assert.Equal(t, "tier=gold", action.GetListRestrictions().Labels.String()) +} + +// TestDeleteCollectionRemovesAll wires the delete-collection verb to the tracker +// (as callers must do themselves) and proves the typed method drives it. +func TestDeleteCollectionRemovesAll(t *testing.T) { + f := newFixture(t, + newClusterPolicy("cp-a", nil), + newClusterPolicy("cp-b", nil), + newClusterPolicy("cp-c", nil), + ) + ctx := t.Context() + + f.fake.PrependReactor("delete-collection", "clusterpolicies", + func(action k8stesting.Action) (bool, runtime.Object, error) { + listObj, err := f.tracker.List(clusterPolicyGVR(), clusterPolicyGVK(), action.GetNamespace()) + if err != nil { + return true, nil, err + } + for i := range listObj.(*v1.ClusterPolicyList).Items { + name := listObj.(*v1.ClusterPolicyList).Items[i].Name + if err := f.tracker.Delete(clusterPolicyGVR(), action.GetNamespace(), name); err != nil { + return true, nil, err + } + } + return true, nil, nil + }) + + require.NoError(t, f.client.DeleteCollection(ctx, metav1.DeleteOptions{}, metav1.ListOptions{})) + + list, err := f.client.List(ctx, metav1.ListOptions{}) + require.NoError(t, err) + assert.Empty(t, list.Items) +} + +func TestPatch(t *testing.T) { + f := newFixture(t, newClusterPolicy("cp", map[string]string{"tier": "gold"})) + ctx := t.Context() + + patch := []byte(`{"metadata":{"labels":{"tier":"platinum","patched":"yes"}},"status":{"state":"ready"}}`) + + patched, err := f.client.Patch(ctx, "cp", types.MergePatchType, patch, metav1.PatchOptions{}) + require.NoError(t, err) + assert.Equal(t, "platinum", patched.Labels["tier"]) + assert.Equal(t, "yes", patched.Labels["patched"]) + assert.Equal(t, v1.Ready, patched.Status.State) + + got, err := f.client.Get(ctx, "cp", metav1.GetOptions{}) + require.NoError(t, err) + assert.Equal(t, "platinum", got.Labels["tier"]) + assert.Equal(t, v1.Ready, got.Status.State) + + patchAction, ok := f.fake.Actions()[0].(k8stesting.PatchActionImpl) + require.True(t, ok) + assert.Equal(t, "patch", patchAction.GetVerb()) + assert.Equal(t, types.MergePatchType, patchAction.GetPatchType()) + assert.Equal(t, patch, patchAction.GetPatch()) + assert.Empty(t, patchAction.GetSubresource()) + assert.Empty(t, patchAction.GetNamespace()) +} + +func TestPatchSubresource(t *testing.T) { + f := newFixture(t, newClusterPolicy("cp", nil)) + ctx := t.Context() + + _, err := f.client.Patch(ctx, "cp", types.MergePatchType, + []byte(`{"status":{"state":"notReady"}}`), metav1.PatchOptions{}, "status") + require.NoError(t, err) + + assert.Equal(t, "status", f.fake.Actions()[0].GetSubresource()) +} + +func TestPatchMissingReturnsNotFound(t *testing.T) { + f := newFixture(t) + + _, err := f.client.Patch(t.Context(), "ghost", types.MergePatchType, + []byte(`{}`), metav1.PatchOptions{}) + require.Error(t, err) + assert.True(t, apierrors.IsNotFound(err), "expected NotFound, got %v", err) +} + +// receiveEvent reads a single watch event with a hard timeout so the test can +// never block indefinitely. Every read that expects an event goes through it. +func receiveEvent(t *testing.T, ch <-chan watch.Event) watch.Event { + t.Helper() + timer := time.NewTimer(eventTimeout) + defer timer.Stop() + select { + case event, ok := <-ch: + require.True(t, ok, "watch channel closed unexpectedly") + return event + case <-timer.C: + t.Fatal("timed out waiting for watch event") + return watch.Event{} + } +} + +func requireEvent(t *testing.T, ch <-chan watch.Event, want watch.EventType, name string) *v1.ClusterPolicy { + t.Helper() + ev := receiveEvent(t, ch) + require.Equal(t, want, ev.Type) + cp, ok := ev.Object.(*v1.ClusterPolicy) + require.True(t, ok, "expected *v1.ClusterPolicy, got %T", ev.Object) + require.Equal(t, name, cp.Name) + return cp +} + +func TestWatchDeliversEvents(t *testing.T) { + f := newFixture(t) + ctx := t.Context() + + w, err := f.client.Watch(ctx, metav1.ListOptions{}) + require.NoError(t, err) + require.NotNil(t, w) + defer w.Stop() + + created, err := f.client.Create(ctx, newClusterPolicy("cp", nil), metav1.CreateOptions{}) + require.NoError(t, err) + requireEvent(t, w.ResultChan(), watch.Added, "cp") + + created.SetStatus(v1.Ready, "gpu-operator") + _, err = f.client.Update(ctx, created, metav1.UpdateOptions{}) + require.NoError(t, err) + modified := requireEvent(t, w.ResultChan(), watch.Modified, "cp") + assert.Equal(t, v1.Ready, modified.Status.State) + + require.NoError(t, f.client.Delete(ctx, "cp", metav1.DeleteOptions{})) + requireEvent(t, w.ResultChan(), watch.Deleted, "cp") + + // The watch action itself is recorded, cluster-scoped, with Watch set. + watchAction, ok := f.fake.Actions()[0].(k8stesting.WatchActionImpl) + require.True(t, ok) + assert.Equal(t, "watch", watchAction.GetVerb()) + assert.Equal(t, clusterPolicyGVR(), watchAction.GetResource()) + assert.Empty(t, watchAction.GetNamespace()) + assert.True(t, watchAction.ListOptions.Watch) +} + +func TestWatchStopClosesChannel(t *testing.T) { + f := newFixture(t) + + w, err := f.client.Watch(t.Context(), metav1.ListOptions{}) + require.NoError(t, err) + + w.Stop() + + // receiveEvent cannot be used here: it asserts an event arrives, whereas this + // test asserts the exact opposite (the channel is closed). The read is still + // bounded by eventTimeout. + timer := time.NewTimer(eventTimeout) + defer timer.Stop() + select { + case _, ok := <-w.ResultChan(): + assert.False(t, ok, "channel must be closed after Stop()") + case <-timer.C: + t.Fatal("timed out waiting for the watch channel to close") + } +} + +func TestWatchWithoutReactorReturnsError(t *testing.T) { + // Only the observable contract is asserted: a Fake with no watch reactor + // yields an error and no watcher. The wording client-go uses for it can + // change on a dependency bump without the behaviour changing. + client := newFakeClusterPolicies(&FakeNvidiaV1{Fake: &k8stesting.Fake{}}) + + w, err := client.Watch(t.Context(), metav1.ListOptions{}) + require.Error(t, err) + assert.Nil(t, w) +} + +// TestRecordedActions walks the whole interface and asserts each recorded +// action carries the expected verb, GVR and cluster scope. +func TestRecordedActions(t *testing.T) { + f := newFixture(t) + ctx := t.Context() + + _, err := f.client.Create(ctx, newClusterPolicy("cp", nil), metav1.CreateOptions{}) + require.NoError(t, err) + _, err = f.client.Get(ctx, "cp", metav1.GetOptions{}) + require.NoError(t, err) + _, err = f.client.List(ctx, metav1.ListOptions{}) + require.NoError(t, err) + cp, err := f.client.Get(ctx, "cp", metav1.GetOptions{}) + require.NoError(t, err) + _, err = f.client.Update(ctx, cp, metav1.UpdateOptions{}) + require.NoError(t, err) + _, err = f.client.UpdateStatus(ctx, cp, metav1.UpdateOptions{}) + require.NoError(t, err) + _, err = f.client.Patch(ctx, "cp", types.MergePatchType, []byte(`{}`), metav1.PatchOptions{}) + require.NoError(t, err) + w, err := f.client.Watch(ctx, metav1.ListOptions{}) + require.NoError(t, err) + w.Stop() + require.NoError(t, f.client.Delete(ctx, "cp", metav1.DeleteOptions{})) + require.NoError(t, f.client.DeleteCollection(ctx, metav1.DeleteOptions{}, metav1.ListOptions{})) + + want := []struct { + verb string + subresource string + }{ + {"create", ""}, + {"get", ""}, + {"list", ""}, + {"get", ""}, + {"update", ""}, + {"update", "status"}, + {"patch", ""}, + {"watch", ""}, + {"delete", ""}, + {"delete-collection", ""}, + } + + actions := f.fake.Actions() + require.Len(t, actions, len(want)) + for i, exp := range want { + action := actions[i] + assert.Equalf(t, exp.verb, action.GetVerb(), "action %d verb", i) + assert.Equalf(t, exp.subresource, action.GetSubresource(), "action %d subresource", i) + assert.Equalf(t, clusterPolicyGVR(), action.GetResource(), "action %d resource", i) + // ClusterPolicy is cluster scoped, so every action is namespace-less. + assert.Emptyf(t, action.GetNamespace(), "action %d namespace", i) + assert.Truef(t, action.Matches(exp.verb, "clusterpolicies"), "action %d Matches()", i) + } + + f.fake.ClearActions() + assert.Empty(t, f.fake.Actions()) +} + +// TestPrependReactorShortCircuits proves the reactor chain is honored: a canned +// error prepended for a verb surfaces through the corresponding typed method +// while the other verbs still hit the tracker. +func TestPrependReactorShortCircuits(t *testing.T) { + sentinel := errors.New("boom") + + for _, tc := range []struct { + name string + verb string + call func(ctx context.Context, c nvidiav1.ClusterPolicyInterface) error + }{ + {"create", "create", func(ctx context.Context, c nvidiav1.ClusterPolicyInterface) error { + _, err := c.Create(ctx, newClusterPolicy("cp", nil), metav1.CreateOptions{}) + return err + }}, + {"get", "get", func(ctx context.Context, c nvidiav1.ClusterPolicyInterface) error { + _, err := c.Get(ctx, "cp", metav1.GetOptions{}) + return err + }}, + {"list", "list", func(ctx context.Context, c nvidiav1.ClusterPolicyInterface) error { + _, err := c.List(ctx, metav1.ListOptions{}) + return err + }}, + {"update", "update", func(ctx context.Context, c nvidiav1.ClusterPolicyInterface) error { + _, err := c.Update(ctx, newClusterPolicy("cp", nil), metav1.UpdateOptions{}) + return err + }}, + {"update status", "update", func(ctx context.Context, c nvidiav1.ClusterPolicyInterface) error { + _, err := c.UpdateStatus(ctx, newClusterPolicy("cp", nil), metav1.UpdateOptions{}) + return err + }}, + {"patch", "patch", func(ctx context.Context, c nvidiav1.ClusterPolicyInterface) error { + _, err := c.Patch(ctx, "cp", types.MergePatchType, []byte(`{}`), metav1.PatchOptions{}) + return err + }}, + {"delete", "delete", func(ctx context.Context, c nvidiav1.ClusterPolicyInterface) error { + return c.Delete(ctx, "cp", metav1.DeleteOptions{}) + }}, + {"delete collection", "delete-collection", func(ctx context.Context, c nvidiav1.ClusterPolicyInterface) error { + return c.DeleteCollection(ctx, metav1.DeleteOptions{}, metav1.ListOptions{}) + }}, + } { + t.Run(tc.name, func(t *testing.T) { + f := newFixture(t, newClusterPolicy("cp", nil)) + + var reacted int + f.fake.PrependReactor(tc.verb, "clusterpolicies", + func(action k8stesting.Action) (bool, runtime.Object, error) { + reacted++ + return true, nil, sentinel + }) + + err := tc.call(t.Context(), f.client) + require.Error(t, err) + assert.ErrorIs(t, err, sentinel) + assert.Equal(t, 1, reacted, "the prepended reactor must run exactly once") + + // The object was never touched: the reactor short-circuited the chain. + tracked, getErr := f.tracker.Get(clusterPolicyGVR(), "", "cp") + require.NoError(t, getErr) + assert.Equal(t, "cp", tracked.(*v1.ClusterPolicy).Name) + }) + } +} + +// TestPrependReactorCanSubstituteObjects shows the reactor chain can also return +// synthetic objects, not just errors. +func TestPrependReactorCanSubstituteObjects(t *testing.T) { + f := newFixture(t) + + canned := newClusterPolicy("canned", nil) + canned.SetStatus(v1.NotReady, "elsewhere") + + f.fake.PrependReactor("get", "clusterpolicies", + func(action k8stesting.Action) (bool, runtime.Object, error) { + return true, canned, nil + }) + + got, err := f.client.Get(t.Context(), "anything", metav1.GetOptions{}) + require.NoError(t, err) + assert.Equal(t, "canned", got.Name) + assert.Equal(t, v1.NotReady, got.Status.State) + + // The requested name is still recorded even though the reactor ignored it. + getAction, ok := f.fake.Actions()[0].(k8stesting.GetActionImpl) + require.True(t, ok) + assert.Equal(t, "anything", getAction.GetName()) +} + +// TestReactorScopedToOtherResourceIsIgnored guards against the reactor matching +// on the wrong resource name. +func TestReactorScopedToOtherResourceIsIgnored(t *testing.T) { + f := newFixture(t, newClusterPolicy("cp", nil)) + + f.fake.PrependReactor("get", "nvidiadrivers", + func(action k8stesting.Action) (bool, runtime.Object, error) { + return true, nil, errors.New("should not fire") + }) + + got, err := f.client.Get(t.Context(), "cp", metav1.GetOptions{}) + require.NoError(t, err) + assert.Equal(t, "cp", got.Name) +} + +// TestActionsAreSharedAcrossClientInstances confirms every ClusterPolicies() +// value writes into the one testing.Fake owned by the group client. +func TestActionsAreSharedAcrossClientInstances(t *testing.T) { + f := newFixture(t) + ctx := t.Context() + + first := f.group.ClusterPolicies() + second := f.group.ClusterPolicies() + + _, err := first.Create(ctx, newClusterPolicy("cp", nil), metav1.CreateOptions{}) + require.NoError(t, err) + + // A different client instance sees the object created through the first one. + got, err := second.Get(ctx, "cp", metav1.GetOptions{}) + require.NoError(t, err) + assert.Equal(t, "cp", got.Name) + + assert.Len(t, f.fake.Actions(), 2) +} diff --git a/api/versioned/typed/nvidia/v1/fake/fake_nvidia_client_test.go b/api/versioned/typed/nvidia/v1/fake/fake_nvidia_client_test.go new file mode 100644 index 0000000000..317c564105 --- /dev/null +++ b/api/versioned/typed/nvidia/v1/fake/fake_nvidia_client_test.go @@ -0,0 +1,106 @@ +/** +# Copyright (c) NVIDIA CORPORATION. All rights reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +**/ + +package fake + +import ( + "reflect" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + rest "k8s.io/client-go/rest" + k8stesting "k8s.io/client-go/testing" + + nvidiav1 "github.com/NVIDIA/gpu-operator/api/versioned/typed/nvidia/v1" +) + +// FakeNvidiaV1 must remain a drop-in replacement for the real typed client. +var _ nvidiav1.NvidiaV1Interface = &FakeNvidiaV1{} + +// The ClusterPolicies() getter must also satisfy the getter interface. +var _ nvidiav1.ClusterPoliciesGetter = &FakeNvidiaV1{} + +func TestFakeNvidiaV1ClusterPolicies(t *testing.T) { + c := &FakeNvidiaV1{Fake: &k8stesting.Fake{}} + + cps := c.ClusterPolicies() + require.NotNil(t, cps) + + // The returned value is the package-private fake implementation, wired back + // to the same FakeNvidiaV1 so that reactors/actions are shared. + impl, ok := cps.(*fakeClusterPolicies) + require.True(t, ok, "ClusterPolicies() should return *fakeClusterPolicies, got %T", cps) + assert.Same(t, c, impl.Fake, "fakeClusterPolicies must point back at its FakeNvidiaV1") + assert.Same(t, c.Fake, impl.FakeClientWithList.Fake, + "the embedded gentype client must share the same testing.Fake as the group client") +} + +func TestFakeNvidiaV1ClusterPoliciesReturnsFreshClients(t *testing.T) { + c := &FakeNvidiaV1{Fake: &k8stesting.Fake{}} + + first := c.ClusterPolicies() + second := c.ClusterPolicies() + + require.NotNil(t, first) + require.NotNil(t, second) + // Each call constructs a new value (the generated code does not memoize), but + // both are backed by the very same testing.Fake, so actions/reactors are shared. + assert.NotSame(t, first, second) + assert.Same(t, + first.(*fakeClusterPolicies).FakeClientWithList.Fake, + second.(*fakeClusterPolicies).FakeClientWithList.Fake, + ) +} + +// TestFakeNvidiaV1RESTClientIsTypedNil documents a sharp edge of the generated +// stub in this repo: RESTClient() declares `var ret *rest.RESTClient` and returns +// it, so the caller receives a NON-nil rest.Interface whose dynamic value is a nil +// *rest.RESTClient. A plain `if c.RESTClient() == nil` check therefore does NOT +// fire. +// +// The returned client has no usable transport (no config, no base URL, no +// round-tripper), so it cannot be used to issue requests. Exactly how client-go's +// rest package reacts to that is an upstream detail and is not asserted here. +func TestFakeNvidiaV1RESTClientIsTypedNil(t *testing.T) { + c := &FakeNvidiaV1{Fake: &k8stesting.Fake{}} + + got := c.RESTClient() + + // The interface value itself is not the untyped nil... + assert.False(t, got == nil, + "RESTClient() returns a non-nil interface holding a nil pointer") + + // ...but the dynamic value is a nil *rest.RESTClient. + restClient, ok := got.(*rest.RESTClient) + require.True(t, ok, "expected dynamic type *rest.RESTClient, got %T", got) + assert.Nil(t, restClient) + + v := reflect.ValueOf(got) + require.Equal(t, reflect.Pointer, v.Kind()) + assert.True(t, v.IsNil(), "underlying *rest.RESTClient must be nil") + + // testify's assert.Nil is reflection based, so it agrees the value is nil. + assert.Nil(t, got) +} + +func TestFakeNvidiaV1RESTClientIsStable(t *testing.T) { + c := &FakeNvidiaV1{Fake: &k8stesting.Fake{}} + + // Repeated calls are pure: no state, no recorded actions. + assert.Equal(t, c.RESTClient(), c.RESTClient()) + assert.Empty(t, c.Actions(), "RESTClient() must not record an action") +} diff --git a/api/versioned/typed/nvidia/v1/nvidia_client_test.go b/api/versioned/typed/nvidia/v1/nvidia_client_test.go new file mode 100644 index 0000000000..6bf7f60486 --- /dev/null +++ b/api/versioned/typed/nvidia/v1/nvidia_client_test.go @@ -0,0 +1,191 @@ +/** +# Copyright (c) NVIDIA CORPORATION. All rights reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +**/ + +package v1 + +import ( + "io/fs" + "net/http" + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + rest "k8s.io/client-go/rest" + + nvidiav1 "github.com/NVIDIA/gpu-operator/api/nvidia/v1" +) + +// *NvidiaV1Client must satisfy the generated group interface. +var _ NvidiaV1Interface = &NvidiaV1Client{} + +func TestSetConfigDefaults(t *testing.T) { + t.Run("populates group version, api path, serializer and user agent", func(t *testing.T) { + cfg := &rest.Config{} + setConfigDefaults(cfg) + + require.NotNil(t, cfg.GroupVersion) + assert.Equal(t, nvidiav1.SchemeGroupVersion, *cfg.GroupVersion) + assert.Equal(t, "nvidia.com", cfg.GroupVersion.Group) + assert.Equal(t, "v1", cfg.GroupVersion.Version) + assert.Equal(t, "/apis", cfg.APIPath) + assert.NotNil(t, cfg.NegotiatedSerializer) + assert.Equal(t, rest.DefaultKubernetesUserAgent(), cfg.UserAgent) + assert.NotEmpty(t, cfg.UserAgent) + }) + + t.Run("preserves a caller supplied user agent", func(t *testing.T) { + cfg := &rest.Config{UserAgent: "my-operator/v1.2.3"} + setConfigDefaults(cfg) + + assert.Equal(t, "my-operator/v1.2.3", cfg.UserAgent) + }) + + t.Run("overwrites a stale group version", func(t *testing.T) { + wrong := nvidiav1.SchemeGroupVersion + wrong.Group = "example.com" + cfg := &rest.Config{APIPath: "/api"} + cfg.GroupVersion = &wrong + setConfigDefaults(cfg) + + require.NotNil(t, cfg.GroupVersion) + assert.Equal(t, "nvidia.com", cfg.GroupVersion.Group) + assert.Equal(t, "/apis", cfg.APIPath) + // The caller's GroupVersion value must not be aliased/modified in place. + assert.Equal(t, "example.com", wrong.Group) + }) +} + +func TestNewForConfig(t *testing.T) { + t.Run("success", func(t *testing.T) { + cfg := &rest.Config{Host: "https://localhost:6443"} + + client, err := NewForConfig(cfg) + require.NoError(t, err) + require.NotNil(t, client) + require.NotNil(t, client.RESTClient()) + assert.Equal(t, nvidiav1.SchemeGroupVersion, client.RESTClient().APIVersion()) + }) + + t.Run("does not mutate the caller config", func(t *testing.T) { + cfg := &rest.Config{Host: "https://localhost:6443"} + + _, err := NewForConfig(cfg) + require.NoError(t, err) + + assert.Nil(t, cfg.GroupVersion, "caller GroupVersion should be untouched") + assert.Empty(t, cfg.APIPath, "caller APIPath should be untouched") + assert.Nil(t, cfg.NegotiatedSerializer, "caller NegotiatedSerializer should be untouched") + assert.Empty(t, cfg.UserAgent, "caller UserAgent should be untouched") + }) + + // One malformed-TLS case is enough to prove that the transport error is + // propagated instead of swallowed. The assertion is on the error's type, + // not its text: the message comes from the standard library and client-go + // and changes across versions and platforms. + t.Run("propagates a transport construction error", func(t *testing.T) { + cfg := &rest.Config{ + Host: "https://localhost:6443", + TLSClientConfig: rest.TLSClientConfig{ + CAFile: filepath.Join(t.TempDir(), "does-not-exist.crt"), + }, + } + + client, err := NewForConfig(cfg) + require.Error(t, err) + assert.Nil(t, client) + + var pathErr *os.PathError + require.ErrorAs(t, err, &pathErr) + assert.Equal(t, cfg.CAFile, pathErr.Path) + assert.ErrorIs(t, err, fs.ErrNotExist) + }) +} + +func TestNewForConfigAndClient(t *testing.T) { + t.Run("uses the supplied http client", func(t *testing.T) { + cfg := &rest.Config{Host: "https://localhost:6443"} + httpClient := &http.Client{} + + client, err := NewForConfigAndClient(cfg, httpClient) + require.NoError(t, err) + require.NotNil(t, client) + + restClient, ok := client.RESTClient().(*rest.RESTClient) + require.True(t, ok) + assert.Same(t, httpClient, restClient.Client) + }) + + t.Run("errors on a malformed host", func(t *testing.T) { + cfg := &rest.Config{Host: "https://localhost:6443/\x7f/bad"} + + client, err := NewForConfigAndClient(cfg, &http.Client{}) + require.Error(t, err) + assert.Nil(t, client) + }) +} + +func TestNewForConfigOrDie(t *testing.T) { + t.Run("returns a client for a good config", func(t *testing.T) { + var client *NvidiaV1Client + require.NotPanics(t, func() { + client = NewForConfigOrDie(&rest.Config{Host: "https://localhost:6443"}) + }) + require.NotNil(t, client) + assert.NotNil(t, client.ClusterPolicies()) + }) + + t.Run("panics for a bad config", func(t *testing.T) { + bad := &rest.Config{ + Host: "https://localhost:6443", + TLSClientConfig: rest.TLSClientConfig{ + CAFile: filepath.Join(t.TempDir(), "missing.crt"), + }, + } + assert.Panics(t, func() { _ = NewForConfigOrDie(bad) }) + }) +} + +func TestNew(t *testing.T) { + seed, err := NewForConfig(&rest.Config{Host: "https://localhost:6443"}) + require.NoError(t, err) + inner := seed.RESTClient() + require.NotNil(t, inner) + + client := New(inner) + require.NotNil(t, client) + assert.Same(t, inner, client.RESTClient()) +} + +func TestRESTClientNilReceiver(t *testing.T) { + var client *NvidiaV1Client + assert.Nil(t, client.RESTClient()) +} + +func TestClusterPolicies(t *testing.T) { + client, err := NewForConfig(&rest.Config{Host: "https://localhost:6443"}) + require.NoError(t, err) + + cp := client.ClusterPolicies() + require.NotNil(t, cp) + + typed, ok := cp.(*clusterPolicies) + require.True(t, ok) + // ClusterPolicy is cluster scoped: the generated client is built with an empty namespace. + assert.Empty(t, typed.GetNamespace()) + assert.Same(t, client.RESTClient(), typed.GetClient()) +} diff --git a/api/versioned/typed/nvidia/v1alpha1/fake/fake_gpucluster_test.go b/api/versioned/typed/nvidia/v1alpha1/fake/fake_gpucluster_test.go new file mode 100644 index 0000000000..52dc79ab43 --- /dev/null +++ b/api/versioned/typed/nvidia/v1alpha1/fake/fake_gpucluster_test.go @@ -0,0 +1,667 @@ +/** +# Copyright (c) NVIDIA CORPORATION. All rights reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +**/ + +package fake + +import ( + "context" + "errors" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + apierrors "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/types" + "k8s.io/apimachinery/pkg/watch" + k8stesting "k8s.io/client-go/testing" + + v1alpha1 "github.com/NVIDIA/gpu-operator/api/nvidia/v1alpha1" + nvidiav1alpha1 "github.com/NVIDIA/gpu-operator/api/versioned/typed/nvidia/v1alpha1" +) + +// gpuClusterFixture is the shared group fixture (see fake_nvidia_client_test.go) +// narrowed to the GPUCluster client. +type gpuClusterFixture struct { + *fakeGroupFixture + client nvidiav1alpha1.GPUClusterInterface +} + +func newGPUClusterFixture(t *testing.T, objects ...runtime.Object) *gpuClusterFixture { + t.Helper() + base := newFakeGroupFixture(t, objects...) + return &gpuClusterFixture{fakeGroupFixture: base, client: base.group.GPUClusters()} +} + +// newGPUCluster builds a GPUCluster. Note that the CRD declares GPUCluster a +// singleton via a CEL rule pinning metadata.name to "gpu-cluster"; the fake +// performs no CEL validation, so list/selector tests below legitimately hold +// several objects at once. +func newGPUCluster(name string, labels map[string]string) *v1alpha1.GPUCluster { + return &v1alpha1.GPUCluster{ + ObjectMeta: metav1.ObjectMeta{Name: name, Labels: labels}, + Spec: v1alpha1.GPUClusterSpec{ + DRADriver: v1alpha1.DRADriverSpec{ + Repository: "nvcr.io/nvidia/cloud-native", + Image: "k8s-dra-driver-gpu", + Version: "v25.3.0", + }, + }, + } +} + +func TestGPUClusters_CreateAndGet(t *testing.T) { + f := newGPUClusterFixture(t) + ctx := t.Context() + + created, err := f.client.Create(ctx, newGPUCluster("gpu-cluster", nil), metav1.CreateOptions{}) + require.NoError(t, err) + require.NotNil(t, created) + assert.Equal(t, "gpu-cluster", created.Name) + assert.Equal(t, "v25.3.0", created.Spec.DRADriver.Version) + + got, err := f.client.Get(ctx, "gpu-cluster", metav1.GetOptions{}) + require.NoError(t, err) + assert.Equal(t, created, got) + + // The tracker hands back copies: mutating the result must not leak back in. + got.Spec.DRADriver.Version = "mutated" + fresh, err := f.client.Get(ctx, "gpu-cluster", metav1.GetOptions{}) + require.NoError(t, err) + assert.Equal(t, "v25.3.0", fresh.Spec.DRADriver.Version) + + createAction := f.fake.Actions()[0] + assert.Equal(t, "create", createAction.GetVerb()) + assert.Equal(t, gpuClusterGVR, createAction.GetResource()) +} + +func TestGPUClusters_CreateDuplicateIsAlreadyExists(t *testing.T) { + f := newGPUClusterFixture(t, newGPUCluster("gpu-cluster", nil)) + + _, err := f.client.Create(t.Context(), newGPUCluster("gpu-cluster", nil), metav1.CreateOptions{}) + require.Error(t, err) + assert.True(t, apierrors.IsAlreadyExists(err), "expected AlreadyExists, got %v", err) +} + +func TestGPUClusters_GetMissingIsNotFound(t *testing.T) { + f := newGPUClusterFixture(t) + + got, err := f.client.Get(t.Context(), "nope", metav1.GetOptions{}) + require.Error(t, err) + assert.True(t, apierrors.IsNotFound(err), "expected NotFound, got %v", err) + // On error the generated client still returns a non-nil zero value. + require.NotNil(t, got) + assert.Empty(t, got.Name) + + statusErr := &apierrors.StatusError{} + require.True(t, errors.As(err, &statusErr)) + assert.Equal(t, gpuClusterGVR.Group, statusErr.ErrStatus.Details.Group) + assert.Equal(t, gpuClusterGVR.Resource, statusErr.ErrStatus.Details.Kind) + assert.Equal(t, "nope", statusErr.ErrStatus.Details.Name) +} + +// TestGPUClusters_MissingObjectIsNotFoundTable covers the NotFound path of every +// name-addressed verb in one place. +func TestGPUClusters_MissingObjectIsNotFoundTable(t *testing.T) { + for _, tc := range []struct { + name string + call func(context.Context, nvidiav1alpha1.GPUClusterInterface) error + }{ + {"get", func(ctx context.Context, c nvidiav1alpha1.GPUClusterInterface) error { + _, err := c.Get(ctx, "ghost", metav1.GetOptions{}) + return err + }}, + {"update", func(ctx context.Context, c nvidiav1alpha1.GPUClusterInterface) error { + _, err := c.Update(ctx, newGPUCluster("ghost", nil), metav1.UpdateOptions{}) + return err + }}, + {"update status", func(ctx context.Context, c nvidiav1alpha1.GPUClusterInterface) error { + _, err := c.UpdateStatus(ctx, newGPUCluster("ghost", nil), metav1.UpdateOptions{}) + return err + }}, + {"delete", func(ctx context.Context, c nvidiav1alpha1.GPUClusterInterface) error { + return c.Delete(ctx, "ghost", metav1.DeleteOptions{}) + }}, + {"patch", func(ctx context.Context, c nvidiav1alpha1.GPUClusterInterface) error { + _, err := c.Patch(ctx, "ghost", types.MergePatchType, []byte(`{}`), metav1.PatchOptions{}) + return err + }}, + } { + t.Run(tc.name, func(t *testing.T) { + f := newGPUClusterFixture(t) + + err := tc.call(t.Context(), f.client) + require.Error(t, err) + assert.True(t, apierrors.IsNotFound(err), "expected NotFound, got %v", err) + }) + } +} + +func TestGPUClusters_Update(t *testing.T) { + f := newGPUClusterFixture(t, newGPUCluster("gpu-cluster", nil)) + ctx := t.Context() + + current, err := f.client.Get(ctx, "gpu-cluster", metav1.GetOptions{}) + require.NoError(t, err) + + f.fake.ClearActions() + current.Spec.DRADriver.Version = "v25.4.0" + updated, err := f.client.Update(ctx, current, metav1.UpdateOptions{}) + require.NoError(t, err) + assert.Equal(t, "v25.4.0", updated.Spec.DRADriver.Version) + + // The change is visible through the tracker on the next read. + reread, err := f.client.Get(ctx, "gpu-cluster", metav1.GetOptions{}) + require.NoError(t, err) + assert.Equal(t, "v25.4.0", reread.Spec.DRADriver.Version) + + updateAction := f.fake.Actions()[0] + assert.Equal(t, "update", updateAction.GetVerb()) + assert.Equal(t, gpuClusterGVR, updateAction.GetResource()) + assert.Empty(t, updateAction.GetSubresource(), "Update must not target a subresource") +} + +func TestGPUClusters_UpdateStatus(t *testing.T) { + f := newGPUClusterFixture(t, newGPUCluster("gpu-cluster", nil)) + ctx := t.Context() + + current, err := f.client.Get(ctx, "gpu-cluster", metav1.GetOptions{}) + require.NoError(t, err) + current.Status.State = v1alpha1.Ready + current.Status.Namespace = "gpu-operator" + + f.fake.ClearActions() + updated, err := f.client.UpdateStatus(ctx, current, metav1.UpdateOptions{}) + require.NoError(t, err) + assert.Equal(t, v1alpha1.Ready, updated.Status.State) + assert.Equal(t, "gpu-operator", updated.Status.Namespace) + + reread, err := f.client.Get(ctx, "gpu-cluster", metav1.GetOptions{}) + require.NoError(t, err) + assert.Equal(t, v1alpha1.Ready, reread.Status.State) + + // UpdateStatus is recorded as an update against the "status" subresource. + action := f.fake.Actions()[0] + assert.Equal(t, "update", action.GetVerb()) + assert.Equal(t, "status", action.GetSubresource()) + assert.Equal(t, gpuClusterGVR, action.GetResource()) +} + +// TestGPUClusters_UpdateStatusWithSpecChangeStillTargetsStatusSubresource is +// the GPUCluster half of the assertion documented on the NVIDIADriver test of +// the same name: regardless of what the caller mutated, UpdateStatus is +// recorded as an "update" against the "status" subresource of the right +// cluster-scoped GVR. How the reaction chain applies it (today: as a +// full-object replace) is an upstream detail and is not asserted. +func TestGPUClusters_UpdateStatusWithSpecChangeStillTargetsStatusSubresource(t *testing.T) { + f := newGPUClusterFixture(t, newGPUCluster("gpu-cluster", nil)) + ctx := t.Context() + + current, err := f.client.Get(ctx, "gpu-cluster", metav1.GetOptions{}) + require.NoError(t, err) + current.Status.State = v1alpha1.NotReady + current.Spec.DRADriver.Version = "spec-change-sent-alongside-the-status-update" + + f.fake.ClearActions() + _, err = f.client.UpdateStatus(ctx, current, metav1.UpdateOptions{}) + require.NoError(t, err) + + action := lastAction(t, f.fake) + assert.Equal(t, "update", action.GetVerb()) + assert.Equal(t, "status", action.GetSubresource()) + assert.Equal(t, gpuClusterGVR, action.GetResource()) + assert.Empty(t, action.GetNamespace(), "GPUCluster is cluster scoped") +} + +func TestGPUClusters_List(t *testing.T) { + f := newGPUClusterFixture(t, + newGPUCluster("cluster-a", map[string]string{"tier": "prod"}), + newGPUCluster("cluster-b", map[string]string{"tier": "dev"}), + newGPUCluster("cluster-c", map[string]string{"tier": "prod"}), + ) + + all, err := f.client.List(t.Context(), metav1.ListOptions{}) + require.NoError(t, err) + require.Len(t, all.Items, 3) + assert.ElementsMatch(t, + []string{"cluster-a", "cluster-b", "cluster-c"}, + []string{all.Items[0].Name, all.Items[1].Name, all.Items[2].Name}, + ) + + // The recorded list action carries both the GVR and the GVK wired into + // newFakeGPUClusters. + listAction, ok := lastAction(t, f.fake).(k8stesting.ListActionImpl) + require.True(t, ok) + assert.Equal(t, "list", listAction.GetVerb()) + assert.Equal(t, gpuClusterGVR, listAction.GetResource()) + assert.Equal(t, gpuClusterGVK, listAction.Kind) +} + +func TestGPUClusters_ListEmpty(t *testing.T) { + f := newGPUClusterFixture(t) + + list, err := f.client.List(t.Context(), metav1.ListOptions{}) + require.NoError(t, err) + require.NotNil(t, list) + assert.Empty(t, list.Items) +} + +// TestGPUClusters_ListLabelSelectorPreservesListMeta exercises the generated +// copyListMeta hook: +// +// func(dst, src *v1alpha1.GPUClusterList) { dst.ListMeta = src.ListMeta } +// +// It is only invoked on the label-selector path, where gentype builds a fresh +// list and must carry the ListMeta (notably ResourceVersion) across. +func TestGPUClusters_ListLabelSelectorPreservesListMeta(t *testing.T) { + f := newGPUClusterFixture(t, + newGPUCluster("cluster-a", map[string]string{"tier": "prod"}), + newGPUCluster("cluster-b", map[string]string{"tier": "dev"}), + newGPUCluster("cluster-c", map[string]string{"tier": "prod"}), + ) + ctx := t.Context() + + // The tracker stamps the collection ResourceVersion onto every list it returns. + unfiltered, err := f.client.List(ctx, metav1.ListOptions{}) + require.NoError(t, err) + seededRV := unfiltered.ResourceVersion + require.NotEmpty(t, seededRV) + require.NotEqual(t, "0", seededRV) + + filtered, err := f.client.List(ctx, metav1.ListOptions{LabelSelector: "tier=prod"}) + require.NoError(t, err) + require.Len(t, filtered.Items, 2) + assert.ElementsMatch(t, + []string{"cluster-a", "cluster-c"}, + []string{filtered.Items[0].Name, filtered.Items[1].Name}, + ) + assert.Equal(t, seededRV, filtered.ResourceVersion, + "copyListMeta must carry ListMeta onto the label-filtered list") +} + +func TestGPUClusters_ListLabelSelectorTable(t *testing.T) { + objects := []runtime.Object{ + newGPUCluster("cluster-a", map[string]string{"tier": "prod", "arch": "amd64"}), + newGPUCluster("cluster-b", map[string]string{"tier": "dev", "arch": "amd64"}), + newGPUCluster("cluster-c", map[string]string{"tier": "prod", "arch": "arm64"}), + newGPUCluster("cluster-unlabeled", nil), + } + + for _, tc := range []struct { + name string + selector string + want []string + }{ + {"empty selector matches everything", "", []string{"cluster-a", "cluster-b", "cluster-c", "cluster-unlabeled"}}, + {"single label", "tier=prod", []string{"cluster-a", "cluster-c"}}, + {"conjunction", "tier=prod,arch=arm64", []string{"cluster-c"}}, + {"set based", "tier in (dev,prod)", []string{"cluster-a", "cluster-b", "cluster-c"}}, + {"negation", "tier!=prod", []string{"cluster-b", "cluster-unlabeled"}}, + {"no match", "tier=staging", nil}, + } { + t.Run(tc.name, func(t *testing.T) { + f := newGPUClusterFixture(t, objects...) + + list, err := f.client.List(t.Context(), metav1.ListOptions{LabelSelector: tc.selector}) + require.NoError(t, err) + + got := make([]string, 0, len(list.Items)) + for i := range list.Items { + got = append(got, list.Items[i].Name) + } + assert.ElementsMatch(t, tc.want, got) + }) + } +} + +func TestGPUClusters_Delete(t *testing.T) { + f := newGPUClusterFixture(t, newGPUCluster("cluster-a", nil), newGPUCluster("cluster-b", nil)) + ctx := t.Context() + + require.NoError(t, f.client.Delete(ctx, "cluster-a", metav1.DeleteOptions{})) + + _, err := f.client.Get(ctx, "cluster-a", metav1.GetOptions{}) + require.Error(t, err) + assert.True(t, apierrors.IsNotFound(err), "expected NotFound after delete, got %v", err) + + // Unrelated objects are untouched. + _, err = f.client.Get(ctx, "cluster-b", metav1.GetOptions{}) + require.NoError(t, err) +} + +// TestGPUClusters_DeleteCollectionRecordsTheAction asserts the generated client +// contract: DeleteCollection is recorded as a "delete-collection" action on the +// right cluster-scoped GVR, carrying through both the DeleteOptions and the +// ListOptions the caller supplied. +// +// As documented on the NVIDIADriver test of the same name, whether anything is +// removed is up to the reaction chain: testing.ObjectReaction has no case for +// DeleteCollectionActionImpl, so the default reactor leaves the tracker alone. +// TestGPUClusters_DeleteCollectionWithReactorRemovesAll covers the supported way +// to get collection semantics. +func TestGPUClusters_DeleteCollectionRecordsTheAction(t *testing.T) { + f := newGPUClusterFixture(t, newGPUCluster("cluster-a", nil), newGPUCluster("cluster-b", nil)) + + gracePeriod := int64(30) + deleteOpts := metav1.DeleteOptions{GracePeriodSeconds: &gracePeriod} + listOpts := metav1.ListOptions{LabelSelector: "tier=prod"} + + require.NoError(t, f.client.DeleteCollection(t.Context(), deleteOpts, listOpts)) + + action, ok := lastAction(t, f.fake).(k8stesting.DeleteCollectionActionImpl) + require.True(t, ok) + assert.Equal(t, "delete-collection", action.GetVerb()) + assert.Equal(t, gpuClusterGVR, action.GetResource()) + // GPUCluster is cluster scoped, so the action carries no namespace. + assert.Empty(t, action.GetNamespace()) + assert.Equal(t, deleteOpts, action.GetDeleteOptions()) + assert.Equal(t, listOpts, action.GetListOptions()) + assert.Equal(t, "tier=prod", action.GetListRestrictions().Labels.String()) +} + +// TestGPUClusters_DeleteCollectionWithReactorRemovesAll shows the supported way +// to get collection semantics: a reactor that fans the request out to the +// tracker. This also proves DeleteCollection flows through the reaction chain. +func TestGPUClusters_DeleteCollectionWithReactorRemovesAll(t *testing.T) { + f := newGPUClusterFixture(t, newGPUCluster("cluster-a", nil), newGPUCluster("cluster-b", nil)) + ctx := t.Context() + + f.fake.PrependReactor("delete-collection", "gpuclusters", func(action k8stesting.Action) (bool, runtime.Object, error) { + obj, err := f.tracker.List(gpuClusterGVR, gpuClusterGVK, action.GetNamespace()) + if err != nil { + return true, nil, err + } + list, ok := obj.(*v1alpha1.GPUClusterList) + if !ok { + return true, nil, errors.New("unexpected list type") + } + for i := range list.Items { + if err := f.tracker.Delete(gpuClusterGVR, action.GetNamespace(), list.Items[i].Name); err != nil { + return true, nil, err + } + } + return true, nil, nil + }) + + require.NoError(t, f.client.DeleteCollection(ctx, metav1.DeleteOptions{}, metav1.ListOptions{})) + + remaining, err := f.client.List(ctx, metav1.ListOptions{}) + require.NoError(t, err) + assert.Empty(t, remaining.Items) +} + +func TestGPUClusters_Patch(t *testing.T) { + f := newGPUClusterFixture(t, newGPUCluster("gpu-cluster", map[string]string{"tier": "dev"})) + ctx := t.Context() + + patch := []byte(`{"metadata":{"labels":{"tier":"prod"}},"spec":{"draDriver":{"version":"v25.5.0"}}}`) + patched, err := f.client.Patch(ctx, "gpu-cluster", types.MergePatchType, patch, metav1.PatchOptions{}) + require.NoError(t, err) + assert.Equal(t, "v25.5.0", patched.Spec.DRADriver.Version) + assert.Equal(t, "prod", patched.Labels["tier"]) + // A merge patch of a nested object leaves sibling fields intact. + assert.Equal(t, "k8s-dra-driver-gpu", patched.Spec.DRADriver.Image) + + reread, err := f.client.Get(ctx, "gpu-cluster", metav1.GetOptions{}) + require.NoError(t, err) + assert.Equal(t, "v25.5.0", reread.Spec.DRADriver.Version) + assert.Equal(t, "prod", reread.Labels["tier"]) + + action, ok := f.fake.Actions()[0].(k8stesting.PatchAction) + require.True(t, ok) + assert.Equal(t, "patch", action.GetVerb()) + assert.Equal(t, types.MergePatchType, action.GetPatchType()) + assert.Equal(t, "gpu-cluster", action.GetName()) + assert.Equal(t, gpuClusterGVR, action.GetResource()) +} + +func TestGPUClusters_PatchSubresource(t *testing.T) { + f := newGPUClusterFixture(t, newGPUCluster("gpu-cluster", nil)) + + patch := []byte(`{"status":{"state":"ready"}}`) + patched, err := f.client.Patch(t.Context(), "gpu-cluster", types.MergePatchType, patch, metav1.PatchOptions{}, "status") + require.NoError(t, err) + assert.Equal(t, v1alpha1.Ready, patched.Status.State) + + assert.Equal(t, "status", f.fake.Actions()[0].GetSubresource()) +} + +func TestGPUClusters_Watch(t *testing.T) { + f := newGPUClusterFixture(t) + ctx := t.Context() + + w, err := f.client.Watch(ctx, metav1.ListOptions{}) + require.NoError(t, err) + require.NotNil(t, w) + defer w.Stop() + + created, err := f.client.Create(ctx, newGPUCluster("gpu-cluster", nil), metav1.CreateOptions{}) + require.NoError(t, err) + added := expectEvent[*v1alpha1.GPUCluster](t, w.ResultChan(), watch.Added) + assert.Equal(t, "gpu-cluster", added.Name) + + created.Spec.DRADriver.Version = "v25.4.0" + _, err = f.client.Update(ctx, created, metav1.UpdateOptions{}) + require.NoError(t, err) + modified := expectEvent[*v1alpha1.GPUCluster](t, w.ResultChan(), watch.Modified) + assert.Equal(t, "v25.4.0", modified.Spec.DRADriver.Version) + + require.NoError(t, f.client.Delete(ctx, "gpu-cluster", metav1.DeleteOptions{})) + deleted := expectEvent[*v1alpha1.GPUCluster](t, w.ResultChan(), watch.Deleted) + assert.Equal(t, "gpu-cluster", deleted.Name) + + // The watch action is recorded with the right GVR. + watchAction, ok := f.fake.Actions()[0].(k8stesting.WatchAction) + require.True(t, ok) + assert.Equal(t, "watch", watchAction.GetVerb()) + assert.Equal(t, gpuClusterGVR, watchAction.GetResource()) +} + +// TestGPUClusters_WatchIsScopedToItsOwnResource proves the tracker's per-GVR +// watcher registry does not leak NVIDIADriver events into a GPUCluster watch. +func TestGPUClusters_WatchIsScopedToItsOwnResource(t *testing.T) { + f := newGPUClusterFixture(t) + ctx := t.Context() + + w, err := f.client.Watch(ctx, metav1.ListOptions{}) + require.NoError(t, err) + defer w.Stop() + + // Mutate the sibling resource first; it must not produce an event here. + _, err = f.group.NVIDIADrivers().Create(ctx, newDriver("gpu-driver", nil), metav1.CreateOptions{}) + require.NoError(t, err) + + _, err = f.client.Create(ctx, newGPUCluster("gpu-cluster", nil), metav1.CreateOptions{}) + require.NoError(t, err) + + // The first (and only) event delivered is the GPUCluster one. + added := expectEvent[*v1alpha1.GPUCluster](t, w.ResultChan(), watch.Added) + assert.Equal(t, "gpu-cluster", added.Name) +} + +// TestGPUClusters_WatchForwardsListOptions asserts the generated client hands +// the caller's ListOptions straight through onto the recorded watch action, and +// therefore on to tracker.Watch. +// +// Nothing is asserted about replayed events: whether the tracker replays +// already-known objects, and whether it applies the label selector when it +// does, is client-go's business rather than part of the generated client's +// contract. Event delivery is covered by TestGPUClusters_Watch. +func TestGPUClusters_WatchForwardsListOptions(t *testing.T) { + f := newGPUClusterFixture(t, newGPUCluster("cluster-a", map[string]string{"tier": "prod"})) + + opts := metav1.ListOptions{ResourceVersion: "0", LabelSelector: "tier=prod"} + w, err := f.client.Watch(t.Context(), opts) + require.NoError(t, err) + defer w.Stop() + + watchAction, ok := lastAction(t, f.fake).(k8stesting.WatchActionImpl) + require.True(t, ok) + assert.Equal(t, "watch", watchAction.GetVerb()) + assert.Equal(t, gpuClusterGVR, watchAction.GetResource()) + assert.Empty(t, watchAction.GetNamespace()) + assert.Equal(t, "0", watchAction.ListOptions.ResourceVersion) + assert.Equal(t, "tier=prod", watchAction.ListOptions.LabelSelector) + // The generated client also flips Watch on before recording the action. + assert.True(t, watchAction.ListOptions.Watch) + assert.Equal(t, "tier=prod", watchAction.GetWatchRestrictions().Labels.String()) +} + +func TestGPUClusters_ActionsAreClusterScoped(t *testing.T) { + f := newGPUClusterFixture(t, newGPUCluster("gpu-cluster", nil)) + ctx := t.Context() + + _, _ = f.client.Get(ctx, "gpu-cluster", metav1.GetOptions{}) + _, _ = f.client.List(ctx, metav1.ListOptions{}) + _, _ = f.client.Create(ctx, newGPUCluster("other", nil), metav1.CreateOptions{}) + _, _ = f.client.Update(ctx, newGPUCluster("gpu-cluster", nil), metav1.UpdateOptions{}) + _, _ = f.client.UpdateStatus(ctx, newGPUCluster("gpu-cluster", nil), metav1.UpdateOptions{}) + _, _ = f.client.Patch(ctx, "gpu-cluster", types.MergePatchType, []byte(`{}`), metav1.PatchOptions{}) + _ = f.client.Delete(ctx, "gpu-cluster", metav1.DeleteOptions{}) + _ = f.client.DeleteCollection(ctx, metav1.DeleteOptions{}, metav1.ListOptions{}) + w, err := f.client.Watch(ctx, metav1.ListOptions{}) + require.NoError(t, err) + w.Stop() + + actions := f.fake.Actions() + require.Len(t, actions, 9) + + wantVerbs := []string{"get", "list", "create", "update", "update", "patch", "delete", "delete-collection", "watch"} + for i, action := range actions { + assert.Equal(t, wantVerbs[i], action.GetVerb(), "action %d verb", i) + assert.Equal(t, gpuClusterGVR, action.GetResource(), "action %d resource", i) + // GPUCluster is cluster scoped, so no action ever carries a namespace. + assert.Empty(t, action.GetNamespace(), "action %d namespace", i) + assert.True(t, action.Matches(wantVerbs[i], "gpuclusters"), "action %d should match its own verb/resource", i) + } +} + +// TestGPUClusters_ReactorChainIsHonored proves a PrependReactor short-circuits +// the tracker-backed reactor for every typed method. +func TestGPUClusters_ReactorChainIsHonored(t *testing.T) { + boom := errors.New("boom") + + for _, tc := range []struct { + name string + verb string + call func(context.Context, nvidiav1alpha1.GPUClusterInterface) error + }{ + {"get", "get", func(ctx context.Context, c nvidiav1alpha1.GPUClusterInterface) error { + _, err := c.Get(ctx, "gpu-cluster", metav1.GetOptions{}) + return err + }}, + {"list", "list", func(ctx context.Context, c nvidiav1alpha1.GPUClusterInterface) error { + _, err := c.List(ctx, metav1.ListOptions{}) + return err + }}, + {"create", "create", func(ctx context.Context, c nvidiav1alpha1.GPUClusterInterface) error { + _, err := c.Create(ctx, newGPUCluster("new", nil), metav1.CreateOptions{}) + return err + }}, + {"update", "update", func(ctx context.Context, c nvidiav1alpha1.GPUClusterInterface) error { + _, err := c.Update(ctx, newGPUCluster("gpu-cluster", nil), metav1.UpdateOptions{}) + return err + }}, + {"update status", "update", func(ctx context.Context, c nvidiav1alpha1.GPUClusterInterface) error { + _, err := c.UpdateStatus(ctx, newGPUCluster("gpu-cluster", nil), metav1.UpdateOptions{}) + return err + }}, + {"patch", "patch", func(ctx context.Context, c nvidiav1alpha1.GPUClusterInterface) error { + _, err := c.Patch(ctx, "gpu-cluster", types.MergePatchType, []byte(`{}`), metav1.PatchOptions{}) + return err + }}, + {"delete", "delete", func(ctx context.Context, c nvidiav1alpha1.GPUClusterInterface) error { + return c.Delete(ctx, "gpu-cluster", metav1.DeleteOptions{}) + }}, + {"delete collection", "delete-collection", func(ctx context.Context, c nvidiav1alpha1.GPUClusterInterface) error { + return c.DeleteCollection(ctx, metav1.DeleteOptions{}, metav1.ListOptions{}) + }}, + } { + t.Run(tc.name, func(t *testing.T) { + f := newGPUClusterFixture(t, newGPUCluster("gpu-cluster", nil)) + + var seen k8stesting.Action + f.fake.PrependReactor(tc.verb, "gpuclusters", func(action k8stesting.Action) (bool, runtime.Object, error) { + seen = action + return true, nil, boom + }) + + err := tc.call(t.Context(), f.client) + require.Error(t, err) + assert.ErrorIs(t, err, boom) + + require.NotNil(t, seen, "reactor should have observed the action") + assert.Equal(t, tc.verb, seen.GetVerb()) + assert.Equal(t, gpuClusterGVR, seen.GetResource()) + + // The object graph is untouched because the reactor never reached the tracker. + untouched, trackerErr := f.tracker.Get(gpuClusterGVR, "", "gpu-cluster") + require.NoError(t, trackerErr) + require.NotNil(t, untouched) + }) + } +} + +// TestGPUClusters_ReactorIsScopedByResource proves a reactor registered for +// "gpuclusters" does not intercept the sibling NVIDIADriver resource even +// though both share one reaction chain. +func TestGPUClusters_ReactorIsScopedByResource(t *testing.T) { + f := newFakeGroupFixture(t, newGPUCluster("gpu-cluster", nil), newDriver("gpu-driver", nil)) + boom := errors.New("boom") + + f.fake.PrependReactor("get", "gpuclusters", func(action k8stesting.Action) (bool, runtime.Object, error) { + return true, nil, boom + }) + + _, err := f.group.GPUClusters().Get(t.Context(), "gpu-cluster", metav1.GetOptions{}) + assert.ErrorIs(t, err, boom) + + driver, err := f.group.NVIDIADrivers().Get(t.Context(), "gpu-driver", metav1.GetOptions{}) + require.NoError(t, err) + assert.Equal(t, "gpu-driver", driver.Name) +} + +// TestGPUClusters_WatchReactorErrorSurfaces covers the parallel watch chain. +func TestGPUClusters_WatchReactorErrorSurfaces(t *testing.T) { + f := newGPUClusterFixture(t) + boom := errors.New("watch boom") + + f.fake.PrependWatchReactor("gpuclusters", func(action k8stesting.Action) (bool, watch.Interface, error) { + return true, nil, boom + }) + + w, err := f.client.Watch(t.Context(), metav1.ListOptions{}) + require.Error(t, err) + assert.ErrorIs(t, err, boom) + assert.Nil(t, w) +} + +// TestGPUClusters_ReactorCanReturnASubstituteObject proves the reaction chain +// can fabricate results without any tracker involvement at all. +func TestGPUClusters_ReactorCanReturnASubstituteObject(t *testing.T) { + f := newGPUClusterFixture(t) + + substitute := newGPUCluster("synthetic", map[string]string{"source": "reactor"}) + f.fake.PrependReactor("get", "gpuclusters", func(action k8stesting.Action) (bool, runtime.Object, error) { + return true, substitute, nil + }) + + got, err := f.client.Get(t.Context(), "anything", metav1.GetOptions{}) + require.NoError(t, err) + assert.Equal(t, "synthetic", got.Name) + assert.Equal(t, "reactor", got.Labels["source"]) +} diff --git a/api/versioned/typed/nvidia/v1alpha1/fake/fake_nvidia_client_test.go b/api/versioned/typed/nvidia/v1alpha1/fake/fake_nvidia_client_test.go new file mode 100644 index 0000000000..231b02b32e --- /dev/null +++ b/api/versioned/typed/nvidia/v1alpha1/fake/fake_nvidia_client_test.go @@ -0,0 +1,230 @@ +/** +# Copyright (c) NVIDIA CORPORATION. All rights reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +**/ + +package fake + +import ( + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/watch" + rest "k8s.io/client-go/rest" + k8stesting "k8s.io/client-go/testing" + + v1alpha1 "github.com/NVIDIA/gpu-operator/api/nvidia/v1alpha1" + "github.com/NVIDIA/gpu-operator/api/versioned/scheme" + nvidiav1alpha1 "github.com/NVIDIA/gpu-operator/api/versioned/typed/nvidia/v1alpha1" +) + +// Compile-time assertion that the generated fake group client satisfies the +// real typed group interface. If client-gen ever emits a client that drifts +// from the interface, this fails to build. +var _ nvidiav1alpha1.NvidiaV1alpha1Interface = &FakeNvidiaV1alpha1{} + +// watchTimeout bounds every channel read so a broken watch fails the test +// instead of hanging the suite. Everything here runs in-process against an +// in-memory tracker, so events are delivered essentially immediately; a short +// bound keeps regressions failing fast. +const watchTimeout = 2 * time.Second + +// Every GVR/GVK below is derived from the API package rather than hardcoded, so +// a group/version rename is caught by the type system. +var ( + nvidiaDriverGVR = v1alpha1.SchemeGroupVersion.WithResource("nvidiadrivers") + nvidiaDriverGVK = v1alpha1.SchemeGroupVersion.WithKind("NVIDIADriver") + gpuClusterGVR = v1alpha1.SchemeGroupVersion.WithResource("gpuclusters") + gpuClusterGVK = v1alpha1.SchemeGroupVersion.WithKind("GPUCluster") +) + +// fakeGroupFixture wires a bare testing.Fake to an ObjectTracker the same way +// the generated top-level fake clientset does. Building it here (instead of +// using api/versioned/fake) keeps this package free of an import cycle. Both +// resource clients of this group are served off the one shared testing.Fake. +type fakeGroupFixture struct { + fake *k8stesting.Fake + group *FakeNvidiaV1alpha1 + tracker k8stesting.ObjectTracker +} + +func newFakeGroupFixture(t *testing.T, objects ...runtime.Object) *fakeGroupFixture { + t.Helper() + + tracker := k8stesting.NewObjectTracker(scheme.Scheme, scheme.Codecs.UniversalDecoder()) + for _, obj := range objects { + require.NoError(t, tracker.Add(obj)) + } + + f := &k8stesting.Fake{} + f.AddReactor("*", "*", k8stesting.ObjectReaction(tracker)) + f.AddWatchReactor("*", func(action k8stesting.Action) (bool, watch.Interface, error) { + var opts metav1.ListOptions + if watchAction, ok := action.(k8stesting.WatchActionImpl); ok { + opts = watchAction.ListOptions + } + w, err := tracker.Watch(action.GetResource(), action.GetNamespace(), opts) + if err != nil { + return false, nil, err + } + return true, w, nil + }) + + return &fakeGroupFixture{fake: f, group: &FakeNvidiaV1alpha1{Fake: f}, tracker: tracker} +} + +// lastAction returns the most recently recorded action, failing if none exist. +func lastAction(t *testing.T, f *k8stesting.Fake) k8stesting.Action { + t.Helper() + actions := f.Actions() + require.NotEmpty(t, actions) + return actions[len(actions)-1] +} + +// expectEvent reads one watch event with a hard timeout and asserts both its +// type and the concrete type of its payload. Every watch channel read in this +// package goes through this helper so no test can hang, and so every failure +// mode (closed channel, timeout, wrong event type, wrong payload type) is +// reported from the test goroutine. +func expectEvent[T runtime.Object](t *testing.T, ch <-chan watch.Event, want watch.EventType) T { + t.Helper() + var zero T + timer := time.NewTimer(watchTimeout) + defer timer.Stop() + select { + case ev, ok := <-ch: + require.True(t, ok, "watch channel closed while waiting for %s", want) + require.Equal(t, want, ev.Type) + obj, ok := ev.Object.(T) + require.True(t, ok, "expected %T watch event object, got %T", zero, ev.Object) + return obj + case <-timer.C: + t.Fatalf("timed out after %s waiting for %s watch event", watchTimeout, want) + return zero + } +} + +func TestFakeNvidiaV1alpha1_NVIDIADriversReturnsUsableClient(t *testing.T) { + c := &FakeNvidiaV1alpha1{Fake: &k8stesting.Fake{}} + + drivers := c.NVIDIADrivers() + require.NotNil(t, drivers) + + // The concrete type is the generated fake, wired back to the group client. + concrete, ok := drivers.(*fakeNVIDIADrivers) + require.True(t, ok, "expected NVIDIADrivers() to return *fakeNVIDIADrivers, got %T", drivers) + assert.Same(t, c, concrete.Fake, "fake driver client must point back at its group client") + assert.Equal(t, nvidiaDriverGVR, concrete.Resource()) + assert.Equal(t, nvidiaDriverGVK, concrete.Kind()) + // NVIDIADriver is a cluster-scoped resource (+genclient:nonNamespaced), so + // the generated client is constructed with an empty namespace. + assert.Empty(t, concrete.Namespace()) +} + +func TestFakeNvidiaV1alpha1_GPUClustersReturnsUsableClient(t *testing.T) { + c := &FakeNvidiaV1alpha1{Fake: &k8stesting.Fake{}} + + clusters := c.GPUClusters() + require.NotNil(t, clusters) + + concrete, ok := clusters.(*fakeGPUClusters) + require.True(t, ok, "expected GPUClusters() to return *fakeGPUClusters, got %T", clusters) + assert.Same(t, c, concrete.Fake, "fake cluster client must point back at its group client") + assert.Equal(t, gpuClusterGVR, concrete.Resource()) + assert.Equal(t, gpuClusterGVK, concrete.Kind()) + // GPUCluster is likewise cluster scoped (+genclient:nonNamespaced). + assert.Empty(t, concrete.Namespace()) +} + +func TestFakeNvidiaV1alpha1_AccessorsShareActionRecorder(t *testing.T) { + f := &k8stesting.Fake{} + c := &FakeNvidiaV1alpha1{Fake: f} + + firstDrivers := c.NVIDIADrivers() + secondDrivers := c.NVIDIADrivers() + // Each call builds a fresh struct... + assert.NotSame(t, firstDrivers, secondDrivers) + + // ...but every accessor funnels its actions into the single shared + // testing.Fake, so a test can assert across both resources at once. + ctx := t.Context() + _, _ = firstDrivers.Get(ctx, "a", metav1.GetOptions{}) + _, _ = secondDrivers.Get(ctx, "b", metav1.GetOptions{}) + _, _ = c.GPUClusters().Get(ctx, "c", metav1.GetOptions{}) + + actions := f.Actions() + require.Len(t, actions, 3) + assert.Equal(t, "a", actions[0].(k8stesting.GetAction).GetName()) + assert.Equal(t, nvidiaDriverGVR, actions[0].GetResource()) + assert.Equal(t, "b", actions[1].(k8stesting.GetAction).GetName()) + assert.Equal(t, nvidiaDriverGVR, actions[1].GetResource()) + assert.Equal(t, "c", actions[2].(k8stesting.GetAction).GetName()) + assert.Equal(t, gpuClusterGVR, actions[2].GetResource()) +} + +// TestFakeNvidiaV1alpha1_ResourcesAreIndependentlyTracked proves the two +// resources of this group do not alias each other in the tracker even though +// they share a testing.Fake and a reaction chain. +func TestFakeNvidiaV1alpha1_ResourcesAreIndependentlyTracked(t *testing.T) { + f := newFakeGroupFixture(t, newDriver("shared-name", nil), newGPUCluster("shared-name", nil)) + ctx := t.Context() + + require.NoError(t, f.group.NVIDIADrivers().Delete(ctx, "shared-name", metav1.DeleteOptions{})) + + _, err := f.group.NVIDIADrivers().Get(ctx, "shared-name", metav1.GetOptions{}) + assert.Error(t, err) + + // The identically named GPUCluster is untouched. + cluster, err := f.group.GPUClusters().Get(ctx, "shared-name", metav1.GetOptions{}) + require.NoError(t, err) + assert.Equal(t, "shared-name", cluster.Name) +} + +// TestFakeNvidiaV1alpha1_RESTClientIsTypedNil documents a sharp edge of the +// generated stub: +// +// func (c *FakeNvidiaV1alpha1) RESTClient() rest.Interface { +// var ret *rest.RESTClient +// return ret +// } +// +// The returned rest.Interface is NOT the nil interface: it carries the dynamic +// type *rest.RESTClient with a nil value. Callers that guard with `if rc != nil` +// will take the non-nil branch and then panic on first use. The fake group +// client simply has no REST transport behind it. +func TestFakeNvidiaV1alpha1_RESTClientIsTypedNil(t *testing.T) { + c := &FakeNvidiaV1alpha1{Fake: &k8stesting.Fake{}} + + rc := c.RESTClient() + + // Reflectively nil (this is what assert.Nil checks)... + assert.Nil(t, rc) + // ...but not the nil interface value. + //nolint:staticcheck // deliberately asserting the typed-nil behavior + assert.False(t, rc == nil, "generated stub returns a typed nil, not a nil interface") + assert.IsType(t, (*rest.RESTClient)(nil), rc) + + // GetRateLimiter is explicitly nil-receiver safe upstream, so it is the one + // method that survives the typed nil. + assert.Nil(t, rc.GetRateLimiter()) + + // Anything that actually builds a request (Verb/Post/Get/...) is unusable: + // the returned client has no transport, base URL or content config behind + // it. Exactly how it fails is client-go's business, not this package's, so + // it is documented here rather than asserted. +} diff --git a/api/versioned/typed/nvidia/v1alpha1/fake/fake_nvidiadriver_test.go b/api/versioned/typed/nvidia/v1alpha1/fake/fake_nvidiadriver_test.go new file mode 100644 index 0000000000..c7521bf9d0 --- /dev/null +++ b/api/versioned/typed/nvidia/v1alpha1/fake/fake_nvidiadriver_test.go @@ -0,0 +1,598 @@ +/** +# Copyright (c) NVIDIA CORPORATION. All rights reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +**/ + +package fake + +import ( + "context" + "errors" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + apierrors "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/types" + "k8s.io/apimachinery/pkg/watch" + k8stesting "k8s.io/client-go/testing" + + v1alpha1 "github.com/NVIDIA/gpu-operator/api/nvidia/v1alpha1" + nvidiav1alpha1 "github.com/NVIDIA/gpu-operator/api/versioned/typed/nvidia/v1alpha1" +) + +// driverFixture is the shared group fixture (see fake_nvidia_client_test.go) +// narrowed to the NVIDIADriver client. +type driverFixture struct { + *fakeGroupFixture + client nvidiav1alpha1.NVIDIADriverInterface +} + +func newDriverFixture(t *testing.T, objects ...runtime.Object) *driverFixture { + t.Helper() + base := newFakeGroupFixture(t, objects...) + return &driverFixture{fakeGroupFixture: base, client: base.group.NVIDIADrivers()} +} + +func newDriver(name string, labels map[string]string) *v1alpha1.NVIDIADriver { + return &v1alpha1.NVIDIADriver{ + ObjectMeta: metav1.ObjectMeta{Name: name, Labels: labels}, + Spec: v1alpha1.NVIDIADriverSpec{ + DriverType: v1alpha1.GPU, + Image: "nvcr.io/nvidia/driver", + }, + } +} + +func TestNVIDIADrivers_CreateAndGet(t *testing.T) { + f := newDriverFixture(t) + ctx := t.Context() + + created, err := f.client.Create(ctx, newDriver("gpu-driver", nil), metav1.CreateOptions{}) + require.NoError(t, err) + require.NotNil(t, created) + assert.Equal(t, "gpu-driver", created.Name) + assert.Equal(t, v1alpha1.GPU, created.Spec.DriverType) + + got, err := f.client.Get(ctx, "gpu-driver", metav1.GetOptions{}) + require.NoError(t, err) + assert.Equal(t, created, got) + + // The tracker hands back copies: mutating the result must not leak back in. + got.Spec.Image = "mutated" + fresh, err := f.client.Get(ctx, "gpu-driver", metav1.GetOptions{}) + require.NoError(t, err) + assert.Equal(t, "nvcr.io/nvidia/driver", fresh.Spec.Image) +} + +func TestNVIDIADrivers_CreateDuplicateIsAlreadyExists(t *testing.T) { + f := newDriverFixture(t, newDriver("gpu-driver", nil)) + ctx := t.Context() + + _, err := f.client.Create(ctx, newDriver("gpu-driver", nil), metav1.CreateOptions{}) + require.Error(t, err) + assert.True(t, apierrors.IsAlreadyExists(err), "expected AlreadyExists, got %v", err) +} + +func TestNVIDIADrivers_GetMissingIsNotFound(t *testing.T) { + f := newDriverFixture(t) + + got, err := f.client.Get(t.Context(), "nope", metav1.GetOptions{}) + require.Error(t, err) + assert.True(t, apierrors.IsNotFound(err), "expected NotFound, got %v", err) + // On error the generated client still returns a non-nil zero value. + require.NotNil(t, got) + assert.Empty(t, got.Name) + + statusErr := &apierrors.StatusError{} + require.True(t, errors.As(err, &statusErr)) + assert.Equal(t, nvidiaDriverGVR.Group, statusErr.ErrStatus.Details.Group) + assert.Equal(t, nvidiaDriverGVR.Resource, statusErr.ErrStatus.Details.Kind) + assert.Equal(t, "nope", statusErr.ErrStatus.Details.Name) +} + +func TestNVIDIADrivers_Update(t *testing.T) { + f := newDriverFixture(t, newDriver("gpu-driver", nil)) + ctx := t.Context() + + current, err := f.client.Get(ctx, "gpu-driver", metav1.GetOptions{}) + require.NoError(t, err) + + f.fake.ClearActions() + current.Spec.Image = "nvcr.io/nvidia/driver-next" + updated, err := f.client.Update(ctx, current, metav1.UpdateOptions{}) + require.NoError(t, err) + assert.Equal(t, "nvcr.io/nvidia/driver-next", updated.Spec.Image) + + // The change is visible through the tracker on the next read. + reread, err := f.client.Get(ctx, "gpu-driver", metav1.GetOptions{}) + require.NoError(t, err) + assert.Equal(t, "nvcr.io/nvidia/driver-next", reread.Spec.Image) + + updateAction := f.fake.Actions()[0] + assert.Equal(t, "update", updateAction.GetVerb()) + assert.Empty(t, updateAction.GetSubresource(), "Update must not target a subresource") +} + +func TestNVIDIADrivers_UpdateOfMissingObjectIsNotFound(t *testing.T) { + f := newDriverFixture(t) + + _, err := f.client.Update(t.Context(), newDriver("ghost", nil), metav1.UpdateOptions{}) + require.Error(t, err) + assert.True(t, apierrors.IsNotFound(err), "expected NotFound, got %v", err) +} + +func TestNVIDIADrivers_UpdateStatus(t *testing.T) { + f := newDriverFixture(t, newDriver("gpu-driver", nil)) + ctx := t.Context() + + current, err := f.client.Get(ctx, "gpu-driver", metav1.GetOptions{}) + require.NoError(t, err) + current.Status.State = v1alpha1.Ready + current.Status.Namespace = "gpu-operator" + + f.fake.ClearActions() + updated, err := f.client.UpdateStatus(ctx, current, metav1.UpdateOptions{}) + require.NoError(t, err) + assert.Equal(t, v1alpha1.Ready, updated.Status.State) + assert.Equal(t, "gpu-operator", updated.Status.Namespace) + + reread, err := f.client.Get(ctx, "gpu-driver", metav1.GetOptions{}) + require.NoError(t, err) + assert.Equal(t, v1alpha1.Ready, reread.Status.State) + + // UpdateStatus is recorded as an update against the "status" subresource. + action := f.fake.Actions()[0] + assert.Equal(t, "update", action.GetVerb()) + assert.Equal(t, "status", action.GetSubresource()) + assert.Equal(t, nvidiaDriverGVR, action.GetResource()) +} + +// TestNVIDIADrivers_UpdateStatusWithSpecChangeStillTargetsStatusSubresource +// asserts the part of this flow the generated client actually owns: no matter +// what the caller mutated, UpdateStatus is recorded as an "update" against the +// "status" subresource of the right cluster-scoped GVR. +// +// What the reaction chain then does with the spec change is not this package's +// contract. For the record, testing.ObjectReaction applies subresource updates +// as a full-object replace, so today the spec change is persisted rather than +// dropped the way a real API server would drop it. That is an upstream detail +// which may legitimately change, so it is deliberately not asserted here. +func TestNVIDIADrivers_UpdateStatusWithSpecChangeStillTargetsStatusSubresource(t *testing.T) { + f := newDriverFixture(t, newDriver("gpu-driver", nil)) + ctx := t.Context() + + current, err := f.client.Get(ctx, "gpu-driver", metav1.GetOptions{}) + require.NoError(t, err) + current.Status.State = v1alpha1.NotReady + current.Spec.Image = "spec-change-sent-alongside-the-status-update" + + f.fake.ClearActions() + _, err = f.client.UpdateStatus(ctx, current, metav1.UpdateOptions{}) + require.NoError(t, err) + + action := lastAction(t, f.fake) + assert.Equal(t, "update", action.GetVerb()) + assert.Equal(t, "status", action.GetSubresource()) + assert.Equal(t, nvidiaDriverGVR, action.GetResource()) + assert.Empty(t, action.GetNamespace(), "NVIDIADriver is cluster scoped") +} + +func TestNVIDIADrivers_List(t *testing.T) { + f := newDriverFixture(t, + newDriver("driver-a", map[string]string{"tier": "prod"}), + newDriver("driver-b", map[string]string{"tier": "dev"}), + newDriver("driver-c", map[string]string{"tier": "prod"}), + ) + ctx := t.Context() + + all, err := f.client.List(ctx, metav1.ListOptions{}) + require.NoError(t, err) + require.Len(t, all.Items, 3) + assert.ElementsMatch(t, + []string{"driver-a", "driver-b", "driver-c"}, + []string{all.Items[0].Name, all.Items[1].Name, all.Items[2].Name}, + ) + + // The recorded list action carries both the GVR and the GVK wired into + // newFakeNVIDIADrivers. + listAction, ok := lastAction(t, f.fake).(k8stesting.ListActionImpl) + require.True(t, ok) + assert.Equal(t, "list", listAction.GetVerb()) + assert.Equal(t, nvidiaDriverGVR, listAction.GetResource()) + assert.Equal(t, nvidiaDriverGVK, listAction.Kind) +} + +// TestNVIDIADrivers_ListLabelSelectorPreservesListMeta exercises the generated +// copyListMeta hook: +// +// func(dst, src *v1alpha1.NVIDIADriverList) { dst.ListMeta = src.ListMeta } +// +// It is only invoked on the label-selector path, where gentype builds a fresh +// list and must carry the ListMeta (notably ResourceVersion) across. +func TestNVIDIADrivers_ListLabelSelectorPreservesListMeta(t *testing.T) { + f := newDriverFixture(t, + newDriver("driver-a", map[string]string{"tier": "prod"}), + newDriver("driver-b", map[string]string{"tier": "dev"}), + newDriver("driver-c", map[string]string{"tier": "prod"}), + ) + ctx := t.Context() + + // The tracker stamps the collection ResourceVersion onto every list it returns. + unfiltered, err := f.client.List(ctx, metav1.ListOptions{}) + require.NoError(t, err) + seededRV := unfiltered.ResourceVersion + require.NotEmpty(t, seededRV) + require.NotEqual(t, "0", seededRV) + + filtered, err := f.client.List(ctx, metav1.ListOptions{LabelSelector: "tier=prod"}) + require.NoError(t, err) + require.Len(t, filtered.Items, 2) + assert.ElementsMatch(t, + []string{"driver-a", "driver-c"}, + []string{filtered.Items[0].Name, filtered.Items[1].Name}, + ) + assert.Equal(t, seededRV, filtered.ResourceVersion, + "copyListMeta must carry ListMeta onto the label-filtered list") +} + +func TestNVIDIADrivers_ListLabelSelectorTable(t *testing.T) { + objects := []runtime.Object{ + newDriver("driver-a", map[string]string{"tier": "prod", "arch": "amd64"}), + newDriver("driver-b", map[string]string{"tier": "dev", "arch": "amd64"}), + newDriver("driver-c", map[string]string{"tier": "prod", "arch": "arm64"}), + newDriver("driver-unlabeled", nil), + } + + for _, tc := range []struct { + name string + selector string + want []string + }{ + {"empty selector matches everything", "", []string{"driver-a", "driver-b", "driver-c", "driver-unlabeled"}}, + {"single label", "tier=prod", []string{"driver-a", "driver-c"}}, + {"conjunction", "tier=prod,arch=arm64", []string{"driver-c"}}, + {"set based", "tier in (dev,prod)", []string{"driver-a", "driver-b", "driver-c"}}, + {"negation", "tier!=prod", []string{"driver-b", "driver-unlabeled"}}, + {"no match", "tier=staging", nil}, + } { + t.Run(tc.name, func(t *testing.T) { + f := newDriverFixture(t, objects...) + + list, err := f.client.List(t.Context(), metav1.ListOptions{LabelSelector: tc.selector}) + require.NoError(t, err) + + got := make([]string, 0, len(list.Items)) + for i := range list.Items { + got = append(got, list.Items[i].Name) + } + assert.ElementsMatch(t, tc.want, got) + }) + } +} + +func TestNVIDIADrivers_Delete(t *testing.T) { + f := newDriverFixture(t, newDriver("driver-a", nil), newDriver("driver-b", nil)) + ctx := t.Context() + + require.NoError(t, f.client.Delete(ctx, "driver-a", metav1.DeleteOptions{})) + + _, err := f.client.Get(ctx, "driver-a", metav1.GetOptions{}) + require.Error(t, err) + assert.True(t, apierrors.IsNotFound(err), "expected NotFound after delete, got %v", err) + + // Unrelated objects are untouched. + _, err = f.client.Get(ctx, "driver-b", metav1.GetOptions{}) + require.NoError(t, err) +} + +func TestNVIDIADrivers_DeleteMissingIsNotFound(t *testing.T) { + f := newDriverFixture(t) + + err := f.client.Delete(t.Context(), "ghost", metav1.DeleteOptions{}) + require.Error(t, err) + assert.True(t, apierrors.IsNotFound(err), "expected NotFound, got %v", err) +} + +// TestNVIDIADrivers_DeleteCollectionRecordsTheAction asserts the generated +// client contract: DeleteCollection is recorded as a "delete-collection" action +// on the right cluster-scoped GVR, carrying through both the DeleteOptions and +// the ListOptions the caller supplied. +// +// Whether anything is actually removed is up to the reaction chain, not the +// generated client. For the record, testing.ObjectReaction has no case for +// DeleteCollectionActionImpl, so with only the default reactor installed the +// action falls through unhandled and the tracker keeps every object; see +// TestNVIDIADrivers_DeleteCollectionWithReactorRemovesAll for the supported way +// to get collection semantics. +func TestNVIDIADrivers_DeleteCollectionRecordsTheAction(t *testing.T) { + f := newDriverFixture(t, newDriver("driver-a", nil), newDriver("driver-b", nil)) + + gracePeriod := int64(30) + deleteOpts := metav1.DeleteOptions{GracePeriodSeconds: &gracePeriod} + listOpts := metav1.ListOptions{LabelSelector: "tier=prod"} + + require.NoError(t, f.client.DeleteCollection(t.Context(), deleteOpts, listOpts)) + + action, ok := lastAction(t, f.fake).(k8stesting.DeleteCollectionActionImpl) + require.True(t, ok) + assert.Equal(t, "delete-collection", action.GetVerb()) + assert.Equal(t, nvidiaDriverGVR, action.GetResource()) + // NVIDIADriver is cluster scoped, so the action carries no namespace. + assert.Empty(t, action.GetNamespace()) + assert.Equal(t, deleteOpts, action.GetDeleteOptions()) + assert.Equal(t, listOpts, action.GetListOptions()) + assert.Equal(t, "tier=prod", action.GetListRestrictions().Labels.String()) +} + +// TestNVIDIADrivers_DeleteCollectionWithReactorRemovesAll shows the supported +// way to get collection semantics: a reactor that fans the request out to the +// tracker. This also proves DeleteCollection flows through the reaction chain. +func TestNVIDIADrivers_DeleteCollectionWithReactorRemovesAll(t *testing.T) { + f := newDriverFixture(t, newDriver("driver-a", nil), newDriver("driver-b", nil)) + ctx := t.Context() + + f.fake.PrependReactor("delete-collection", "nvidiadrivers", func(action k8stesting.Action) (bool, runtime.Object, error) { + obj, err := f.tracker.List(nvidiaDriverGVR, nvidiaDriverGVK, action.GetNamespace()) + if err != nil { + return true, nil, err + } + list, ok := obj.(*v1alpha1.NVIDIADriverList) + if !ok { + return true, nil, errors.New("unexpected list type") + } + for i := range list.Items { + if err := f.tracker.Delete(nvidiaDriverGVR, action.GetNamespace(), list.Items[i].Name); err != nil { + return true, nil, err + } + } + return true, nil, nil + }) + + require.NoError(t, f.client.DeleteCollection(ctx, metav1.DeleteOptions{}, metav1.ListOptions{})) + + remaining, err := f.client.List(ctx, metav1.ListOptions{}) + require.NoError(t, err) + assert.Empty(t, remaining.Items) +} + +func TestNVIDIADrivers_Patch(t *testing.T) { + f := newDriverFixture(t, newDriver("gpu-driver", map[string]string{"tier": "dev"})) + ctx := t.Context() + + patch := []byte(`{"metadata":{"labels":{"tier":"prod"}},"spec":{"image":"nvcr.io/nvidia/driver-patched"}}`) + patched, err := f.client.Patch(ctx, "gpu-driver", types.MergePatchType, patch, metav1.PatchOptions{}) + require.NoError(t, err) + assert.Equal(t, "nvcr.io/nvidia/driver-patched", patched.Spec.Image) + assert.Equal(t, "prod", patched.Labels["tier"]) + + reread, err := f.client.Get(ctx, "gpu-driver", metav1.GetOptions{}) + require.NoError(t, err) + assert.Equal(t, "nvcr.io/nvidia/driver-patched", reread.Spec.Image) + assert.Equal(t, "prod", reread.Labels["tier"]) + + action, ok := f.fake.Actions()[0].(k8stesting.PatchAction) + require.True(t, ok) + assert.Equal(t, "patch", action.GetVerb()) + assert.Equal(t, types.MergePatchType, action.GetPatchType()) + assert.Equal(t, "gpu-driver", action.GetName()) + assert.Equal(t, nvidiaDriverGVR, action.GetResource()) +} + +func TestNVIDIADrivers_PatchSubresource(t *testing.T) { + f := newDriverFixture(t, newDriver("gpu-driver", nil)) + ctx := t.Context() + + patch := []byte(`{"status":{"state":"ready"}}`) + patched, err := f.client.Patch(ctx, "gpu-driver", types.MergePatchType, patch, metav1.PatchOptions{}, "status") + require.NoError(t, err) + assert.Equal(t, v1alpha1.Ready, patched.Status.State) + + assert.Equal(t, "status", f.fake.Actions()[0].GetSubresource()) +} + +func TestNVIDIADrivers_PatchMissingIsNotFound(t *testing.T) { + f := newDriverFixture(t) + + _, err := f.client.Patch(t.Context(), "ghost", types.MergePatchType, []byte(`{}`), metav1.PatchOptions{}) + require.Error(t, err) + assert.True(t, apierrors.IsNotFound(err), "expected NotFound, got %v", err) +} + +func TestNVIDIADrivers_Watch(t *testing.T) { + f := newDriverFixture(t) + ctx := t.Context() + + w, err := f.client.Watch(ctx, metav1.ListOptions{}) + require.NoError(t, err) + require.NotNil(t, w) + defer w.Stop() + + created, err := f.client.Create(ctx, newDriver("gpu-driver", nil), metav1.CreateOptions{}) + require.NoError(t, err) + added := expectEvent[*v1alpha1.NVIDIADriver](t, w.ResultChan(), watch.Added) + assert.Equal(t, "gpu-driver", added.Name) + + created.Spec.Image = "nvcr.io/nvidia/driver-next" + _, err = f.client.Update(ctx, created, metav1.UpdateOptions{}) + require.NoError(t, err) + modified := expectEvent[*v1alpha1.NVIDIADriver](t, w.ResultChan(), watch.Modified) + assert.Equal(t, "nvcr.io/nvidia/driver-next", modified.Spec.Image) + + require.NoError(t, f.client.Delete(ctx, "gpu-driver", metav1.DeleteOptions{})) + deleted := expectEvent[*v1alpha1.NVIDIADriver](t, w.ResultChan(), watch.Deleted) + assert.Equal(t, "gpu-driver", deleted.Name) + + // The watch action is recorded with Watch=true and the right GVR. + watchAction, ok := f.fake.Actions()[0].(k8stesting.WatchAction) + require.True(t, ok) + assert.Equal(t, "watch", watchAction.GetVerb()) + assert.Equal(t, nvidiaDriverGVR, watchAction.GetResource()) +} + +// TestNVIDIADrivers_WatchForwardsListOptions asserts the generated client hands +// the caller's ListOptions straight through onto the recorded watch action, and +// therefore on to tracker.Watch. +// +// Nothing is asserted about replayed events: whether the tracker replays +// already-known objects, and whether it applies the label selector when it +// does, is client-go's business rather than part of the generated client's +// contract. Event delivery is covered by TestNVIDIADrivers_Watch. +func TestNVIDIADrivers_WatchForwardsListOptions(t *testing.T) { + f := newDriverFixture(t, newDriver("driver-a", map[string]string{"tier": "prod"})) + + opts := metav1.ListOptions{ResourceVersion: "0", LabelSelector: "tier=prod"} + w, err := f.client.Watch(t.Context(), opts) + require.NoError(t, err) + defer w.Stop() + + watchAction, ok := lastAction(t, f.fake).(k8stesting.WatchActionImpl) + require.True(t, ok) + assert.Equal(t, "watch", watchAction.GetVerb()) + assert.Equal(t, nvidiaDriverGVR, watchAction.GetResource()) + assert.Empty(t, watchAction.GetNamespace()) + assert.Equal(t, "0", watchAction.ListOptions.ResourceVersion) + assert.Equal(t, "tier=prod", watchAction.ListOptions.LabelSelector) + // The generated client also flips Watch on before recording the action. + assert.True(t, watchAction.ListOptions.Watch) + assert.Equal(t, "tier=prod", watchAction.GetWatchRestrictions().Labels.String()) +} + +func TestNVIDIADrivers_ActionsAreClusterScoped(t *testing.T) { + f := newDriverFixture(t, newDriver("gpu-driver", nil)) + ctx := t.Context() + + _, _ = f.client.Get(ctx, "gpu-driver", metav1.GetOptions{}) + _, _ = f.client.List(ctx, metav1.ListOptions{}) + _, _ = f.client.Create(ctx, newDriver("other", nil), metav1.CreateOptions{}) + _, _ = f.client.Update(ctx, newDriver("gpu-driver", nil), metav1.UpdateOptions{}) + _, _ = f.client.UpdateStatus(ctx, newDriver("gpu-driver", nil), metav1.UpdateOptions{}) + _, _ = f.client.Patch(ctx, "gpu-driver", types.MergePatchType, []byte(`{}`), metav1.PatchOptions{}) + _ = f.client.Delete(ctx, "gpu-driver", metav1.DeleteOptions{}) + _ = f.client.DeleteCollection(ctx, metav1.DeleteOptions{}, metav1.ListOptions{}) + w, err := f.client.Watch(ctx, metav1.ListOptions{}) + require.NoError(t, err) + w.Stop() + + actions := f.fake.Actions() + require.Len(t, actions, 9) + + wantVerbs := []string{"get", "list", "create", "update", "update", "patch", "delete", "delete-collection", "watch"} + for i, action := range actions { + assert.Equal(t, wantVerbs[i], action.GetVerb(), "action %d verb", i) + assert.Equal(t, nvidiaDriverGVR, action.GetResource(), "action %d resource", i) + // NVIDIADriver is cluster scoped, so no action ever carries a namespace. + assert.Empty(t, action.GetNamespace(), "action %d namespace", i) + assert.True(t, action.Matches(wantVerbs[i], "nvidiadrivers"), "action %d should match its own verb/resource", i) + } +} + +// TestNVIDIADrivers_ReactorChainIsHonored proves a PrependReactor short-circuits +// the tracker-backed reactor for every typed method. +func TestNVIDIADrivers_ReactorChainIsHonored(t *testing.T) { + boom := errors.New("boom") + + for _, tc := range []struct { + name string + verb string + call func(context.Context, nvidiav1alpha1.NVIDIADriverInterface) error + }{ + {"get", "get", func(ctx context.Context, c nvidiav1alpha1.NVIDIADriverInterface) error { + _, err := c.Get(ctx, "gpu-driver", metav1.GetOptions{}) + return err + }}, + {"list", "list", func(ctx context.Context, c nvidiav1alpha1.NVIDIADriverInterface) error { + _, err := c.List(ctx, metav1.ListOptions{}) + return err + }}, + {"create", "create", func(ctx context.Context, c nvidiav1alpha1.NVIDIADriverInterface) error { + _, err := c.Create(ctx, newDriver("new", nil), metav1.CreateOptions{}) + return err + }}, + {"update", "update", func(ctx context.Context, c nvidiav1alpha1.NVIDIADriverInterface) error { + _, err := c.Update(ctx, newDriver("gpu-driver", nil), metav1.UpdateOptions{}) + return err + }}, + {"update status", "update", func(ctx context.Context, c nvidiav1alpha1.NVIDIADriverInterface) error { + _, err := c.UpdateStatus(ctx, newDriver("gpu-driver", nil), metav1.UpdateOptions{}) + return err + }}, + {"patch", "patch", func(ctx context.Context, c nvidiav1alpha1.NVIDIADriverInterface) error { + _, err := c.Patch(ctx, "gpu-driver", types.MergePatchType, []byte(`{}`), metav1.PatchOptions{}) + return err + }}, + {"delete", "delete", func(ctx context.Context, c nvidiav1alpha1.NVIDIADriverInterface) error { + return c.Delete(ctx, "gpu-driver", metav1.DeleteOptions{}) + }}, + {"delete collection", "delete-collection", func(ctx context.Context, c nvidiav1alpha1.NVIDIADriverInterface) error { + return c.DeleteCollection(ctx, metav1.DeleteOptions{}, metav1.ListOptions{}) + }}, + } { + t.Run(tc.name, func(t *testing.T) { + f := newDriverFixture(t, newDriver("gpu-driver", nil)) + + var seen k8stesting.Action + f.fake.PrependReactor(tc.verb, "nvidiadrivers", func(action k8stesting.Action) (bool, runtime.Object, error) { + seen = action + return true, nil, boom + }) + + err := tc.call(t.Context(), f.client) + require.Error(t, err) + assert.ErrorIs(t, err, boom) + + require.NotNil(t, seen, "reactor should have observed the action") + assert.Equal(t, tc.verb, seen.GetVerb()) + assert.Equal(t, nvidiaDriverGVR, seen.GetResource()) + + // The object graph is untouched because the reactor never reached the tracker. + untouched, trackerErr := f.tracker.Get(nvidiaDriverGVR, "", "gpu-driver") + require.NoError(t, trackerErr) + require.NotNil(t, untouched) + }) + } +} + +// TestNVIDIADrivers_WatchReactorErrorSurfaces covers the parallel watch chain. +func TestNVIDIADrivers_WatchReactorErrorSurfaces(t *testing.T) { + f := newDriverFixture(t) + boom := errors.New("watch boom") + + f.fake.PrependWatchReactor("nvidiadrivers", func(action k8stesting.Action) (bool, watch.Interface, error) { + return true, nil, boom + }) + + w, err := f.client.Watch(t.Context(), metav1.ListOptions{}) + require.Error(t, err) + assert.ErrorIs(t, err, boom) + assert.Nil(t, w) +} + +// TestNVIDIADrivers_ReactorCanReturnASubstituteObject proves the reaction chain +// can fabricate results without any tracker involvement at all. +func TestNVIDIADrivers_ReactorCanReturnASubstituteObject(t *testing.T) { + f := newDriverFixture(t) + + substitute := newDriver("synthetic", map[string]string{"source": "reactor"}) + f.fake.PrependReactor("get", "nvidiadrivers", func(action k8stesting.Action) (bool, runtime.Object, error) { + return true, substitute, nil + }) + + got, err := f.client.Get(t.Context(), "anything", metav1.GetOptions{}) + require.NoError(t, err) + assert.Equal(t, "synthetic", got.Name) + assert.Equal(t, "reactor", got.Labels["source"]) +} diff --git a/api/versioned/typed/nvidia/v1alpha1/gpucluster_test.go b/api/versioned/typed/nvidia/v1alpha1/gpucluster_test.go new file mode 100644 index 0000000000..11d7de702f --- /dev/null +++ b/api/versioned/typed/nvidia/v1alpha1/gpucluster_test.go @@ -0,0 +1,325 @@ +/** +# Copyright (c) NVIDIA CORPORATION. All rights reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +**/ + +package v1alpha1 + +import ( + "context" + "encoding/json" + "net/http" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + types "k8s.io/apimachinery/pkg/types" + watch "k8s.io/apimachinery/pkg/watch" + + nvidiav1alpha1 "github.com/NVIDIA/gpu-operator/api/nvidia/v1alpha1" +) + +// gpuClusterResource is the plural resource name passed to newGPUClusters. +const gpuClusterResource = "gpuclusters" + +// gpuClusterSingletonName is the only name the CRD's CEL rule admits. No CEL +// validation runs at this client layer, but the fixtures use the real name. +const gpuClusterSingletonName = "gpu-cluster" + +func gpuClusterCollectionPath() string { + return collectionPath(gpuClusterResource) +} + +func gpuClusterNamedPath(name string, subresources ...string) string { + return namedPath(gpuClusterResource, name, subresources...) +} + +// gpuClusterNewServer stands up a recording API server and returns the typed +// GPUCluster client wired to it. +func gpuClusterNewServer(t *testing.T, handler http.HandlerFunc) (*recordingServer, GPUClusterInterface) { + t.Helper() + + ts, client := newRecordingServer(t, handler) + return ts, client.GPUClusters() +} + +// gpuCluster builds a fully typed GPUCluster, including the TypeMeta the +// client-side decoder needs to recognize the payload. +func gpuCluster(name string) *nvidiav1alpha1.GPUCluster { + gv := nvidiav1alpha1.SchemeGroupVersion + return &nvidiav1alpha1.GPUCluster{ + TypeMeta: metav1.TypeMeta{ + APIVersion: gv.String(), + Kind: nvidiav1alpha1.GPUClusterCRDName, + }, + ObjectMeta: metav1.ObjectMeta{ + Name: name, + ResourceVersion: "7", + Labels: map[string]string{"app": "gpu-cluster"}, + }, + Spec: nvidiav1alpha1.GPUClusterSpec{ + DRADriver: nvidiav1alpha1.DRADriverSpec{ + Repository: "nvcr.io/nvidia/cloud-native", + Image: "k8s-dra-driver-gpu", + Version: "v25.3.0", + FeatureGates: map[string]bool{"ComputeDomains": true}, + }, + }, + } +} + +// gpuClusterList builds a decodable list payload holding items. +func gpuClusterList(items ...*nvidiav1alpha1.GPUCluster) *nvidiav1alpha1.GPUClusterList { + gv := nvidiav1alpha1.SchemeGroupVersion + list := &nvidiav1alpha1.GPUClusterList{ + TypeMeta: metav1.TypeMeta{APIVersion: gv.String(), Kind: "GPUClusterList"}, + } + for _, item := range items { + list.Items = append(list.Items, *item) + } + return list +} + +// TestGPUClusterHTTPMatrix runs the shared verb/HTTP plumbing matrix against +// the generated GPUCluster client. Everything that depends on the GPUCluster +// schema lives in the focused tests below. +func TestGPUClusterHTTPMatrix(t *testing.T) { + matrix := resourceMatrix[*nvidiav1alpha1.GPUCluster, *nvidiav1alpha1.GPUClusterList]{ + resource: gpuClusterResource, + labelSelector: "app=gpu-cluster", + fieldSelector: "metadata.name=gpu-cluster", + + newObject: gpuCluster, + newEmpty: func() *nvidiav1alpha1.GPUCluster { return &nvidiav1alpha1.GPUCluster{} }, + newList: gpuClusterList, + listItems: func(list *nvidiav1alpha1.GPUClusterList) []*nvidiav1alpha1.GPUCluster { + items := make([]*nvidiav1alpha1.GPUCluster, 0, len(list.Items)) + for i := range list.Items { + items = append(items, &list.Items[i]) + } + return items + }, + + get: func(ctx context.Context, c *NvidiaV1alpha1Client, name string, opts metav1.GetOptions) (*nvidiav1alpha1.GPUCluster, error) { + return c.GPUClusters().Get(ctx, name, opts) + }, + list: func(ctx context.Context, c *NvidiaV1alpha1Client, opts metav1.ListOptions) (*nvidiav1alpha1.GPUClusterList, error) { + return c.GPUClusters().List(ctx, opts) + }, + create: func(ctx context.Context, c *NvidiaV1alpha1Client, obj *nvidiav1alpha1.GPUCluster, opts metav1.CreateOptions) (*nvidiav1alpha1.GPUCluster, error) { + return c.GPUClusters().Create(ctx, obj, opts) + }, + update: func(ctx context.Context, c *NvidiaV1alpha1Client, obj *nvidiav1alpha1.GPUCluster, opts metav1.UpdateOptions) (*nvidiav1alpha1.GPUCluster, error) { + return c.GPUClusters().Update(ctx, obj, opts) + }, + updateStatus: func(ctx context.Context, c *NvidiaV1alpha1Client, obj *nvidiav1alpha1.GPUCluster, opts metav1.UpdateOptions) (*nvidiav1alpha1.GPUCluster, error) { + return c.GPUClusters().UpdateStatus(ctx, obj, opts) + }, + remove: func(ctx context.Context, c *NvidiaV1alpha1Client, name string, opts metav1.DeleteOptions) error { + return c.GPUClusters().Delete(ctx, name, opts) + }, + removeCollection: func(ctx context.Context, c *NvidiaV1alpha1Client, opts metav1.DeleteOptions, listOpts metav1.ListOptions) error { + return c.GPUClusters().DeleteCollection(ctx, opts, listOpts) + }, + patch: func(ctx context.Context, c *NvidiaV1alpha1Client, name string, pt types.PatchType, data []byte, opts metav1.PatchOptions, subresources ...string) (*nvidiav1alpha1.GPUCluster, error) { + return c.GPUClusters().Patch(ctx, name, pt, data, opts, subresources...) + }, + watch: func(ctx context.Context, c *NvidiaV1alpha1Client, opts metav1.ListOptions) (watch.Interface, error) { + return c.GPUClusters().Watch(ctx, opts) + }, + } + + matrix.run(t) +} + +func TestGPUClustersRequestPaths(t *testing.T) { + // Spelled out literally: the shared matrix derives its expectations from + // the same helpers the client uses, so this pins the actual URLs. + assert.Equal(t, "/apis/nvidia.com/v1alpha1/gpuclusters", gpuClusterCollectionPath()) + assert.Equal(t, "/apis/nvidia.com/v1alpha1/gpuclusters/gpu-cluster", gpuClusterNamedPath(gpuClusterSingletonName)) + assert.Equal(t, "/apis/nvidia.com/v1alpha1/gpuclusters/gpu-cluster/status", gpuClusterNamedPath(gpuClusterSingletonName, "status")) + + ts, client := gpuClusterNewServer(t, func(w http.ResponseWriter, _ *http.Request) { + writeJSONResponse(w, http.StatusOK, gpuCluster(gpuClusterSingletonName)) + }) + + _, err := client.Get(t.Context(), gpuClusterSingletonName, metav1.GetOptions{}) + require.NoError(t, err) + + req := ts.lastRequest(t) + assert.Equal(t, "/apis/nvidia.com/v1alpha1/gpuclusters/gpu-cluster", req.Path) + assertClusterScoped(t, req.Path) +} + +func TestGPUClustersGroupVersionKind(t *testing.T) { + gvk := gpuCluster(gpuClusterSingletonName).GroupVersionKind() + assert.Equal(t, "nvidia.com", gvk.Group) + assert.Equal(t, "v1alpha1", gvk.Version) + assert.Equal(t, nvidiav1alpha1.GPUClusterCRDName, gvk.Kind) + assert.Equal(t, "GPUClusterList", gpuClusterList().GroupVersionKind().Kind) +} + +func TestGPUClustersGetDecodesSpec(t *testing.T) { + want := gpuCluster(gpuClusterSingletonName) + + _, client := gpuClusterNewServer(t, func(w http.ResponseWriter, _ *http.Request) { + writeJSONResponse(w, http.StatusOK, want) + }) + + got, err := client.Get(t.Context(), gpuClusterSingletonName, metav1.GetOptions{}) + require.NoError(t, err) + + require.NotNil(t, got) + assert.Equal(t, gpuClusterSingletonName, got.Name) + assert.Equal(t, "7", got.ResourceVersion) + assert.Equal(t, "nvcr.io/nvidia/cloud-native", got.Spec.DRADriver.Repository) + assert.Equal(t, "k8s-dra-driver-gpu", got.Spec.DRADriver.Image) + assert.Equal(t, "v25.3.0", got.Spec.DRADriver.Version) + assert.Equal(t, map[string]bool{"ComputeDomains": true}, got.Spec.DRADriver.FeatureGates) +} + +func TestGPUClustersListDecodesItems(t *testing.T) { + // The CRD restricts GPUCluster to a singleton name, but no CEL validation + // runs at this layer, so a multi-item list still exercises the decoder. + want := gpuClusterList(gpuCluster(gpuClusterSingletonName), gpuCluster("gpu-cluster-legacy")) + want.ResourceVersion = "512" + want.Continue = "next-token" + + _, client := gpuClusterNewServer(t, func(w http.ResponseWriter, _ *http.Request) { + writeJSONResponse(w, http.StatusOK, want) + }) + + got, err := client.List(t.Context(), metav1.ListOptions{}) + require.NoError(t, err) + + require.Len(t, got.Items, 2) + assert.Equal(t, gpuClusterSingletonName, got.Items[0].Name) + assert.Equal(t, "gpu-cluster-legacy", got.Items[1].Name) + assert.Equal(t, "v25.3.0", got.Items[0].Spec.DRADriver.Version) + assert.Equal(t, "512", got.ResourceVersion) + assert.Equal(t, "next-token", got.Continue) +} + +func TestGPUClustersCreateSendsSpec(t *testing.T) { + in := gpuCluster(gpuClusterSingletonName) + + ts, client := gpuClusterNewServer(t, func(w http.ResponseWriter, _ *http.Request) { + writeJSONResponse(w, http.StatusCreated, in) + }) + + got, err := client.Create(t.Context(), in, metav1.CreateOptions{}) + require.NoError(t, err) + + // The spec must round-trip through the request body. + var sent nvidiav1alpha1.GPUCluster + require.NoError(t, json.Unmarshal(ts.lastRequest(t).Body, &sent)) + assert.Equal(t, gpuClusterSingletonName, sent.Name) + assert.Equal(t, in.Spec.DRADriver.Repository, sent.Spec.DRADriver.Repository) + assert.Equal(t, in.Spec.DRADriver.Image, sent.Spec.DRADriver.Image) + assert.Equal(t, in.Spec.DRADriver.Version, sent.Spec.DRADriver.Version) + assert.Equal(t, map[string]bool{"ComputeDomains": true}, sent.Spec.DRADriver.FeatureGates) + assert.Equal(t, map[string]string{"app": "gpu-cluster"}, sent.Labels) + + assert.Equal(t, gpuClusterSingletonName, got.Name) +} + +func TestGPUClustersUpdateSendsSpec(t *testing.T) { + in := gpuCluster(gpuClusterSingletonName) + in.Spec.DRADriver.Version = "v25.8.0" + + ts, client := gpuClusterNewServer(t, func(w http.ResponseWriter, _ *http.Request) { + writeJSONResponse(w, http.StatusOK, in) + }) + + got, err := client.Update(t.Context(), in, metav1.UpdateOptions{}) + require.NoError(t, err) + + req := ts.lastRequest(t) + assert.Equal(t, gpuClusterNamedPath(gpuClusterSingletonName), req.Path) + assert.NotContains(t, req.Path, "/status") + + var sent nvidiav1alpha1.GPUCluster + require.NoError(t, json.Unmarshal(req.Body, &sent)) + assert.Equal(t, "v25.8.0", sent.Spec.DRADriver.Version) + + assert.Equal(t, "v25.8.0", got.Spec.DRADriver.Version) +} + +func TestGPUClustersUpdateStatusSendsStatus(t *testing.T) { + in := gpuCluster(gpuClusterSingletonName) + in.Status.State = nvidiav1alpha1.Ready + in.Status.Namespace = "gpu-operator" + + ts, client := gpuClusterNewServer(t, func(w http.ResponseWriter, _ *http.Request) { + writeJSONResponse(w, http.StatusOK, in) + }) + + got, err := client.UpdateStatus(t.Context(), in, metav1.UpdateOptions{}) + require.NoError(t, err) + + req := ts.lastRequest(t) + assert.Equal(t, gpuClusterNamedPath(gpuClusterSingletonName, "status"), req.Path) + assert.True(t, strings.HasSuffix(req.Path, "/status"), "UpdateStatus must target the status subresource") + + var sent nvidiav1alpha1.GPUCluster + require.NoError(t, json.Unmarshal(req.Body, &sent)) + assert.Equal(t, nvidiav1alpha1.Ready, sent.Status.State) + assert.Equal(t, "gpu-operator", sent.Status.Namespace) + + assert.Equal(t, nvidiav1alpha1.Ready, got.Status.State) + assert.Equal(t, "gpu-operator", got.Status.Namespace) +} + +func TestGPUClustersPatchDecodesSpec(t *testing.T) { + result := gpuCluster(gpuClusterSingletonName) + result.Spec.DRADriver.Version = "v25.8.0" + + ts, client := gpuClusterNewServer(t, func(w http.ResponseWriter, _ *http.Request) { + writeJSONResponse(w, http.StatusOK, result) + }) + + patch := []byte(`{"spec":{"draDriver":{"version":"v25.8.0"}}}`) + got, err := client.Patch(t.Context(), gpuClusterSingletonName, types.MergePatchType, patch, metav1.PatchOptions{}) + require.NoError(t, err) + + assert.Equal(t, patch, ts.lastRequest(t).Body) + assert.Equal(t, "v25.8.0", got.Spec.DRADriver.Version) +} + +func TestGPUClustersWatchDecodesObject(t *testing.T) { + modified := gpuCluster(gpuClusterSingletonName) + modified.Status.State = nvidiav1alpha1.NotReady + // The frame is marshalled here, on the test goroutine, so the handler below + // carries no assertions. + frame := marshalWatchFrame(t, watch.Modified, modified) + + _, client := gpuClusterNewServer(t, func(w http.ResponseWriter, _ *http.Request) { + writeWatchFrames(w, frame) + }) + + watcher, err := client.Watch(t.Context(), metav1.ListOptions{}) + require.NoError(t, err) + defer watcher.Stop() + + event := receiveEvent(t, watcher.ResultChan()) + assert.Equal(t, watch.Modified, event.Type) + obj, ok := event.Object.(*nvidiav1alpha1.GPUCluster) + require.True(t, ok, "expected a *GPUCluster, got %T", event.Object) + assert.Equal(t, gpuClusterSingletonName, obj.Name) + assert.Equal(t, nvidiav1alpha1.NotReady, obj.Status.State) + assert.Equal(t, "v25.3.0", obj.Spec.DRADriver.Version) +} diff --git a/api/versioned/typed/nvidia/v1alpha1/helpers_test.go b/api/versioned/typed/nvidia/v1alpha1/helpers_test.go new file mode 100644 index 0000000000..26c1f5f191 --- /dev/null +++ b/api/versioned/typed/nvidia/v1alpha1/helpers_test.go @@ -0,0 +1,798 @@ +/** +# Copyright (c) NVIDIA CORPORATION. All rights reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +**/ + +package v1alpha1 + +import ( + "context" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "net/url" + "path" + "strings" + "sync" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + apierrors "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + runtime "k8s.io/apimachinery/pkg/runtime" + types "k8s.io/apimachinery/pkg/types" + watch "k8s.io/apimachinery/pkg/watch" + rest "k8s.io/client-go/rest" + + nvidiav1alpha1 "github.com/NVIDIA/gpu-operator/api/nvidia/v1alpha1" +) + +// Shared test infrastructure for the typed clients in this package. Both the +// NVIDIADriver and GPUCluster suites drive a real HTTP API server through a +// real REST client, so these helpers stay resource agnostic. + +// eventTimeout bounds every watch-channel read in this package. The API server +// stub, the REST client and the test all run in the same process, so a frame +// that is going to arrive arrives in microseconds; a longer bound would only +// make a regression fail slowly. +const eventTimeout = 2 * time.Second + +// collectionPath builds the cluster-scoped collection path for a resource, +// derived from the registered group version rather than hardcoded. +func collectionPath(resource string) string { + gv := nvidiav1alpha1.SchemeGroupVersion + return path.Join("/apis", gv.Group, gv.Version, resource) +} + +// namedPath builds the path for a single named object, optionally below one or +// more subresources. +func namedPath(resource, name string, subresources ...string) string { + return path.Join(append([]string{collectionPath(resource), name}, subresources...)...) +} + +// recordedRequest is a snapshot of a request observed by the test server. +type recordedRequest struct { + Method string + Path string + Query url.Values + Header http.Header + Body []byte +} + +// recordingServer is an httptest.Server that records every request it serves. +// Access to the recorded requests is mutex guarded because the handler runs on +// the server's goroutine while assertions run on the test's. +type recordingServer struct { + *httptest.Server + + mu sync.Mutex + requests []recordedRequest +} + +// newRecordingServer stands up a real HTTP API server running handler, plus a +// typed group client wired to it through NewForConfig. +func newRecordingServer(t *testing.T, handler http.HandlerFunc) (*recordingServer, *NvidiaV1alpha1Client) { + t.Helper() + + ts := &recordingServer{} + ts.Server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + body, err := io.ReadAll(r.Body) + if err != nil { + body = nil + } + + ts.mu.Lock() + ts.requests = append(ts.requests, recordedRequest{ + Method: r.Method, + Path: r.URL.Path, + Query: r.URL.Query(), + Header: r.Header.Clone(), + Body: body, + }) + ts.mu.Unlock() + + handler(w, r) + })) + t.Cleanup(ts.Close) + + client, err := NewForConfig(&rest.Config{Host: ts.URL}) + require.NoError(t, err) + + return ts, client +} + +// lastRequest returns the single request the server saw, failing if the count +// is not exactly one. +func (ts *recordingServer) lastRequest(t *testing.T) recordedRequest { + t.Helper() + + ts.mu.Lock() + defer ts.mu.Unlock() + + require.Len(t, ts.requests, 1, "expected exactly one request to the API server") + return ts.requests[0] +} + +// writeJSONResponse serializes v as the response body with a JSON content type. +// +// It deliberately takes no *testing.T and asserts nothing: it runs on the +// httptest server's goroutine, and testify's require calls t.FailNow, which +// the testing package only permits from the goroutine running the test. A +// marshal failure is surfaced to the client as a 500 so the assertion fails in +// the test goroutine instead. +func writeJSONResponse(w http.ResponseWriter, statusCode int, v interface{}) { + data, err := json.Marshal(v) + if err != nil { + http.Error(w, "test handler: marshal failed: "+err.Error(), http.StatusInternalServerError) + return + } + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(statusCode) + _, _ = w.Write(data) +} + +// successStatus is the metav1.Status an API server returns for a successful +// delete. +func successStatus() *metav1.Status { + return &metav1.Status{ + TypeMeta: metav1.TypeMeta{APIVersion: "v1", Kind: "Status"}, + Status: metav1.StatusSuccess, + } +} + +// notFoundStatus is the metav1.Status an API server returns for a missing +// object, shaped so that errors.IsNotFound recognizes it. +func notFoundStatus(resource, name string) *metav1.Status { + return &metav1.Status{ + TypeMeta: metav1.TypeMeta{APIVersion: "v1", Kind: "Status"}, + Status: metav1.StatusFailure, + Code: http.StatusNotFound, + Reason: metav1.StatusReasonNotFound, + Message: resource + "." + nvidiav1alpha1.SchemeGroupVersion.Group + ` "` + name + `" not found`, + Details: &metav1.StatusDetails{ + Name: name, + Group: nvidiav1alpha1.SchemeGroupVersion.Group, + Kind: resource, + }, + } +} + +// writeEmptyWatchStream returns an immediately-terminated watch stream. +func writeEmptyWatchStream(w http.ResponseWriter) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + if flusher, ok := w.(http.Flusher); ok { + flusher.Flush() + } +} + +// writeWatchFrames writes already-marshalled watch frames. The frames must be +// marshalled on the test goroutine: this runs on the server's goroutine, where +// no assertion helper may be called. +func writeWatchFrames(w http.ResponseWriter, frames []byte) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + _, _ = w.Write(frames) + if flusher, ok := w.(http.Flusher); ok { + flusher.Flush() + } +} + +// marshalWatchFrame encodes obj as a single watch frame of the given type. It +// runs on the test goroutine so the server handler stays assertion free. +func marshalWatchFrame(t *testing.T, eventType watch.EventType, obj interface{}) []byte { + t.Helper() + + raw, err := json.Marshal(obj) + require.NoError(t, err) + + frame, err := json.Marshal(metav1.WatchEvent{ + Type: string(eventType), + Object: runtime.RawExtension{Raw: raw}, + }) + require.NoError(t, err) + + return frame +} + +// receiveEvent is the bounded read every watch assertion uses instead of an +// unbounded receive. +func receiveEvent(t *testing.T, ch <-chan watch.Event) watch.Event { + t.Helper() + timer := time.NewTimer(eventTimeout) + defer timer.Stop() + select { + case event, ok := <-ch: + require.True(t, ok, "watch channel closed unexpectedly") + return event + case <-timer.C: + t.Fatal("timed out waiting for watch event") + return watch.Event{} + } +} + +// requireWatchClosed is the mirror of receiveEvent for streams that must end +// rather than deliver: same bound, opposite expectation. +func requireWatchClosed(t *testing.T, ch <-chan watch.Event) { + t.Helper() + timer := time.NewTimer(eventTimeout) + defer timer.Stop() + select { + case event, ok := <-ch: + assert.False(t, ok, "expected the result channel to be closed, got event %v", event) + case <-timer.C: + t.Fatal("timed out waiting for the watch stream to close") + } +} + +// assertClusterScoped guards the empty namespace passed to the generated +// constructors: cluster-scoped resources never carry a /namespaces/ segment. +func assertClusterScoped(t *testing.T, requestPath string) { + t.Helper() + assert.NotContains(t, requestPath, "/namespaces/", "resource is cluster scoped") +} + +// assertGroupVersionPrefix checks the request targets this group version's +// collection. +func assertGroupVersionPrefix(t *testing.T, requestPath, resource string) { + t.Helper() + base := collectionPath(resource) + assert.True(t, + requestPath == base || strings.HasPrefix(requestPath, base+"/"), + "unexpected path %q for resource %q", requestPath, resource, + ) +} + +// ----------------------------------------------------------------------------- +// Shared verb/HTTP matrix +// ----------------------------------------------------------------------------- + +// Every typed client in this package is a thin wrapper around the same +// gentype.ClientWithList, so the HTTP plumbing under each verb (method, path, +// query parameters, content type, request body, cluster scope) is identical +// across resources. resourceMatrix drives that plumbing once per resource; +// anything that depends on a resource's own schema stays in a focused test in +// that resource's file. + +// matrixObject is the constraint for the typed object a resource client +// returns: the generated types are runtime.Objects with an ObjectMeta. +type matrixObject interface { + metav1.Object + runtime.Object +} + +// matrixList is the constraint for the typed list a resource client returns. +type matrixList interface { + metav1.ListInterface + runtime.Object +} + +// resourceMatrix describes the one generated typed client under test. Only the +// fields here differ between resources; everything else is shared. +type resourceMatrix[T matrixObject, L matrixList] struct { + // resource is the plural resource name the generated constructor passes to + // gentype, and therefore the last path segment of the collection URL. + resource string + + // labelSelector and fieldSelector are realistic selectors for this + // resource; their only job is to prove option propagation. + labelSelector string + fieldSelector string + + // newObject builds a fully populated fixture, including the TypeMeta the + // client-side decoder needs. newEmpty allocates a zero-valued object for + // decoding recorded request bodies. newList builds a decodable list. + newObject func(name string) T + newEmpty func() T + newList func(items ...T) L + listItems func(list L) []T + + // One field per verb under test, bound to the resource's typed methods. + get func(ctx context.Context, c *NvidiaV1alpha1Client, name string, opts metav1.GetOptions) (T, error) + list func(ctx context.Context, c *NvidiaV1alpha1Client, opts metav1.ListOptions) (L, error) + create func(ctx context.Context, c *NvidiaV1alpha1Client, obj T, opts metav1.CreateOptions) (T, error) + update func(ctx context.Context, c *NvidiaV1alpha1Client, obj T, opts metav1.UpdateOptions) (T, error) + updateStatus func(ctx context.Context, c *NvidiaV1alpha1Client, obj T, opts metav1.UpdateOptions) (T, error) + remove func(ctx context.Context, c *NvidiaV1alpha1Client, name string, opts metav1.DeleteOptions) error + removeCollection func(ctx context.Context, c *NvidiaV1alpha1Client, opts metav1.DeleteOptions, listOpts metav1.ListOptions) error + patch func(ctx context.Context, c *NvidiaV1alpha1Client, name string, pt types.PatchType, data []byte, opts metav1.PatchOptions, subresources ...string) (T, error) + watch func(ctx context.Context, c *NvidiaV1alpha1Client, opts metav1.ListOptions) (watch.Interface, error) +} + +func (m resourceMatrix[T, L]) collection() string { + return collectionPath(m.resource) +} + +func (m resourceMatrix[T, L]) named(name string, subresources ...string) string { + return namedPath(m.resource, name, subresources...) +} + +// decodeBody decodes a recorded request body into a freshly allocated object. +func (m resourceMatrix[T, L]) decodeBody(t *testing.T, body []byte) T { + t.Helper() + + obj := m.newEmpty() + require.NoError(t, json.Unmarshal(body, obj)) + return obj +} + +// run executes the whole matrix. Subtest names carry the resource so a failure +// names the resource that broke. +func (m resourceMatrix[T, L]) run(t *testing.T) { + t.Helper() + + for _, tc := range []struct { + verb string + fn func(*testing.T) + }{ + {"get", m.testGet}, + {"get_propagates_resource_version", m.testGetPropagatesResourceVersion}, + {"get_not_found", m.testGetNotFound}, + {"list", m.testList}, + {"list_options_become_query_params", m.testListOptionsBecomeQueryParams}, + {"list_timeout_seconds", m.testListTimeoutSeconds}, + {"create", m.testCreate}, + {"update", m.testUpdate}, + {"update_status", m.testUpdateStatus}, + {"delete", m.testDelete}, + {"delete_error", m.testDeleteError}, + {"delete_collection", m.testDeleteCollection}, + {"patch", m.testPatch}, + {"watch", m.testWatch}, + {"watch_delivers_events", m.testWatchDeliversEvents}, + {"no_namespace_segment_anywhere", m.testNoNamespaceSegmentAnywhere}, + } { + t.Run(m.resource+"/"+tc.verb, tc.fn) + } +} + +func (m resourceMatrix[T, L]) testGet(t *testing.T) { + want := m.newObject("fixture-one") + + ts, client := newRecordingServer(t, func(w http.ResponseWriter, _ *http.Request) { + writeJSONResponse(w, http.StatusOK, want) + }) + + got, err := m.get(t.Context(), client, "fixture-one", metav1.GetOptions{}) + require.NoError(t, err) + + req := ts.lastRequest(t) + assert.Equal(t, http.MethodGet, req.Method) + assert.Equal(t, m.named("fixture-one"), req.Path) + assertClusterScoped(t, req.Path) + assertGroupVersionPrefix(t, req.Path, m.resource) + + require.NotNil(t, got) + assert.Equal(t, "fixture-one", got.GetName()) + assert.Equal(t, want.GetResourceVersion(), got.GetResourceVersion()) + assert.Equal(t, want.GetLabels(), got.GetLabels()) +} + +func (m resourceMatrix[T, L]) testGetPropagatesResourceVersion(t *testing.T) { + ts, client := newRecordingServer(t, func(w http.ResponseWriter, _ *http.Request) { + writeJSONResponse(w, http.StatusOK, m.newObject("fixture-one")) + }) + + _, err := m.get(t.Context(), client, "fixture-one", metav1.GetOptions{ResourceVersion: "0"}) + require.NoError(t, err) + + assert.Equal(t, "0", ts.lastRequest(t).Query.Get("resourceVersion")) +} + +func (m resourceMatrix[T, L]) testGetNotFound(t *testing.T) { + ts, client := newRecordingServer(t, func(w http.ResponseWriter, _ *http.Request) { + writeJSONResponse(w, http.StatusNotFound, notFoundStatus(m.resource, "missing")) + }) + + got, err := m.get(t.Context(), client, "missing", metav1.GetOptions{}) + require.Error(t, err) + assert.True(t, apierrors.IsNotFound(err), "expected a NotFound error, got %v", err) + // The generated client returns a freshly allocated (empty) object on error. + require.NotNil(t, got) + assert.Empty(t, got.GetName()) + + assert.Equal(t, m.named("missing"), ts.lastRequest(t).Path) +} + +func (m resourceMatrix[T, L]) testList(t *testing.T) { + want := m.newList(m.newObject("fixture-one"), m.newObject("fixture-two")) + want.SetResourceVersion("99") + want.SetContinue("next-token") + + ts, client := newRecordingServer(t, func(w http.ResponseWriter, _ *http.Request) { + writeJSONResponse(w, http.StatusOK, want) + }) + + got, err := m.list(t.Context(), client, metav1.ListOptions{}) + require.NoError(t, err) + + req := ts.lastRequest(t) + assert.Equal(t, http.MethodGet, req.Method) + assert.Equal(t, m.collection(), req.Path) + assertClusterScoped(t, req.Path) + + items := m.listItems(got) + require.Len(t, items, 2) + assert.Equal(t, "fixture-one", items[0].GetName()) + assert.Equal(t, "fixture-two", items[1].GetName()) + assert.Equal(t, "99", got.GetResourceVersion()) + assert.Equal(t, "next-token", got.GetContinue()) +} + +func (m resourceMatrix[T, L]) testListOptionsBecomeQueryParams(t *testing.T) { + tests := []struct { + name string + opts metav1.ListOptions + query map[string]string + }{ + { + name: "label selector", + opts: metav1.ListOptions{LabelSelector: m.labelSelector}, + query: map[string]string{"labelSelector": m.labelSelector}, + }, + { + name: "field selector", + opts: metav1.ListOptions{FieldSelector: m.fieldSelector}, + query: map[string]string{"fieldSelector": m.fieldSelector}, + }, + { + name: "resource version", + opts: metav1.ListOptions{ResourceVersion: "1234"}, + query: map[string]string{"resourceVersion": "1234"}, + }, + { + name: "limit and continue", + opts: metav1.ListOptions{Limit: 50, Continue: "abc"}, + query: map[string]string{"limit": "50", "continue": "abc"}, + }, + { + name: "all selectors together", + opts: metav1.ListOptions{ + LabelSelector: m.labelSelector, + FieldSelector: m.fieldSelector, + ResourceVersion: "7", + Limit: 10, + }, + query: map[string]string{ + "labelSelector": m.labelSelector, + "fieldSelector": m.fieldSelector, + "resourceVersion": "7", + "limit": "10", + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ts, client := newRecordingServer(t, func(w http.ResponseWriter, _ *http.Request) { + writeJSONResponse(w, http.StatusOK, m.newList()) + }) + + _, err := m.list(t.Context(), client, tt.opts) + require.NoError(t, err) + + req := ts.lastRequest(t) + assert.Equal(t, m.collection(), req.Path) + for key, want := range tt.query { + assert.Equal(t, want, req.Query.Get(key), "query param %q", key) + } + }) + } +} + +func (m resourceMatrix[T, L]) testListTimeoutSeconds(t *testing.T) { + timeoutSeconds := int64(17) + + ts, client := newRecordingServer(t, func(w http.ResponseWriter, _ *http.Request) { + writeJSONResponse(w, http.StatusOK, m.newList()) + }) + + _, err := m.list(t.Context(), client, metav1.ListOptions{TimeoutSeconds: &timeoutSeconds}) + require.NoError(t, err) + + assert.Equal(t, "17", ts.lastRequest(t).Query.Get("timeoutSeconds")) +} + +func (m resourceMatrix[T, L]) testCreate(t *testing.T) { + in := m.newObject("fixture-created") + + ts, client := newRecordingServer(t, func(w http.ResponseWriter, _ *http.Request) { + writeJSONResponse(w, http.StatusCreated, in) + }) + + got, err := m.create(t.Context(), client, in, metav1.CreateOptions{FieldManager: "gpu-operator"}) + require.NoError(t, err) + + req := ts.lastRequest(t) + assert.Equal(t, http.MethodPost, req.Method) + assert.Equal(t, m.collection(), req.Path) + assertClusterScoped(t, req.Path) + assert.Equal(t, "gpu-operator", req.Query.Get("fieldManager")) + assert.Equal(t, "application/json", req.Header.Get("Content-Type")) + + // The object must round-trip through the request body. + sent := m.decodeBody(t, req.Body) + assert.Equal(t, "fixture-created", sent.GetName()) + assert.Equal(t, in.GetLabels(), sent.GetLabels()) + + assert.Equal(t, "fixture-created", got.GetName()) +} + +func (m resourceMatrix[T, L]) testUpdate(t *testing.T) { + in := m.newObject("fixture-existing") + + ts, client := newRecordingServer(t, func(w http.ResponseWriter, _ *http.Request) { + writeJSONResponse(w, http.StatusOK, in) + }) + + got, err := m.update(t.Context(), client, in, metav1.UpdateOptions{}) + require.NoError(t, err) + + req := ts.lastRequest(t) + assert.Equal(t, http.MethodPut, req.Method) + assert.Equal(t, m.named("fixture-existing"), req.Path) + assert.NotContains(t, req.Path, "/status") + assertClusterScoped(t, req.Path) + + sent := m.decodeBody(t, req.Body) + assert.Equal(t, "fixture-existing", sent.GetName()) + + assert.Equal(t, "fixture-existing", got.GetName()) +} + +func (m resourceMatrix[T, L]) testUpdateStatus(t *testing.T) { + in := m.newObject("fixture-existing") + + ts, client := newRecordingServer(t, func(w http.ResponseWriter, _ *http.Request) { + writeJSONResponse(w, http.StatusOK, in) + }) + + got, err := m.updateStatus(t.Context(), client, in, metav1.UpdateOptions{}) + require.NoError(t, err) + + req := ts.lastRequest(t) + assert.Equal(t, http.MethodPut, req.Method) + assert.Equal(t, m.named("fixture-existing", "status"), req.Path) + assert.True(t, strings.HasSuffix(req.Path, "/status")) + assertClusterScoped(t, req.Path) + + sent := m.decodeBody(t, req.Body) + assert.Equal(t, "fixture-existing", sent.GetName()) + + assert.Equal(t, "fixture-existing", got.GetName()) +} + +func (m resourceMatrix[T, L]) testDelete(t *testing.T) { + for _, policy := range []metav1.DeletionPropagation{ + metav1.DeletePropagationForeground, + metav1.DeletePropagationBackground, + } { + t.Run(string(policy), func(t *testing.T) { + ts, client := newRecordingServer(t, func(w http.ResponseWriter, _ *http.Request) { + writeJSONResponse(w, http.StatusOK, successStatus()) + }) + + err := m.remove(t.Context(), client, "fixture-doomed", metav1.DeleteOptions{PropagationPolicy: &policy}) + require.NoError(t, err) + + req := ts.lastRequest(t) + assert.Equal(t, http.MethodDelete, req.Method) + assert.Equal(t, m.named("fixture-doomed"), req.Path) + assertClusterScoped(t, req.Path) + + var sent metav1.DeleteOptions + require.NoError(t, json.Unmarshal(req.Body, &sent)) + require.NotNil(t, sent.PropagationPolicy) + assert.Equal(t, policy, *sent.PropagationPolicy) + }) + } +} + +func (m resourceMatrix[T, L]) testDeleteError(t *testing.T) { + _, client := newRecordingServer(t, func(w http.ResponseWriter, _ *http.Request) { + writeJSONResponse(w, http.StatusNotFound, notFoundStatus(m.resource, "missing")) + }) + + err := m.remove(t.Context(), client, "missing", metav1.DeleteOptions{}) + require.Error(t, err) + assert.True(t, apierrors.IsNotFound(err)) +} + +func (m resourceMatrix[T, L]) testDeleteCollection(t *testing.T) { + ts, client := newRecordingServer(t, func(w http.ResponseWriter, _ *http.Request) { + writeJSONResponse(w, http.StatusOK, successStatus()) + }) + + gracePeriod := int64(30) + err := m.removeCollection( + t.Context(), + client, + metav1.DeleteOptions{GracePeriodSeconds: &gracePeriod}, + metav1.ListOptions{LabelSelector: m.labelSelector, FieldSelector: m.fieldSelector}, + ) + require.NoError(t, err) + + req := ts.lastRequest(t) + assert.Equal(t, http.MethodDelete, req.Method) + assert.Equal(t, m.collection(), req.Path) + assertClusterScoped(t, req.Path) + assert.Equal(t, m.labelSelector, req.Query.Get("labelSelector")) + assert.Equal(t, m.fieldSelector, req.Query.Get("fieldSelector")) + + var sent metav1.DeleteOptions + require.NoError(t, json.Unmarshal(req.Body, &sent)) + require.NotNil(t, sent.GracePeriodSeconds) + assert.Equal(t, int64(30), *sent.GracePeriodSeconds) +} + +func (m resourceMatrix[T, L]) testPatch(t *testing.T) { + tests := []struct { + name string + patchType types.PatchType + expectedContentType string + subresources []string + }{ + { + name: "merge patch on the resource", + patchType: types.MergePatchType, + expectedContentType: "application/merge-patch+json", + }, + { + name: "json patch on the resource", + patchType: types.JSONPatchType, + expectedContentType: "application/json-patch+json", + }, + { + name: "strategic merge patch on the resource", + patchType: types.StrategicMergePatchType, + expectedContentType: "application/strategic-merge-patch+json", + }, + { + name: "apply patch on the resource", + patchType: types.ApplyPatchType, + expectedContentType: "application/apply-patch+yaml", + }, + { + name: "merge patch on the status subresource", + patchType: types.MergePatchType, + expectedContentType: "application/merge-patch+json", + subresources: []string{"status"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + patch := []byte(`{"metadata":{"labels":{"patched":"true"}}}`) + result := m.newObject("fixture-patched") + + ts, client := newRecordingServer(t, func(w http.ResponseWriter, _ *http.Request) { + writeJSONResponse(w, http.StatusOK, result) + }) + + got, err := m.patch( + t.Context(), + client, + "fixture-patched", + tt.patchType, + patch, + metav1.PatchOptions{FieldManager: "gpu-operator"}, + tt.subresources..., + ) + require.NoError(t, err) + + req := ts.lastRequest(t) + assert.Equal(t, http.MethodPatch, req.Method) + assert.Equal(t, m.named("fixture-patched", tt.subresources...), req.Path) + assertClusterScoped(t, req.Path) + assert.Equal(t, tt.expectedContentType, req.Header.Get("Content-Type")) + assert.Equal(t, patch, req.Body) + assert.Equal(t, "gpu-operator", req.Query.Get("fieldManager")) + + assert.Equal(t, "fixture-patched", got.GetName()) + }) + } +} + +func (m resourceMatrix[T, L]) testWatch(t *testing.T) { + ts, client := newRecordingServer(t, func(w http.ResponseWriter, _ *http.Request) { + writeEmptyWatchStream(w) + }) + + watcher, err := m.watch(t.Context(), client, metav1.ListOptions{ + LabelSelector: m.labelSelector, + ResourceVersion: "1234", + }) + require.NoError(t, err) + defer watcher.Stop() + + req := ts.lastRequest(t) + assert.Equal(t, http.MethodGet, req.Method) + assert.Equal(t, m.collection(), req.Path) + assertClusterScoped(t, req.Path) + assert.Equal(t, "true", req.Query.Get("watch"), "Watch must set watch=true") + assert.Equal(t, m.labelSelector, req.Query.Get("labelSelector")) + assert.Equal(t, "1234", req.Query.Get("resourceVersion")) + + // The empty stream must terminate rather than block forever. + requireWatchClosed(t, watcher.ResultChan()) +} + +func (m resourceMatrix[T, L]) testWatchDeliversEvents(t *testing.T) { + added := m.newObject("fixture-watched") + frame := marshalWatchFrame(t, watch.Added, added) + + _, client := newRecordingServer(t, func(w http.ResponseWriter, _ *http.Request) { + writeWatchFrames(w, frame) + }) + + watcher, err := m.watch(t.Context(), client, metav1.ListOptions{}) + require.NoError(t, err) + defer watcher.Stop() + + event := receiveEvent(t, watcher.ResultChan()) + assert.Equal(t, watch.Added, event.Type) + obj, ok := event.Object.(T) + require.True(t, ok, "unexpected watch payload type %T", event.Object) + assert.Equal(t, "fixture-watched", obj.GetName()) +} + +func (m resourceMatrix[T, L]) testNoNamespaceSegmentAnywhere(t *testing.T) { + // A single sweep across every verb asserting the cluster-scoped path shape. + obj := m.newObject("scope-check") + + tests := []struct { + name string + call func(t *testing.T, c *NvidiaV1alpha1Client) + }{ + {"get", func(t *testing.T, c *NvidiaV1alpha1Client) { + _, err := m.get(t.Context(), c, "scope-check", metav1.GetOptions{}) + require.NoError(t, err) + }}, + {"create", func(t *testing.T, c *NvidiaV1alpha1Client) { + _, err := m.create(t.Context(), c, obj, metav1.CreateOptions{}) + require.NoError(t, err) + }}, + {"update", func(t *testing.T, c *NvidiaV1alpha1Client) { + _, err := m.update(t.Context(), c, obj, metav1.UpdateOptions{}) + require.NoError(t, err) + }}, + {"updateStatus", func(t *testing.T, c *NvidiaV1alpha1Client) { + _, err := m.updateStatus(t.Context(), c, obj, metav1.UpdateOptions{}) + require.NoError(t, err) + }}, + {"patch", func(t *testing.T, c *NvidiaV1alpha1Client) { + _, err := m.patch(t.Context(), c, "scope-check", types.MergePatchType, []byte(`{}`), metav1.PatchOptions{}) + require.NoError(t, err) + }}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ts, client := newRecordingServer(t, func(w http.ResponseWriter, _ *http.Request) { + writeJSONResponse(w, http.StatusOK, obj) + }) + + tt.call(t, client) + + req := ts.lastRequest(t) + assertClusterScoped(t, req.Path) + assertGroupVersionPrefix(t, req.Path, m.resource) + }) + } +} diff --git a/api/versioned/typed/nvidia/v1alpha1/nvidia_client_test.go b/api/versioned/typed/nvidia/v1alpha1/nvidia_client_test.go new file mode 100644 index 0000000000..06522675ee --- /dev/null +++ b/api/versioned/typed/nvidia/v1alpha1/nvidia_client_test.go @@ -0,0 +1,245 @@ +/** +# Copyright (c) NVIDIA CORPORATION. All rights reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +**/ + +package v1alpha1 + +import ( + "net/http" + "os" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + rest "k8s.io/client-go/rest" + + nvidiav1alpha1 "github.com/NVIDIA/gpu-operator/api/nvidia/v1alpha1" +) + +// Compile-time assertion that the generated client satisfies the group interface. +var _ NvidiaV1alpha1Interface = &NvidiaV1alpha1Client{} + +func TestSetConfigDefaults(t *testing.T) { + tests := []struct { + name string + in rest.Config + expectedUserAgent func(t *testing.T, got string) + }{ + { + name: "empty user agent gets the default kubernetes user agent", + in: rest.Config{}, + expectedUserAgent: func(t *testing.T, got string) { + t.Helper() + assert.Equal(t, rest.DefaultKubernetesUserAgent(), got) + }, + }, + { + name: "caller supplied user agent is preserved", + in: rest.Config{UserAgent: "gpu-operator-tests/1.2.3"}, + expectedUserAgent: func(t *testing.T, got string) { + t.Helper() + assert.Equal(t, "gpu-operator-tests/1.2.3", got) + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cfg := tt.in + setConfigDefaults(&cfg) + + require.NotNil(t, cfg.GroupVersion) + assert.Equal(t, nvidiav1alpha1.SchemeGroupVersion, *cfg.GroupVersion) + assert.Equal(t, "nvidia.com", cfg.GroupVersion.Group) + assert.Equal(t, "v1alpha1", cfg.GroupVersion.Version) + assert.Equal(t, "/apis", cfg.APIPath) + assert.NotNil(t, cfg.NegotiatedSerializer) + tt.expectedUserAgent(t, cfg.UserAgent) + }) + } +} + +func TestSetConfigDefaultsNegotiatedSerializerSupportsJSON(t *testing.T) { + cfg := rest.Config{} + setConfigDefaults(&cfg) + + require.NotNil(t, cfg.NegotiatedSerializer) + + var mediaTypes []string + for _, info := range cfg.NegotiatedSerializer.SupportedMediaTypes() { + mediaTypes = append(mediaTypes, info.MediaType) + } + assert.Contains(t, mediaTypes, "application/json") +} + +func TestNewForConfig(t *testing.T) { + t.Run("succeeds for a minimal config", func(t *testing.T) { + client, err := NewForConfig(&rest.Config{Host: "https://192.0.2.1:6443"}) + require.NoError(t, err) + require.NotNil(t, client) + require.NotNil(t, client.RESTClient()) + + // The REST client must have been built against the group's API path. + got := client.RESTClient().Get().URL() + assert.Equal(t, "/apis/nvidia.com/v1alpha1", got.Path) + }) + + t.Run("does not mutate the caller's config", func(t *testing.T) { + in := &rest.Config{Host: "https://192.0.2.1:6443"} + _, err := NewForConfig(in) + require.NoError(t, err) + + assert.Nil(t, in.GroupVersion, "NewForConfig must operate on a copy") + assert.Empty(t, in.APIPath) + assert.Empty(t, in.UserAgent) + assert.Nil(t, in.NegotiatedSerializer) + }) + + // One representative failure is enough to prove NewForConfig surfaces + // rest.HTTPClientFor errors instead of swallowing them. The error text + // itself belongs to client-go and the OS, so it is not asserted on; the + // typed *os.PathError is the stable part. + t.Run("propagates rest.HTTPClientFor errors", func(t *testing.T) { + client, err := NewForConfig(&rest.Config{ + Host: "https://192.0.2.1:6443", + TLSClientConfig: rest.TLSClientConfig{ + CAFile: "/does/not/exist/ca.crt", + }, + }) + require.Error(t, err) + assert.Nil(t, client) + + var pathErr *os.PathError + require.ErrorAs(t, err, &pathErr) + assert.Equal(t, "/does/not/exist/ca.crt", pathErr.Path) + }) +} + +func TestNewForConfigAndClient(t *testing.T) { + t.Run("uses the supplied http client", func(t *testing.T) { + httpClient := &http.Client{} + client, err := NewForConfigAndClient(&rest.Config{Host: "https://192.0.2.1:6443"}, httpClient) + require.NoError(t, err) + require.NotNil(t, client) + assert.Equal(t, "/apis/nvidia.com/v1alpha1", client.RESTClient().Get().URL().Path) + }) + + t.Run("does not mutate the caller's config", func(t *testing.T) { + in := &rest.Config{Host: "https://192.0.2.1:6443"} + _, err := NewForConfigAndClient(in, &http.Client{}) + require.NoError(t, err) + + assert.Nil(t, in.GroupVersion) + assert.Empty(t, in.APIPath) + assert.Empty(t, in.UserAgent) + }) + + // One malformed host is enough to prove the error is propagated rather + // than swallowed. The message is produced by client-go and net/url and + // changes across versions without any change to the generated client, so + // it is deliberately not asserted on. + t.Run("propagates RESTClientForConfigAndClient errors", func(t *testing.T) { + client, err := NewForConfigAndClient(&rest.Config{Host: "://malformed"}, &http.Client{}) + require.Error(t, err) + assert.Nil(t, client) + }) +} + +func TestNewForConfigOrDie(t *testing.T) { + t.Run("returns a client for a good config", func(t *testing.T) { + var client *NvidiaV1alpha1Client + require.NotPanics(t, func() { + client = NewForConfigOrDie(&rest.Config{Host: "https://192.0.2.1:6443"}) + }) + require.NotNil(t, client) + assert.NotNil(t, client.RESTClient()) + }) + + t.Run("panics for a bad config", func(t *testing.T) { + assert.Panics(t, func() { + NewForConfigOrDie(&rest.Config{ + Host: "https://192.0.2.1:6443", + TLSClientConfig: rest.TLSClientConfig{ + CAFile: "/does/not/exist/ca.crt", + }, + }) + }) + }) +} + +func TestNew(t *testing.T) { + restClient, err := rest.RESTClientFor(newTestRESTConfig(t)) + require.NoError(t, err) + + client := New(restClient) + require.NotNil(t, client) + assert.Same(t, restClient, client.RESTClient(), "New must wrap the passed rest.Interface verbatim") +} + +func TestRESTClientNilReceiver(t *testing.T) { + var client *NvidiaV1alpha1Client + assert.Nil(t, client.RESTClient(), "a nil receiver must return a nil rest.Interface") +} + +func TestNVIDIADriversGetter(t *testing.T) { + client, err := NewForConfig(&rest.Config{Host: "https://192.0.2.1:6443"}) + require.NoError(t, err) + + drivers := client.NVIDIADrivers() + require.NotNil(t, drivers) + + concrete, ok := drivers.(*nVIDIADrivers) + require.True(t, ok, "expected the generated *nVIDIADrivers implementation") + assert.Empty(t, concrete.GetNamespace(), "NVIDIADriver is cluster scoped") +} + +func TestGPUClustersGetter(t *testing.T) { + client, err := NewForConfig(&rest.Config{Host: "https://192.0.2.1:6443"}) + require.NoError(t, err) + + clusters := client.GPUClusters() + require.NotNil(t, clusters) + + concrete, ok := clusters.(*gPUClusters) + require.True(t, ok, "expected the generated *gPUClusters implementation") + assert.Empty(t, concrete.GetNamespace(), "GPUCluster is cluster scoped") +} + +func TestGettersShareTheGroupRESTClient(t *testing.T) { + // Both resource clients must be built on top of the very REST client the + // group client exposes. + restClient, err := rest.RESTClientFor(newTestRESTConfig(t)) + require.NoError(t, err) + + client := New(restClient) + + drivers, ok := client.NVIDIADrivers().(*nVIDIADrivers) + require.True(t, ok) + clusters, ok := client.GPUClusters().(*gPUClusters) + require.True(t, ok) + + assert.Same(t, restClient, drivers.GetClient()) + assert.Same(t, restClient, clusters.GetClient()) +} + +// newTestRESTConfig returns a config that is already valid for rest.RESTClientFor. +func newTestRESTConfig(t *testing.T) *rest.Config { + t.Helper() + + cfg := &rest.Config{Host: "https://192.0.2.1:6443"} + setConfigDefaults(cfg) + return cfg +} diff --git a/api/versioned/typed/nvidia/v1alpha1/nvidiadriver_test.go b/api/versioned/typed/nvidia/v1alpha1/nvidiadriver_test.go new file mode 100644 index 0000000000..464a82f16f --- /dev/null +++ b/api/versioned/typed/nvidia/v1alpha1/nvidiadriver_test.go @@ -0,0 +1,309 @@ +/** +# Copyright (c) NVIDIA CORPORATION. All rights reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +**/ + +package v1alpha1 + +import ( + "context" + "encoding/json" + "net/http" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + types "k8s.io/apimachinery/pkg/types" + watch "k8s.io/apimachinery/pkg/watch" + + nvidiav1alpha1 "github.com/NVIDIA/gpu-operator/api/nvidia/v1alpha1" +) + +// nvdResource is the plural resource name passed to newNVIDIADrivers. +const nvdResource = "nvidiadrivers" + +func nvdCollectionPath() string { + return collectionPath(nvdResource) +} + +func nvdNamedPath(name string, subresources ...string) string { + return namedPath(nvdResource, name, subresources...) +} + +// nvdNewServer stands up a recording API server and returns the typed +// NVIDIADriver client wired to it. +func nvdNewServer(t *testing.T, handler http.HandlerFunc) (*recordingServer, NVIDIADriverInterface) { + t.Helper() + + ts, client := newRecordingServer(t, handler) + return ts, client.NVIDIADrivers() +} + +// nvdDriver builds a fully typed NVIDIADriver, including the TypeMeta the +// client-side decoder needs to recognize the payload. +func nvdDriver(name string) *nvidiav1alpha1.NVIDIADriver { + gv := nvidiav1alpha1.SchemeGroupVersion + return &nvidiav1alpha1.NVIDIADriver{ + TypeMeta: metav1.TypeMeta{ + APIVersion: gv.String(), + Kind: "NVIDIADriver", + }, + ObjectMeta: metav1.ObjectMeta{ + Name: name, + ResourceVersion: "42", + Labels: map[string]string{"app": "nvidia-driver"}, + }, + Spec: nvidiav1alpha1.NVIDIADriverSpec{ + Default: true, + DriverType: nvidiav1alpha1.GPU, + Image: "nvcr.io/nvidia/driver", + }, + } +} + +// nvdList builds a decodable list payload holding items. +func nvdList(items ...*nvidiav1alpha1.NVIDIADriver) *nvidiav1alpha1.NVIDIADriverList { + gv := nvidiav1alpha1.SchemeGroupVersion + list := &nvidiav1alpha1.NVIDIADriverList{ + TypeMeta: metav1.TypeMeta{APIVersion: gv.String(), Kind: "NVIDIADriverList"}, + } + for _, item := range items { + list.Items = append(list.Items, *item) + } + return list +} + +// TestNVIDIADriverHTTPMatrix runs the shared verb/HTTP plumbing matrix against +// the generated NVIDIADriver client. Everything that depends on the +// NVIDIADriver schema lives in the focused tests below. +func TestNVIDIADriverHTTPMatrix(t *testing.T) { + matrix := resourceMatrix[*nvidiav1alpha1.NVIDIADriver, *nvidiav1alpha1.NVIDIADriverList]{ + resource: nvdResource, + labelSelector: "app=nvidia-driver", + fieldSelector: "metadata.name=driver-a", + + newObject: nvdDriver, + newEmpty: func() *nvidiav1alpha1.NVIDIADriver { return &nvidiav1alpha1.NVIDIADriver{} }, + newList: nvdList, + listItems: func(list *nvidiav1alpha1.NVIDIADriverList) []*nvidiav1alpha1.NVIDIADriver { + items := make([]*nvidiav1alpha1.NVIDIADriver, 0, len(list.Items)) + for i := range list.Items { + items = append(items, &list.Items[i]) + } + return items + }, + + get: func(ctx context.Context, c *NvidiaV1alpha1Client, name string, opts metav1.GetOptions) (*nvidiav1alpha1.NVIDIADriver, error) { + return c.NVIDIADrivers().Get(ctx, name, opts) + }, + list: func(ctx context.Context, c *NvidiaV1alpha1Client, opts metav1.ListOptions) (*nvidiav1alpha1.NVIDIADriverList, error) { + return c.NVIDIADrivers().List(ctx, opts) + }, + create: func(ctx context.Context, c *NvidiaV1alpha1Client, obj *nvidiav1alpha1.NVIDIADriver, opts metav1.CreateOptions) (*nvidiav1alpha1.NVIDIADriver, error) { + return c.NVIDIADrivers().Create(ctx, obj, opts) + }, + update: func(ctx context.Context, c *NvidiaV1alpha1Client, obj *nvidiav1alpha1.NVIDIADriver, opts metav1.UpdateOptions) (*nvidiav1alpha1.NVIDIADriver, error) { + return c.NVIDIADrivers().Update(ctx, obj, opts) + }, + updateStatus: func(ctx context.Context, c *NvidiaV1alpha1Client, obj *nvidiav1alpha1.NVIDIADriver, opts metav1.UpdateOptions) (*nvidiav1alpha1.NVIDIADriver, error) { + return c.NVIDIADrivers().UpdateStatus(ctx, obj, opts) + }, + remove: func(ctx context.Context, c *NvidiaV1alpha1Client, name string, opts metav1.DeleteOptions) error { + return c.NVIDIADrivers().Delete(ctx, name, opts) + }, + removeCollection: func(ctx context.Context, c *NvidiaV1alpha1Client, opts metav1.DeleteOptions, listOpts metav1.ListOptions) error { + return c.NVIDIADrivers().DeleteCollection(ctx, opts, listOpts) + }, + patch: func(ctx context.Context, c *NvidiaV1alpha1Client, name string, pt types.PatchType, data []byte, opts metav1.PatchOptions, subresources ...string) (*nvidiav1alpha1.NVIDIADriver, error) { + return c.NVIDIADrivers().Patch(ctx, name, pt, data, opts, subresources...) + }, + watch: func(ctx context.Context, c *NvidiaV1alpha1Client, opts metav1.ListOptions) (watch.Interface, error) { + return c.NVIDIADrivers().Watch(ctx, opts) + }, + } + + matrix.run(t) +} + +func TestNVIDIADriversRequestPaths(t *testing.T) { + // Spelled out literally: the shared matrix derives its expectations from + // the same helpers the client uses, so this pins the actual URLs. + assert.Equal(t, "/apis/nvidia.com/v1alpha1/nvidiadrivers", nvdCollectionPath()) + assert.Equal(t, "/apis/nvidia.com/v1alpha1/nvidiadrivers/gpu-driver", nvdNamedPath("gpu-driver")) + assert.Equal(t, "/apis/nvidia.com/v1alpha1/nvidiadrivers/gpu-driver/status", nvdNamedPath("gpu-driver", "status")) + + ts, client := nvdNewServer(t, func(w http.ResponseWriter, _ *http.Request) { + writeJSONResponse(w, http.StatusOK, nvdDriver("gpu-driver")) + }) + + _, err := client.Get(t.Context(), "gpu-driver", metav1.GetOptions{}) + require.NoError(t, err) + + req := ts.lastRequest(t) + assert.Equal(t, "/apis/nvidia.com/v1alpha1/nvidiadrivers/gpu-driver", req.Path) + assertClusterScoped(t, req.Path) +} + +func TestNVIDIADriversGroupVersionKind(t *testing.T) { + gvk := nvdDriver("gpu-driver").GroupVersionKind() + assert.Equal(t, "nvidia.com", gvk.Group) + assert.Equal(t, "v1alpha1", gvk.Version) + assert.Equal(t, "NVIDIADriver", gvk.Kind) + assert.Equal(t, "NVIDIADriverList", nvdList().GroupVersionKind().Kind) +} + +func TestNVIDIADriversGetDecodesSpec(t *testing.T) { + want := nvdDriver("gpu-driver") + + _, client := nvdNewServer(t, func(w http.ResponseWriter, _ *http.Request) { + writeJSONResponse(w, http.StatusOK, want) + }) + + got, err := client.Get(t.Context(), "gpu-driver", metav1.GetOptions{}) + require.NoError(t, err) + + require.NotNil(t, got) + assert.Equal(t, "gpu-driver", got.Name) + assert.Equal(t, "42", got.ResourceVersion) + assert.True(t, got.Spec.Default) + assert.Equal(t, nvidiav1alpha1.GPU, got.Spec.DriverType) + assert.Equal(t, "nvcr.io/nvidia/driver", got.Spec.Image) +} + +func TestNVIDIADriversListDecodesItems(t *testing.T) { + want := nvdList(nvdDriver("driver-a"), nvdDriver("driver-b")) + want.ResourceVersion = "99" + want.Continue = "next-token" + + _, client := nvdNewServer(t, func(w http.ResponseWriter, _ *http.Request) { + writeJSONResponse(w, http.StatusOK, want) + }) + + got, err := client.List(t.Context(), metav1.ListOptions{}) + require.NoError(t, err) + + require.Len(t, got.Items, 2) + assert.Equal(t, "driver-a", got.Items[0].Name) + assert.Equal(t, "driver-b", got.Items[1].Name) + assert.Equal(t, "nvcr.io/nvidia/driver", got.Items[0].Spec.Image) + assert.Equal(t, "99", got.ResourceVersion) + assert.Equal(t, "next-token", got.Continue) +} + +func TestNVIDIADriversCreateSendsSpec(t *testing.T) { + in := nvdDriver("new-driver") + + ts, client := nvdNewServer(t, func(w http.ResponseWriter, _ *http.Request) { + writeJSONResponse(w, http.StatusCreated, in) + }) + + got, err := client.Create(t.Context(), in, metav1.CreateOptions{}) + require.NoError(t, err) + + // The spec must round-trip through the request body. + var sent nvidiav1alpha1.NVIDIADriver + require.NoError(t, json.Unmarshal(ts.lastRequest(t).Body, &sent)) + assert.Equal(t, "new-driver", sent.Name) + assert.Equal(t, in.Spec.Image, sent.Spec.Image) + assert.Equal(t, in.Spec.DriverType, sent.Spec.DriverType) + assert.True(t, sent.Spec.Default) + assert.Equal(t, map[string]string{"app": "nvidia-driver"}, sent.Labels) + + assert.Equal(t, "new-driver", got.Name) +} + +func TestNVIDIADriversUpdateSendsSpec(t *testing.T) { + in := nvdDriver("existing-driver") + in.Spec.Image = "nvcr.io/nvidia/driver-updated" + + ts, client := nvdNewServer(t, func(w http.ResponseWriter, _ *http.Request) { + writeJSONResponse(w, http.StatusOK, in) + }) + + got, err := client.Update(t.Context(), in, metav1.UpdateOptions{}) + require.NoError(t, err) + + req := ts.lastRequest(t) + assert.Equal(t, nvdNamedPath("existing-driver"), req.Path) + assert.NotContains(t, req.Path, "/status") + + var sent nvidiav1alpha1.NVIDIADriver + require.NoError(t, json.Unmarshal(req.Body, &sent)) + assert.Equal(t, "nvcr.io/nvidia/driver-updated", sent.Spec.Image) + + assert.Equal(t, "nvcr.io/nvidia/driver-updated", got.Spec.Image) +} + +func TestNVIDIADriversUpdateStatusSendsStatus(t *testing.T) { + in := nvdDriver("existing-driver") + in.Status.State = nvidiav1alpha1.Ready + + ts, client := nvdNewServer(t, func(w http.ResponseWriter, _ *http.Request) { + writeJSONResponse(w, http.StatusOK, in) + }) + + got, err := client.UpdateStatus(t.Context(), in, metav1.UpdateOptions{}) + require.NoError(t, err) + + req := ts.lastRequest(t) + assert.Equal(t, nvdNamedPath("existing-driver", "status"), req.Path) + assert.True(t, strings.HasSuffix(req.Path, "/status"), "UpdateStatus must target the status subresource") + + var sent nvidiav1alpha1.NVIDIADriver + require.NoError(t, json.Unmarshal(req.Body, &sent)) + assert.Equal(t, nvidiav1alpha1.Ready, sent.Status.State) + + assert.Equal(t, nvidiav1alpha1.Ready, got.Status.State) +} + +func TestNVIDIADriversPatchDecodesSpec(t *testing.T) { + result := nvdDriver("patched-driver") + result.Spec.Image = "nvcr.io/nvidia/driver-patched" + + ts, client := nvdNewServer(t, func(w http.ResponseWriter, _ *http.Request) { + writeJSONResponse(w, http.StatusOK, result) + }) + + patch := []byte(`{"spec":{"image":"nvcr.io/nvidia/driver-patched"}}`) + got, err := client.Patch(t.Context(), "patched-driver", types.MergePatchType, patch, metav1.PatchOptions{}) + require.NoError(t, err) + + assert.Equal(t, patch, ts.lastRequest(t).Body) + assert.Equal(t, "nvcr.io/nvidia/driver-patched", got.Spec.Image) +} + +func TestNVIDIADriversWatchDecodesObject(t *testing.T) { + added := nvdDriver("watched-driver") + // The frame is marshalled here, on the test goroutine, so the handler below + // carries no assertions. + frame := marshalWatchFrame(t, watch.Added, added) + + _, client := nvdNewServer(t, func(w http.ResponseWriter, _ *http.Request) { + writeWatchFrames(w, frame) + }) + + watcher, err := client.Watch(t.Context(), metav1.ListOptions{}) + require.NoError(t, err) + defer watcher.Stop() + + event := receiveEvent(t, watcher.ResultChan()) + assert.Equal(t, watch.Added, event.Type) + obj, ok := event.Object.(*nvidiav1alpha1.NVIDIADriver) + require.True(t, ok, "expected a *NVIDIADriver, got %T", event.Object) + assert.Equal(t, "watched-driver", obj.Name) + assert.Equal(t, "nvcr.io/nvidia/driver", obj.Spec.Image) +}