From c3b63c0a1c78809fa5936973e847a453d9d99584 Mon Sep 17 00:00:00 2001 From: Abrar Shivani Date: Thu, 30 Jul 2026 21:04:38 -0700 Subject: [PATCH 1/7] Add unit tests for api/versioned/scheme Cover the generated clientset scheme registration: all six nvidia kinds (ClusterPolicy, NVIDIADriver, GPUCluster and their List types) resolve in both directions via ObjectKinds and Scheme.New, with negative cases for unknown groups and cross-version kinds. Also covers the metav1 types added for GroupVersion{Version:"v1"}, Status being registered as unversioned, Codecs JSON round-trips (asserting encoding does not mutate the input), missing/unknown kind decode errors, ParameterCodec encode/decode of ListOptions including the *int64 timeout, and AddToScheme composing into a fresh scheme idempotently. The codec round-trip matrix covers every registered kind, GPUCluster and GPUClusterList included, so it matches its stated purpose. AddToScheme is asserted through its public result rather than by indexing the generated localSchemeBuilder: the number of registration functions client-gen emits and the order it emits them in are not a contract, so adding a group or reordering the slice should only fail this test if the resulting scheme is wrong. Signed-off-by: Abrar Shivani --- api/versioned/scheme/register_test.go | 558 ++++++++++++++++++++++++++ 1 file changed, 558 insertions(+) create mode 100644 api/versioned/scheme/register_test.go 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) +} From f4d7fc7fc72b964d3f371671aee70c91e7b1ebdd Mon Sep 17 00:00:00 2001 From: Abrar Shivani Date: Thu, 30 Jul 2026 21:04:38 -0700 Subject: [PATCH 2/7] Add unit tests for api/versioned/typed/nvidia/v1 Cover the generated NvidiaV1 typed client and the ClusterPolicy resource client. setConfigDefaults is checked for GroupVersion, APIPath, a non-nil NegotiatedSerializer and UserAgent defaulting, along with the constructor error paths, NewForConfigOrDie panicking, New wiring the supplied rest.Interface, and the nil-receiver branch of RESTClient. The ClusterPolicy tests drive a real httptest server through NewForConfig and assert both the outgoing request and the decoded result for Get, List, Create, Update, UpdateStatus, Delete, DeleteCollection, Patch and Watch: paths, ParameterCodec-encoded query params, per-PatchType Content-Type and verbatim body, the subresources variadic producing /status, and error mapping for 404 and 500 Status bodies. Every path assertion also confirms the resource is cluster-scoped by rejecting a /namespaces/ segment. No assertion runs on the httptest server's goroutine. testify's require calls t.FailNow, which the testing package only permits on the goroutine running the test; a failure there could kill the handler goroutine without stopping the test. writeJSON therefore takes no *testing.T and surfaces a marshal failure as a 500, responses are built on the test goroutine, and request bodies are asserted afterwards from the recorder. Error paths assert stable typed errors such as *os.PathError and fs.ErrNotExist rather than message text owned by client-go, the standard library or the OS, which can change without any behaviour change here. Waits are bounded by a single 2s eventTimeout through a shared receiveEvent helper, and request contexts derive from t.Context(). Signed-off-by: Abrar Shivani --- .../typed/nvidia/v1/clusterpolicy_test.go | 572 ++++++++++++++++++ .../typed/nvidia/v1/nvidia_client_test.go | 191 ++++++ 2 files changed, 763 insertions(+) create mode 100644 api/versioned/typed/nvidia/v1/clusterpolicy_test.go create mode 100644 api/versioned/typed/nvidia/v1/nvidia_client_test.go 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/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()) +} From c8098e937a0c166da5fc9e95b38d6de4258532d1 Mon Sep 17 00:00:00 2001 From: Abrar Shivani Date: Thu, 30 Jul 2026 21:04:48 -0700 Subject: [PATCH 3/7] Add unit tests for api/versioned/typed/nvidia/v1/fake Cover the generated fake NvidiaV1 client and fake ClusterPolicy client using a bare testing.Fake wired to an ObjectTracker and ObjectReaction, so the tests exercise the generated wiring without depending on the top-level fake clientset. Includes the full CRUD/List/Watch/Patch surface, IsAlreadyExists and IsNotFound boundaries, label-selector filtering, recorded action verbs, subresources, GVR/GVK and cluster-scoped empty namespace, and reactor-chain behaviour for both short-circuit errors and object substitution. Assertions are limited to the generated client's own contract rather than the behaviour of the current client-go fake. DeleteCollection is asserted through the recorded DeleteCollectionActionImpl (verb, GroupVersionResource, empty namespace, and the DeleteOptions and ListOptions carried on it) instead of asserting that nothing is deleted, which would pin a testing.ObjectReaction implementation detail and break a future Kubernetes upgrade that implemented collection deletion. That limitation is recorded in a comment, and a reactor-backed test covers real deletion. RESTClient is asserted to return a typed-nil *rest.RESTClient, since that is generated code in this repository; how client-go reacts to such a client is left unasserted. The copyListMeta hook is reachable only through a filtered List, because gentype returns the tracker's list verbatim when no label selector is set, so it is exercised that way. Waits are bounded by a 2s eventTimeout through a shared receiveEvent helper and contexts derive from t.Context(). Where a behaviour belongs to client-go rather than to the generated client, only the observable contract is asserted. The watch-without-a-reactor case asserts an error and a nil watcher without matching client-go's wording, which can change on a dependency bump without the behaviour changing. Signed-off-by: Abrar Shivani --- .../nvidia/v1/fake/fake_clusterpolicy_test.go | 765 ++++++++++++++++++ .../nvidia/v1/fake/fake_nvidia_client_test.go | 106 +++ 2 files changed, 871 insertions(+) create mode 100644 api/versioned/typed/nvidia/v1/fake/fake_clusterpolicy_test.go create mode 100644 api/versioned/typed/nvidia/v1/fake/fake_nvidia_client_test.go 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") +} From 82e5487dd2cd5c8542e5ecf1184ec6c22999d8b8 Mon Sep 17 00:00:00 2001 From: Abrar Shivani Date: Thu, 30 Jul 2026 21:04:48 -0700 Subject: [PATCH 4/7] Add unit tests for api/versioned/typed/nvidia/v1alpha1 Cover the generated NvidiaV1alpha1 typed client and both of its resource clients, NVIDIADriver and GPUCluster. setConfigDefaults is checked for GroupVersion, APIPath, a non-nil NegotiatedSerializer and UserAgent defaulting, plus the constructor error paths, NewForConfigOrDie panicking, New wiring the supplied rest.Interface, the nil-receiver branch of RESTClient, and both resource getters sharing the group REST client. Both resources are driven against a real httptest server through NewForConfig, asserting the outgoing request and decoded result for Get, List, Create, Update, UpdateStatus, Delete, DeleteCollection, Patch and Watch: paths derived from SchemeGroupVersion, ParameterCodec-encoded query params, per-PatchType Content-Type and verbatim body, the subresources variadic producing /status, and 404 mapping to IsNotFound. Every path is asserted to be cluster-scoped with no /namespaces/ segment. The verb plumbing is shared rather than written out per resource: both resources go through the same generated generic client, so a single generic resourceMatrix in helpers_test.go drives them, parameterized on resource name, selectors, object and list constructors and one function per verb, emitting subtests named / so failures identify the resource. Schema-specific assertions stay in focused per-resource tests: literal request paths, GVK, decoded spec fields, the status subresource payload and typed watch decoding. The matrix is not lifted into its own package, because api/versioned is a client-gen output tree. No assertion runs on the httptest server's goroutine: testify's require calls t.FailNow, which only the test goroutine may call, so writeJSONResponse takes no *testing.T and watch frames are marshalled ahead of time. Error paths assert typed errors rather than message text owned by client-go, the standard library or the OS. Waits are bounded by a 2s eventTimeout and contexts derive from t.Context(). Signed-off-by: Abrar Shivani --- .../typed/nvidia/v1alpha1/gpucluster_test.go | 325 +++++++ .../typed/nvidia/v1alpha1/helpers_test.go | 798 ++++++++++++++++++ .../nvidia/v1alpha1/nvidia_client_test.go | 245 ++++++ .../nvidia/v1alpha1/nvidiadriver_test.go | 309 +++++++ 4 files changed, 1677 insertions(+) create mode 100644 api/versioned/typed/nvidia/v1alpha1/gpucluster_test.go create mode 100644 api/versioned/typed/nvidia/v1alpha1/helpers_test.go create mode 100644 api/versioned/typed/nvidia/v1alpha1/nvidia_client_test.go create mode 100644 api/versioned/typed/nvidia/v1alpha1/nvidiadriver_test.go 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) +} From 578c60c9a60c33025041de29d97464cfc1e082e7 Mon Sep 17 00:00:00 2001 From: Abrar Shivani Date: Thu, 30 Jul 2026 21:04:48 -0700 Subject: [PATCH 5/7] Add unit tests for api/versioned/typed/nvidia/v1alpha1/fake Cover the generated fake NvidiaV1alpha1 client and the fake NVIDIADriver and GPUCluster clients using a bare testing.Fake wired to an ObjectTracker and ObjectReaction, avoiding a dependency on the top-level fake clientset. Includes the full CRUD/List/Watch/Patch surface for both resources, IsAlreadyExists and IsNotFound boundaries, label-selector filtering, the copyListMeta hook, recorded action verbs and subresources, GVR/GVK derived from SchemeGroupVersion, cluster-scoped empty namespaces, and reactor-chain short-circuiting across the typed methods. Because both resources share one testing.Fake and one reaction chain, the group-level tests also assert that actions from both interleave into a single recorder, that an NVIDIADriver and a GPUCluster with the same name are tracked independently, and that watches and reactors are scoped per GVR so driver events do not leak into a GPUCluster watch. Assertions are limited to the generated client's own contract rather than the behaviour of the current client-go fake. DeleteCollection and UpdateStatus are asserted through the recorded action (verb, GroupVersionResource, empty namespace, status subresource, and the DeleteOptions and ListOptions carried on it) instead of asserting that nothing is deleted or that the whole object is replaced, which would pin testing.ObjectReaction implementation details and break a future Kubernetes upgrade that fixed them. Those limitations are recorded in comments, and reactor-backed tests cover real collection deletion. The watch tests assert the ListOptions the generated client forwards to tracker.Watch, including the Watch field it sets itself; replay of pre-existing objects follows from that forwarding and is kept as a documented corollary. RESTClient is asserted to return a typed-nil *rest.RESTClient, which is generated code here, while how client-go reacts to such a client is left unasserted. Waits are bounded by a 2s watchTimeout and contexts derive from t.Context(). The watch tests assert only the ListOptions the generated client forwards onto the recorded action. Nothing is asserted about replayed events: whether the tracker replays already-known objects, and whether it applies the label selector when it does, belongs to client-go rather than to the generated client. Event delivery for create, update and delete is covered separately. Signed-off-by: Abrar Shivani --- .../v1alpha1/fake/fake_gpucluster_test.go | 667 ++++++++++++++++++ .../v1alpha1/fake/fake_nvidia_client_test.go | 230 ++++++ .../v1alpha1/fake/fake_nvidiadriver_test.go | 598 ++++++++++++++++ 3 files changed, 1495 insertions(+) create mode 100644 api/versioned/typed/nvidia/v1alpha1/fake/fake_gpucluster_test.go create mode 100644 api/versioned/typed/nvidia/v1alpha1/fake/fake_nvidia_client_test.go create mode 100644 api/versioned/typed/nvidia/v1alpha1/fake/fake_nvidiadriver_test.go 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"]) +} From 1e58de0f220c9aaf3f8988a5640493d7752e4292 Mon Sep 17 00:00:00 2001 From: Abrar Shivani Date: Thu, 30 Jul 2026 21:04:59 -0700 Subject: [PATCH 6/7] Add unit tests for api/versioned clientset Cover the generated top-level Clientset constructors. NewForConfig is tested for success, for the rest.HTTPClientFor error path, for UserAgent defaulting observed on a real request header via an httptest server, and for leaving the caller's rest.Config unmutated given the shallow-copy semantics. NewForConfigAndClient covers the burst validation error when QPS is set without a positive burst, a rate limiter being installed and shared by both group clients, a caller-supplied RateLimiter being passed through by identity, and the QPS=0 case where each group client falls back to its own default limiter. Also covers NewForConfigOrDie panicking, New wiring the supplied RESTClient into every group client and discovery, the group accessors, and Discovery returning a genuine nil for a nil receiver so a typed-nil regression would be caught. State shared with the httptest handler goroutine is mutex guarded. The recorded request paths and the observed User-Agent are written by the server's goroutine and read by the test's; the calls happen to be sequential today, so -race passes, but the accesses are unsynchronized and would become a real race if a test ever issued concurrent calls. Request contexts derive from t.Context(). Signed-off-by: Abrar Shivani --- api/versioned/clientset_test.go | 432 ++++++++++++++++++++++++++++++++ 1 file changed, 432 insertions(+) create mode 100644 api/versioned/clientset_test.go 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()) +} From abd086fca005fc54af4a6c5761e128e9b97355b8 Mon Sep 17 00:00:00 2001 From: Abrar Shivani Date: Thu, 30 Jul 2026 21:04:59 -0700 Subject: [PATCH 7/7] Add unit tests for api/versioned/fake clientset Cover NewSimpleClientset and the generated fake clientset wiring: seeding the object tracker, CRUD round trips through both group clients, the default watch reactor delivering Added/Modified/Deleted events, action recording and ClearActions, reactor interception and fall-through, panicking on an object type absent from the fake scheme, Tracker being shared with the typed clients, Discovery returning a FakeDiscovery bound to the same Fake, IsWatchListSemanticsUnSupported, and the interface assertions from the generated var block. Also covers register.go: the package scheme recognising all nvidia kinds plus the metav1 types, AddToScheme composing into a fresh scheme, and codecs decoding both group versions. Assertions are limited to the generated client's own contract rather than the behaviour of the current client-go fake. DeleteCollection is asserted through the recorded action (verb, GroupVersionResource, empty namespace, and the DeleteOptions and ListOptions carried on it) instead of asserting that nothing is deleted, which would pin a testing.ObjectReaction implementation detail and break a future Kubernetes upgrade that implemented it. That limitation is recorded in a comment, and a reactor-backed test covers real collection deletion. The watch test asserts the ListOptions the generated client forwards to tracker.Watch, including the Watch field it sets itself; replay of pre-existing objects follows from that forwarding and is kept as a documented corollary. The unregistered-kind panic uses a private, never-registered test type rather than corev1.Pod, which would silently stop testing anything if core types were ever added to this scheme, and asserts the recovered value satisfies runtime.IsNotRegisteredError so it cannot pass for the wrong reason. Waits are bounded by a 2s eventTimeout and contexts derive from t.Context(). Where a behaviour belongs to client-go rather than to the generated client, only the observable contract is asserted. The watch test checks the ListOptions the generated client forwards onto the recorded action and asserts nothing about replayed events, since whether the tracker replays already-known objects, and whether it applies the label selector when it does, is client-go's business. The watch-error case asserts an error and a nil watcher without matching client-go's wording, which can change on a dependency bump. Signed-off-by: Abrar Shivani --- .../fake/clientset_generated_test.go | 803 ++++++++++++++++++ api/versioned/fake/register_test.go | 204 +++++ 2 files changed, 1007 insertions(+) create mode 100644 api/versioned/fake/clientset_generated_test.go create mode 100644 api/versioned/fake/register_test.go 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) +}