From 171c4f738682274c29e6e14da876c46eb9c5a558 Mon Sep 17 00:00:00 2001 From: Sunyanan Choochotkaew Date: Mon, 6 Jul 2026 14:13:36 +0900 Subject: [PATCH 1/2] DRA: Fix fraction not supported in ConsumableCapacityPolicyRange Signed-off-by: Sunyanan Choochotkaew --- pkg/apis/resource/validation/validation.go | 20 +++++-- .../validation_device_capacity_test.go | 33 +++++++++++ .../experimental/consumable_capacity.go | 55 +++++++++++------ .../experimental/consumable_capacity_test.go | 59 +++++++++++++++++++ .../incubating/consumable_capacity.go | 55 +++++++++++------ .../incubating/consumable_capacity_test.go | 59 +++++++++++++++++++ 6 files changed, 240 insertions(+), 41 deletions(-) diff --git a/pkg/apis/resource/validation/validation.go b/pkg/apis/resource/validation/validation.go index 8f28bf073d7f0..3d5bd7e977b03 100644 --- a/pkg/apis/resource/validation/validation.go +++ b/pkg/apis/resource/validation/validation.go @@ -26,6 +26,8 @@ import ( "strconv" "strings" + infdec "gopkg.in/inf.v0" + "github.com/google/uuid" corev1 "k8s.io/api/core/v1" @@ -1194,11 +1196,19 @@ func validateRequestPolicyRange(defaultValue apiresource.Quantity, maxCapacity a func validateRequestPolicyRangeStep(value, min, step apiresource.Quantity, fldPath *field.Path) field.ErrorList { var allErrs field.ErrorList - stepVal := step.Value() - minVal := min.Value() - val := value.Value() - added := (val - minVal) - if added%stepVal != 0 { + // span = value - min; check span is a whole-number multiple of step. + // Using inf.Dec arithmetic avoids int64 overflow and is correct at any scale. + // DeepCopy before AsDec to avoid mutating the originals. + span := value.DeepCopy() + span.Sub(min) + spanDec := span.AsDec() + stepCopy := step.DeepCopy() + stepDec := stepCopy.AsDec() + // n = floor(span / step); if n*step != span then it's not aligned. + var nDec, remDec, mulDec infdec.Dec + n := nDec.QuoRound(spanDec, stepDec, 0, infdec.RoundDown) + remainder := remDec.Sub(spanDec, mulDec.Mul(n, stepDec)) + if remainder.Sign() != 0 { allErrs = append(allErrs, field.Invalid(fldPath, value.String(), fmt.Sprintf("value is not a multiple of a given step (%s) from (%s)", step.String(), min.String()))) } return allErrs diff --git a/pkg/apis/resource/validation/validation_device_capacity_test.go b/pkg/apis/resource/validation/validation_device_capacity_test.go index ada505198ba2c..180501ce82b05 100644 --- a/pkg/apis/resource/validation/validation_device_capacity_test.go +++ b/pkg/apis/resource/validation/validation_device_capacity_test.go @@ -167,6 +167,39 @@ func TestValidateDeviceCapacity(t *testing.T) { field.Invalid(validRangeField.Child("step"), "10Gi", "value is not a multiple of a given step (2Gi) from (1Gi)"), }, }, + "valid-range-fractional-step": { + // min=0.2, step=0.1, max=1, default=0.2, capacity=1: 0.2 = min+0*0.1, 1.0 = min+8*0.1 + capacity: testDeviceCapacity( + apiresource.MustParse("1"), + testCapacityRequestPolicy( + ptr.To(apiresource.MustParse("200m")), + nil, + testValidRange( + ptr.To(apiresource.MustParse("200m")), + ptr.To(apiresource.MustParse("1")), + ptr.To(apiresource.MustParse("100m")), + ), + ), + ), + }, + "invalid-range-fractional-step-not-aligned": { + // default=0.25 is not a multiple of 0.1 from 0.2 + capacity: testDeviceCapacity( + apiresource.MustParse("1"), + testCapacityRequestPolicy( + ptr.To(apiresource.MustParse("250m")), + nil, + testValidRange( + ptr.To(apiresource.MustParse("200m")), + ptr.To(apiresource.MustParse("1")), + ptr.To(apiresource.MustParse("100m")), + ), + ), + ), + wantFailures: field.ErrorList{ + field.Invalid(validRangeField.Child("step"), "250m", "value is not a multiple of a given step (100m) from (200m)"), + }, + }, "invalid-range-large-step": { capacity: testDeviceCapacity(maxCapacity, testCapacityRequestPolicy(&one, nil, testValidRange(ptr.To(one), nil, ptr.To(maxCapacity)))), wantFailures: field.ErrorList{ diff --git a/staging/src/k8s.io/dynamic-resource-allocation/structured/internal/experimental/consumable_capacity.go b/staging/src/k8s.io/dynamic-resource-allocation/structured/internal/experimental/consumable_capacity.go index 1df17186c2721..f0561719d943f 100644 --- a/staging/src/k8s.io/dynamic-resource-allocation/structured/internal/experimental/consumable_capacity.go +++ b/staging/src/k8s.io/dynamic-resource-allocation/structured/internal/experimental/consumable_capacity.go @@ -19,6 +19,7 @@ package experimental import ( "errors" + inf "gopkg.in/inf.v0" resourceapi "k8s.io/api/resource/v1" "k8s.io/apimachinery/pkg/api/resource" "k8s.io/utils/ptr" @@ -118,17 +119,30 @@ func roundUpRange(requestedVal *resource.Quantity, validRange *resourceapi.Capac if validRange.Step == nil { return *requestedVal } - requestedInt64 := requestedVal.Value() - step := validRange.Step.Value() - min := validRange.Min.Value() - added := (requestedInt64 - min) - n := added / step - mod := added % step - if mod != 0 { - n += 1 - } - val := min + step*n - return *resource.NewQuantity(val, resource.BinarySI) + // Compute how far requested is above min, then round up to the next + // multiple of step. All arithmetic uses inf.Dec via Quantity.AsDec() so + // it is exact at any scale and cannot overflow. + span := requestedVal.DeepCopy() + span.Sub(*validRange.Min) // span = requested - min + stepCopy := validRange.Step.DeepCopy() + stepDec := stepCopy.AsDec() + minCopy := validRange.Min.DeepCopy() + minDec := minCopy.AsDec() + + // n = ceil(span / step) + var nDec, resultDec, mulDec inf.Dec + n := nDec.QuoRound(span.AsDec(), stepDec, 0, inf.RoundUp) + + // result = min + n*step, computed entirely in Dec. + resultDec.Add(minDec, mulDec.Mul(n, stepDec)) + + // If the result is a whole number, use NewQuantity to keep the representation compact + // and compatible with quantities parsed from whole-number strings. + format := validRange.Step.Format + if v, ok := resultDec.Unscaled(); ok && resultDec.Scale() == 0 { + return *resource.NewQuantity(v, format) + } + return *resource.NewDecimalQuantity(resultDec, format) } // roundUpValidValues returns the first value in validValues that is greater than or equal to requestedVal. @@ -188,13 +202,18 @@ func violateValidRange(requestedVal resource.Quantity, validRange resourceapi.Ca return true } if validRange.Step != nil { - requestedInt64 := requestedVal.Value() - step := validRange.Step.Value() - min := validRange.Min.Value() - added := (requestedInt64 - min) - mod := added % step - // must be a multiply of step - if mod != 0 { + // span = requested - min; check span is a whole-number multiple of step. + // DeepCopy before AsDec to avoid mutating the originals. + span := requestedVal.DeepCopy() + span.Sub(*validRange.Min) + spanDec := span.AsDec() + stepCopy := validRange.Step.DeepCopy() + stepDec := stepCopy.AsDec() + // n = floor(span / step); if n*step != span then it's not aligned. + var nDec, remDec, mulDec inf.Dec + n := nDec.QuoRound(spanDec, stepDec, 0, inf.RoundDown) + remainder := remDec.Sub(spanDec, mulDec.Mul(n, stepDec)) + if remainder.Sign() != 0 { return true } } diff --git a/staging/src/k8s.io/dynamic-resource-allocation/structured/internal/experimental/consumable_capacity_test.go b/staging/src/k8s.io/dynamic-resource-allocation/structured/internal/experimental/consumable_capacity_test.go index eccc709e6c25d..9953ac2937539 100644 --- a/staging/src/k8s.io/dynamic-resource-allocation/structured/internal/experimental/consumable_capacity_test.go +++ b/staging/src/k8s.io/dynamic-resource-allocation/structured/internal/experimental/consumable_capacity_test.go @@ -37,6 +37,12 @@ var ( one = resource.MustParse("1") two = resource.MustParse("2") three = resource.MustParse("3") + + pointTwoFive = resource.MustParse("250m") + pointFour = resource.MustParse("400m") + pointThree = resource.MustParse("300m") + pointTwo = resource.MustParse("200m") + pointOne = resource.MustParse("100m") ) func deviceConsumedCapacity(deviceID DeviceID) DeviceConsumedCapacity { @@ -155,6 +161,31 @@ func testViolateCapacityRequestPolicy(t *testing.T) { }, true, }, + // fractional step: min=0.2, step=0.1, max=1 + "fractional step aligned (0.3 = min+1*step)": { + pointThree, + &resourceapi.CapacityRequestPolicy{ + Default: &pointTwo, + ValidRange: &resourceapi.CapacityRequestPolicyRange{ + Min: &pointTwo, + Max: &one, + Step: &pointOne, + }, + }, + false, + }, + "fractional step not aligned (0.25 is not a multiple of 0.1 from 0.2)": { + pointTwoFive, + &resourceapi.CapacityRequestPolicy{ + Default: &pointTwo, + ValidRange: &resourceapi.CapacityRequestPolicyRange{ + Min: &pointTwo, + Max: &one, + Step: &pointOne, + }, + }, + true, + }, } for name, tc := range testcases { t.Run(name, func(t *testing.T) { @@ -210,6 +241,34 @@ func testCalculateConsumedCapacity(t *testing.T) { &resourceapi.CapacityRequestPolicy{Default: ptr.To(one), ValidRange: &resourceapi.CapacityRequestPolicyRange{Min: ptr.To(one), Step: ptr.To(one.DeepCopy())}}, two, }, + // fractional step: min=0.2, step=0.1, max=1; request=0.25; rounds up to 0.3 + "fractional step round up (0.25 to 0.3)": { + &pointTwoFive, + resource.MustParse("1"), + &resourceapi.CapacityRequestPolicy{ + Default: &pointTwo, + ValidRange: &resourceapi.CapacityRequestPolicyRange{ + Min: &pointTwo, + Max: &one, + Step: &pointOne, + }, + }, + resource.MustParse("300m"), + }, + // fractional step: request already aligned; no rounding + "fractional step already aligned (0.4 = min+2*step)": { + &pointFour, + resource.MustParse("1"), + &resourceapi.CapacityRequestPolicy{ + Default: &pointTwo, + ValidRange: &resourceapi.CapacityRequestPolicyRange{ + Min: &pointTwo, + Max: &one, + Step: &pointOne, + }, + }, + resource.MustParse("400m"), + }, "valid value in set": { &two, three, diff --git a/staging/src/k8s.io/dynamic-resource-allocation/structured/internal/incubating/consumable_capacity.go b/staging/src/k8s.io/dynamic-resource-allocation/structured/internal/incubating/consumable_capacity.go index 2bb3239f5876e..289fa02a946d2 100644 --- a/staging/src/k8s.io/dynamic-resource-allocation/structured/internal/incubating/consumable_capacity.go +++ b/staging/src/k8s.io/dynamic-resource-allocation/structured/internal/incubating/consumable_capacity.go @@ -19,6 +19,7 @@ package incubating import ( "errors" + inf "gopkg.in/inf.v0" resourceapi "k8s.io/api/resource/v1" "k8s.io/apimachinery/pkg/api/resource" "k8s.io/utils/ptr" @@ -118,17 +119,30 @@ func roundUpRange(requestedVal *resource.Quantity, validRange *resourceapi.Capac if validRange.Step == nil { return *requestedVal } - requestedInt64 := requestedVal.Value() - step := validRange.Step.Value() - min := validRange.Min.Value() - added := (requestedInt64 - min) - n := added / step - mod := added % step - if mod != 0 { - n += 1 - } - val := min + step*n - return *resource.NewQuantity(val, resource.BinarySI) + // Compute how far requested is above min, then round up to the next + // multiple of step. All arithmetic uses inf.Dec via Quantity.AsDec() so + // it is exact at any scale and cannot overflow. + span := requestedVal.DeepCopy() + span.Sub(*validRange.Min) // span = requested - min + stepCopy := validRange.Step.DeepCopy() + stepDec := stepCopy.AsDec() + minCopy := validRange.Min.DeepCopy() + minDec := minCopy.AsDec() + + // n = ceil(span / step) + var nDec, resultDec, mulDec inf.Dec + n := nDec.QuoRound(span.AsDec(), stepDec, 0, inf.RoundUp) + + // result = min + n*step, computed entirely in Dec. + resultDec.Add(minDec, mulDec.Mul(n, stepDec)) + + // If the result is a whole number, use NewQuantity to keep the representation compact + // and compatible with quantities parsed from whole-number strings. + format := validRange.Step.Format + if v, ok := resultDec.Unscaled(); ok && resultDec.Scale() == 0 { + return *resource.NewQuantity(v, format) + } + return *resource.NewDecimalQuantity(resultDec, format) } // roundUpValidValues returns the first value in validValues that is greater than or equal to requestedVal. @@ -188,13 +202,18 @@ func violateValidRange(requestedVal resource.Quantity, validRange resourceapi.Ca return true } if validRange.Step != nil { - requestedInt64 := requestedVal.Value() - step := validRange.Step.Value() - min := validRange.Min.Value() - added := (requestedInt64 - min) - mod := added % step - // must be a multiply of step - if mod != 0 { + // span = requested - min; check span is a whole-number multiple of step. + // DeepCopy before AsDec to avoid mutating the originals. + span := requestedVal.DeepCopy() + span.Sub(*validRange.Min) + spanDec := span.AsDec() + stepCopy := validRange.Step.DeepCopy() + stepDec := stepCopy.AsDec() + // n = floor(span / step); if n*step != span then it's not aligned. + var nDec, remDec, mulDec inf.Dec + n := nDec.QuoRound(spanDec, stepDec, 0, inf.RoundDown) + remainder := remDec.Sub(spanDec, mulDec.Mul(n, stepDec)) + if remainder.Sign() != 0 { return true } } diff --git a/staging/src/k8s.io/dynamic-resource-allocation/structured/internal/incubating/consumable_capacity_test.go b/staging/src/k8s.io/dynamic-resource-allocation/structured/internal/incubating/consumable_capacity_test.go index df618737649c0..32b1e5840889b 100644 --- a/staging/src/k8s.io/dynamic-resource-allocation/structured/internal/incubating/consumable_capacity_test.go +++ b/staging/src/k8s.io/dynamic-resource-allocation/structured/internal/incubating/consumable_capacity_test.go @@ -37,6 +37,12 @@ var ( one = resource.MustParse("1") two = resource.MustParse("2") three = resource.MustParse("3") + + pointTwoFive = resource.MustParse("250m") + pointFour = resource.MustParse("400m") + pointThree = resource.MustParse("300m") + pointTwo = resource.MustParse("200m") + pointOne = resource.MustParse("100m") ) func deviceConsumedCapacity(deviceID DeviceID) DeviceConsumedCapacity { @@ -155,6 +161,31 @@ func testViolateCapacityRequestPolicy(t *testing.T) { }, true, }, + // fractional step: min=0.2, step=0.1, max=1 + "fractional step aligned (0.3 = min+1*step)": { + pointThree, + &resourceapi.CapacityRequestPolicy{ + Default: &pointTwo, + ValidRange: &resourceapi.CapacityRequestPolicyRange{ + Min: &pointTwo, + Max: &one, + Step: &pointOne, + }, + }, + false, + }, + "fractional step not aligned (0.25 is not a multiple of 0.1 from 0.2)": { + pointTwoFive, + &resourceapi.CapacityRequestPolicy{ + Default: &pointTwo, + ValidRange: &resourceapi.CapacityRequestPolicyRange{ + Min: &pointTwo, + Max: &one, + Step: &pointOne, + }, + }, + true, + }, } for name, tc := range testcases { t.Run(name, func(t *testing.T) { @@ -210,6 +241,34 @@ func testCalculateConsumedCapacity(t *testing.T) { &resourceapi.CapacityRequestPolicy{Default: ptr.To(one), ValidRange: &resourceapi.CapacityRequestPolicyRange{Min: ptr.To(one), Step: ptr.To(one.DeepCopy())}}, two, }, + // fractional step: min=0.2, step=0.1, max=1; request=0.25; rounds up to 0.3 + "fractional step round up (0.25 to 0.3)": { + &pointTwoFive, + resource.MustParse("1"), + &resourceapi.CapacityRequestPolicy{ + Default: &pointTwo, + ValidRange: &resourceapi.CapacityRequestPolicyRange{ + Min: &pointTwo, + Max: &one, + Step: &pointOne, + }, + }, + resource.MustParse("300m"), + }, + // fractional step: request already aligned; no rounding + "fractional step already aligned (0.4 = min+2*step)": { + &pointFour, + resource.MustParse("1"), + &resourceapi.CapacityRequestPolicy{ + Default: &pointTwo, + ValidRange: &resourceapi.CapacityRequestPolicyRange{ + Min: &pointTwo, + Max: &one, + Step: &pointOne, + }, + }, + resource.MustParse("400m"), + }, "valid value in set": { &two, three, From 14d8e1a90e3c2d36d96c700305646cce426cf785 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Mon, 6 Jul 2026 05:35:30 +0000 Subject: [PATCH 2/2] automated updates from make update --- go.mod | 2 +- staging/src/k8s.io/dynamic-resource-allocation/go.mod | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/go.mod b/go.mod index 4a7c8cc243ce0..68521ce7c96d2 100644 --- a/go.mod +++ b/go.mod @@ -81,6 +81,7 @@ require ( google.golang.org/protobuf v1.36.12-0.20260120151049-f2248ac996af gopkg.in/evanphx/json-patch.v4 v4.13.0 gopkg.in/go-jose/go-jose.v2 v2.6.3 + gopkg.in/inf.v0 v0.9.1 k8s.io/api v0.0.0 k8s.io/apiextensions-apiserver v0.0.0 k8s.io/apimachinery v0.0.0 @@ -211,7 +212,6 @@ require ( golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f // indirect golang.org/x/mod v0.35.0 // indirect google.golang.org/genproto/googleapis/api v0.0.0-20260526163538-3dc84a4a5aaa // indirect - gopkg.in/inf.v0 v0.9.1 // indirect gopkg.in/natefinch/lumberjack.v2 v2.2.1 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect k8s.io/gengo/v2 v2.0.0-20260408192533-25e2208e0dc3 // indirect diff --git a/staging/src/k8s.io/dynamic-resource-allocation/go.mod b/staging/src/k8s.io/dynamic-resource-allocation/go.mod index 0c9cf97b168bd..423cd8dbad3e8 100644 --- a/staging/src/k8s.io/dynamic-resource-allocation/go.mod +++ b/staging/src/k8s.io/dynamic-resource-allocation/go.mod @@ -15,6 +15,7 @@ require ( github.com/stretchr/testify v1.11.1 go.etcd.io/etcd/client/pkg/v3 v3.7.0-rc.0 google.golang.org/grpc v1.81.1 + gopkg.in/inf.v0 v0.9.1 k8s.io/api v0.0.0 k8s.io/apimachinery v0.0.0 k8s.io/apiserver v0.0.0 @@ -82,7 +83,6 @@ require ( google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa // indirect google.golang.org/protobuf v1.36.12-0.20260120151049-f2248ac996af // indirect gopkg.in/evanphx/json-patch.v4 v4.13.0 // indirect - gopkg.in/inf.v0 v0.9.1 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect k8s.io/kube-openapi v0.0.0-20260618221249-bc653b64f974 // indirect sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730 // indirect