diff --git a/staging/src/k8s.io/dynamic-resource-allocation/api/conversion_test.go b/staging/src/k8s.io/dynamic-resource-allocation/api/conversion_test.go new file mode 100644 index 00000000000..1311081f382 --- /dev/null +++ b/staging/src/k8s.io/dynamic-resource-allocation/api/conversion_test.go @@ -0,0 +1,119 @@ +/* +Copyright 2025 The Kubernetes Authors. + +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 api + +import ( + "testing" + + "github.com/google/go-cmp/cmp" + + resourceapi "k8s.io/api/resource/v1" + "k8s.io/apimachinery/pkg/api/apitesting/fuzzer" + apiequality "k8s.io/apimachinery/pkg/api/equality" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/runtime/serializer" + apitest "k8s.io/dynamic-resource-allocation/api/internal/test" + "sigs.k8s.io/randfill" +) + +// v1FillFuncs returns custom fill functions needed for v1 types where +// the default random filling would create objects that cannot round-trip +// due to pointer-to-value conversions in the internal api types. +func v1FillFuncs(codecs serializer.CodecFactory) []interface{} { + return []interface{}{ + // The internal api types use bool instead of *bool for AllNodes. + // Converting nil -> false -> &false means nil doesn't round-trip. + // Ensure AllNodes is always non-nil to avoid this. + func(s *resourceapi.ResourceSliceSpec, c randfill.Continue) { + c.FillNoCustom(s) + if s.AllNodes == nil { + s.AllNodes = new(bool) + } + }, + // The internal api types use bool instead of *bool for BindsToNode. + // Converting nil -> false -> &false means nil doesn't round-trip. + // Ensure BindsToNode is always non-nil to avoid this. + func(d *resourceapi.Device, c randfill.Continue) { + c.FillNoCustom(d) + if d.BindsToNode == nil { + d.BindsToNode = new(bool) + } + }, + } +} + +// TestConversionRoundTrip verifies that the conversion code for the internal +// DRA API correctly round-trips between v1 and the internal api types. +// For v1 -> api -> v1, the conversion should be lossless. +// +// api -> v1 -> api is not lossless because of +// additional fields in the internal representation. +// Those would get lost during conversion. +// But this doesn't matter because in practice, +// the internal representation is never converted back, +// therefore this direction is not tested. +func TestConversionRoundTrip(t *testing.T) { + scheme := runtime.NewScheme() + if err := resourceapi.AddToScheme(scheme); err != nil { + t.Fatal(err) + } + if err := AddToScheme(scheme); err != nil { + t.Fatal(err) + } + + filler := apitest.NewFiller(t, scheme, fuzzer.FuzzerFuncs(v1FillFuncs)) + + testCases := []struct { + name string + v1Type func() runtime.Object + apiNew func() interface{} + }{ + { + name: "ResourceSlice", + v1Type: func() runtime.Object { return &resourceapi.ResourceSlice{} }, + apiNew: func() interface{} { return &ResourceSlice{} }, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + for i := range apitest.FuzzIterations { + // Create and fuzz v1 object. + v1Obj := tc.v1Type() + filler.Fill(v1Obj) + + // Convert v1 -> api. + apiObj := tc.apiNew() + if err := scheme.Convert(v1Obj, apiObj, nil); err != nil { + t.Fatalf("iteration %d: v1 -> api: %v", i, err) + } + + // Convert api -> v1 (round-trip). + roundTripped := tc.v1Type() + if err := scheme.Convert(apiObj, roundTripped, nil); err != nil { + t.Fatalf("iteration %d: api -> v1: %v", i, err) + } + + // The round-tripped object must be equal to the original. + if !apiequality.Semantic.DeepEqual(v1Obj, roundTripped) { + t.Errorf("iteration %d: round-trip v1 -> api -> v1 failed\ndiff (-want +got):\n%s", + i, cmp.Diff(v1Obj, roundTripped)) + } + } + }) + } +} diff --git a/staging/src/k8s.io/dynamic-resource-allocation/api/internal/test/roundtrip.go b/staging/src/k8s.io/dynamic-resource-allocation/api/internal/test/roundtrip.go new file mode 100644 index 00000000000..bceeeb66d9a --- /dev/null +++ b/staging/src/k8s.io/dynamic-resource-allocation/api/internal/test/roundtrip.go @@ -0,0 +1,113 @@ +/* +Copyright The Kubernetes Authors. + +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 test provides test utilities for DRA API conversion testing. +package test + +import ( + "math/rand" + "strings" + "testing" + + "github.com/google/go-cmp/cmp" + + "k8s.io/apimachinery/pkg/api/apitesting/fuzzer" + apiequality "k8s.io/apimachinery/pkg/api/equality" + genericfuzzer "k8s.io/apimachinery/pkg/apis/meta/fuzzer" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/runtime/schema" + "k8s.io/apimachinery/pkg/runtime/serializer" + "sigs.k8s.io/randfill" +) + +// FuzzIterations is the number of fuzz iterations to run for each type. +const FuzzIterations = 100 + +// NewFiller creates a new randfill.Filler for fuzzing objects. +func NewFiller(t *testing.T, scheme *runtime.Scheme, customFuncs fuzzer.FuzzerFuncs) *randfill.Filler { + seed := rand.Int63() + t.Logf("Using seed: %d", seed) + codecs := serializer.NewCodecFactory(scheme) + return fuzzer.FuzzerFor( + fuzzer.MergeFuzzerFuncs(genericfuzzer.Funcs, customFuncs), + rand.NewSource(seed), + codecs, + ) +} + +// ConversionRoundTrip tests that all non-list types in srcGV can be converted +// to dstGV and back without loss of information. +func ConversionRoundTrip(t *testing.T, scheme *runtime.Scheme, filler *randfill.Filler, srcGV, dstGV schema.GroupVersion) { + t.Helper() + + tested := 0 + for kind := range scheme.KnownTypes(srcGV) { + if strings.HasSuffix(kind, "List") { + continue + } + + srcGVK := srcGV.WithKind(kind) + dstGVK := dstGV.WithKind(kind) + if _, err := scheme.New(dstGVK); err != nil { + // Kind does not exist in the destination version. + continue + } + + tested++ + t.Run(kind, func(t *testing.T) { + for i := range FuzzIterations { + // Create and fuzz source object. + srcObj, err := scheme.New(srcGVK) + if err != nil { + t.Fatal(err) + } + filler.Fill(srcObj) + + // Convert source -> destination. + dstObj, err := scheme.New(dstGVK) + if err != nil { + t.Fatal(err) + } + if err := scheme.Convert(srcObj, dstObj, nil); err != nil { + t.Fatalf("iteration %d: %v -> %v: %v", i, srcGVK, dstGVK, err) + } + + // Convert destination -> source (round-trip). + roundTripped, err := scheme.New(srcGVK) + if err != nil { + t.Fatal(err) + } + if err := scheme.Convert(dstObj, roundTripped, nil); err != nil { + t.Fatalf("iteration %d: %v -> %v: %v", i, dstGVK, srcGVK, err) + } + + // The round-tripped object must be equal to the original. + // Use Semantic.DeepEqual which treats nil and empty + // slices/maps as equivalent. + if !apiequality.Semantic.DeepEqual(srcObj, roundTripped) { + t.Errorf("iteration %d: round-trip %v -> %v -> %v failed\ndiff (-want +got):\n%s", + i, srcGVK, dstGVK, srcGVK, + cmp.Diff(srcObj, roundTripped)) + } + } + }) + } + + if tested == 0 { + t.Fatal("no types were tested") + } + t.Logf("tested %d types for %v <-> %v", tested, srcGV, dstGV) +} diff --git a/staging/src/k8s.io/dynamic-resource-allocation/api/v1beta1/conversion.go b/staging/src/k8s.io/dynamic-resource-allocation/api/v1beta1/conversion.go index ad85840fc2b..2289c7993ae 100644 --- a/staging/src/k8s.io/dynamic-resource-allocation/api/v1beta1/conversion.go +++ b/staging/src/k8s.io/dynamic-resource-allocation/api/v1beta1/conversion.go @@ -58,6 +58,7 @@ func Convert_v1beta1_DeviceRequest_To_v1_DeviceRequest(in *resourcev1beta1.Devic tolerations = append(tolerations, toleration) } exactDeviceRequest.Tolerations = tolerations + exactDeviceRequest.Capacity = (*resourceapi.CapacityRequirements)(unsafe.Pointer(in.Capacity)) out.Exactly = &exactDeviceRequest } return nil @@ -69,7 +70,8 @@ func hasAnyMainRequestFieldsSet(deviceRequest *resourcev1beta1.DeviceRequest) bo deviceRequest.AllocationMode != "" || deviceRequest.Count != 0 || deviceRequest.AdminAccess != nil || - deviceRequest.Tolerations != nil + deviceRequest.Tolerations != nil || + deviceRequest.Capacity != nil } func Convert_v1_DeviceRequest_To_v1beta1_DeviceRequest(in *resourceapi.DeviceRequest, out *resourcev1beta1.DeviceRequest, s conversion.Scope) error { @@ -102,6 +104,7 @@ func Convert_v1_DeviceRequest_To_v1beta1_DeviceRequest(in *resourceapi.DeviceReq tolerations = append(tolerations, toleration) } out.Tolerations = tolerations + out.Capacity = (*resourcev1beta1.CapacityRequirements)(unsafe.Pointer(in.Exactly.Capacity)) } return nil } @@ -181,6 +184,10 @@ func Convert_v1beta1_Device_To_v1_Device(in *resourcev1beta1.Device, out *resour taints = append(taints, taint) } out.Taints = taints + out.BindsToNode = basic.BindsToNode + out.BindingConditions = basic.BindingConditions + out.BindingFailureConditions = basic.BindingFailureConditions + out.AllowMultipleAllocations = basic.AllowMultipleAllocations } return nil } @@ -226,6 +233,10 @@ func Convert_v1_Device_To_v1beta1_Device(in *resourceapi.Device, out *resourcev1 taints = append(taints, taint) } out.Basic.Taints = taints + out.Basic.BindsToNode = in.BindsToNode + out.Basic.BindingConditions = in.BindingConditions + out.Basic.BindingFailureConditions = in.BindingFailureConditions + out.Basic.AllowMultipleAllocations = in.AllowMultipleAllocations return nil } diff --git a/staging/src/k8s.io/dynamic-resource-allocation/api/v1beta1/conversion_test.go b/staging/src/k8s.io/dynamic-resource-allocation/api/v1beta1/conversion_test.go index 85a53bef08a..b5fcfcc01c3 100644 --- a/staging/src/k8s.io/dynamic-resource-allocation/api/v1beta1/conversion_test.go +++ b/staging/src/k8s.io/dynamic-resource-allocation/api/v1beta1/conversion_test.go @@ -24,7 +24,12 @@ import ( "github.com/google/go-cmp/cmp" resourceapi "k8s.io/api/resource/v1" resourcev1beta1 "k8s.io/api/resource/v1beta1" + "k8s.io/apimachinery/pkg/api/apitesting/fuzzer" "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/runtime/schema" + "k8s.io/apimachinery/pkg/runtime/serializer" + apitest "k8s.io/dynamic-resource-allocation/api/internal/test" + "sigs.k8s.io/randfill" ) func TestConversion(t *testing.T) { @@ -331,3 +336,49 @@ func TestConversion(t *testing.T) { } } + +// v1beta1FillFuncs returns custom fill functions needed for v1beta1 +// types where the default random filling would create objects that +// cannot round-trip due to structural differences between versions. +func v1beta1FillFuncs(codecs serializer.CodecFactory) []interface{} { + return []interface{}{ + // v1 -> v1beta1 conversion always creates a non-nil Basic, + // so we must ensure it is non-nil in the source object too. + func(d *resourcev1beta1.Device, c randfill.Continue) { + c.FillNoCustom(d) + if d.Basic == nil { + d.Basic = &resourcev1beta1.BasicDevice{} + } + }, + } +} + +// TestConversionRoundTrip verifies that the automatically generated and +// hand-written conversion code for the DRA API correctly round-trips +// between v1beta1 and v1. For each non-list type registered in v1beta1, +// a fuzzed object is converted to v1 and back, and the result must be +// equal to the original. +// +// Note that this only covers conversion code which is called +// while converting the top-level API types. Types embedded +// inside those have their own conversion functions, but those +// are not necessarily called. +func TestConversionRoundTrip(t *testing.T) { + scheme := runtime.NewScheme() + if err := resourceapi.AddToScheme(scheme); err != nil { + t.Fatal(err) + } + if err := resourcev1beta1.AddToScheme(scheme); err != nil { + t.Fatal(err) + } + if err := AddToScheme(scheme); err != nil { + t.Fatal(err) + } + + filler := apitest.NewFiller(t, scheme, fuzzer.FuzzerFuncs(v1beta1FillFuncs)) + + apitest.ConversionRoundTrip(t, scheme, filler, + schema.GroupVersion{Group: resourcev1beta1.GroupName, Version: "v1beta1"}, + schema.GroupVersion{Group: resourceapi.GroupName, Version: "v1"}, + ) +} diff --git a/staging/src/k8s.io/dynamic-resource-allocation/api/v1beta2/conversion_test.go b/staging/src/k8s.io/dynamic-resource-allocation/api/v1beta2/conversion_test.go index 480476948e6..ea899182619 100644 --- a/staging/src/k8s.io/dynamic-resource-allocation/api/v1beta2/conversion_test.go +++ b/staging/src/k8s.io/dynamic-resource-allocation/api/v1beta2/conversion_test.go @@ -25,6 +25,8 @@ import ( resourceapi "k8s.io/api/resource/v1" resourcev1beta2 "k8s.io/api/resource/v1beta2" "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/runtime/schema" + apitest "k8s.io/dynamic-resource-allocation/api/internal/test" ) func TestConversion(t *testing.T) { @@ -335,3 +337,33 @@ func TestConversion(t *testing.T) { } } + +// TestConversionRoundTrip verifies that the automatically generated and +// hand-written conversion code for the DRA API correctly round-trips +// between v1beta2 and v1. For each non-list type registered in v1beta2, +// a fuzzed object is converted to v1 and back, and the result must be +// equal to the original. +// +// Note that this only covers conversion code which is called +// while converting the top-level API types. Types embedded +// inside those have their own conversion functions, but those +// are not necessarily called. +func TestConversionRoundTrip(t *testing.T) { + scheme := runtime.NewScheme() + if err := resourceapi.AddToScheme(scheme); err != nil { + t.Fatal(err) + } + if err := resourcev1beta2.AddToScheme(scheme); err != nil { + t.Fatal(err) + } + if err := AddToScheme(scheme); err != nil { + t.Fatal(err) + } + + filler := apitest.NewFiller(t, scheme, nil) + + apitest.ConversionRoundTrip(t, scheme, filler, + schema.GroupVersion{Group: resourcev1beta2.GroupName, Version: "v1beta2"}, + schema.GroupVersion{Group: resourceapi.GroupName, Version: "v1"}, + ) +} diff --git a/staging/src/k8s.io/dynamic-resource-allocation/go.mod b/staging/src/k8s.io/dynamic-resource-allocation/go.mod index 0162b8c9aab..6a6569b1dcd 100644 --- a/staging/src/k8s.io/dynamic-resource-allocation/go.mod +++ b/staging/src/k8s.io/dynamic-resource-allocation/go.mod @@ -23,6 +23,7 @@ require ( k8s.io/klog/v2 v2.130.1 k8s.io/kubelet v0.0.0 k8s.io/utils v0.0.0-20260210185600-b8788abfbbc2 + sigs.k8s.io/randfill v1.0.0 ) require ( @@ -78,7 +79,6 @@ require ( k8s.io/component-base v0.0.0 // indirect k8s.io/kube-openapi v0.0.0-20260127142750-a19766b6e2d4 // indirect sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730 // indirect - sigs.k8s.io/randfill v1.0.0 // indirect sigs.k8s.io/structured-merge-diff/v6 v6.3.2 // indirect sigs.k8s.io/yaml v1.6.0 // indirect )