Files
Noureldin 8c02d3141f DRA resource pool status request (#140170)
* Fix RPSR device accounting: cap-at-1 per device, skip AdminAccess, count taints as unavailable

Signed-off-by: Nour <nurmn3m@gmail.com>

* Count DeviceTaintRule-tainted devices as unavailable in RPSR controller

Signed-off-by: Nour <nurmn3m@gmail.com>

* Add RPSR API types: v1 PartitionTypeAttribute and v1alpha3 PoolStatus partition/counter/shareable views

Signed-off-by: Nour <nurmn3m@gmail.com>

* Regenerate for RPSR partition/counter/shareable API types

Signed-off-by: Nour <nurmn3m@gmail.com>

* Add RPSR validation and feature-gate drop for PartitionTypeAttribute

Signed-off-by: Nour <nurmn3m@gmail.com>

* Compute partition/counter/shareable views in RPSR controller

Signed-off-by: Nour <nurmn3m@gmail.com>

* Add RPSR integration tests for partition and shareable views

Signed-off-by: Nour <nurmn3m@gmail.com>

* Fix RPSR e2e view tests: own context and complete pool matchers

Signed-off-by: Nour <nurmn3m@gmail.com>

* Fix tests

Signed-off-by: Nour <nurmn3m@gmail.com>

* Migrate RPSR controller to v1 DeviceTaintRule API

Signed-off-by: Nour <nurmn3m@gmail.com>

* Fix linter test && update generated files

Signed-off-by: Nour <nurmn3m@gmail.com>

* Address API review on ResourcePoolStatusRequest status types

Signed-off-by: Nour <nurmn3m@gmail.com>

* Adding tests to cover missing parts like consumable capacity

Signed-off-by: Nour <nurmn3m@gmail.com>

* Address API review: gate partitionTypeAttribute and add request-side selection

Signed-off-by: Nour <nurmn3m@gmail.com>

* Gate partitionTypeAttribute declaratively and consolidate tests

Signed-off-by: Nour <nurmn3m@gmail.com>

* Scope partitionTypeAttribute to slices with partitionable devices

Signed-off-by: Nour <nurmn3m@gmail.com>

* Group partitionSummary per slice attribute and self-describe each entry

Signed-off-by: Nour <nurmn3m@gmail.com>

* Fix RPSR integration fixture to declare partitionTypeAttribute only on the device slice

Signed-off-by: Nour <nurmn3m@gmail.com>

* Rename request default attribute, scope slice doc, pair required markers

Signed-off-by: Nour <nurmn3m@gmail.com>

* Make shareable capacity Quantity fields required pointers, hoist attribute domain-strip

Signed-off-by: Nour <nurmn3m@gmail.com>

* Regenerate declarative validations for partitionTypeAttribute to fix verify test

Signed-off-by: Nour <nurmn3m@gmail.com>

---------

Signed-off-by: Nour <nurmn3m@gmail.com>
2026-07-22 15:05:52 +00:00

479 lines
18 KiB
Go

/*
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 resourcepoolstatusrequest
import (
"strings"
"testing"
resourcev1 "k8s.io/api/resource/v1"
"k8s.io/apimachinery/pkg/api/resource"
"k8s.io/utils/ptr"
)
func qty(s string) resource.Quantity { return resource.MustParse(s) }
func counterSet(name string, counters map[string]string) resourcev1.CounterSet {
c := make(map[string]resourcev1.Counter, len(counters))
for k, v := range counters {
c[k] = resourcev1.Counter{Value: qty(v)}
}
return resourcev1.CounterSet{Name: name, Counters: c}
}
func consumes(set string, counters map[string]string) resourcev1.DeviceCounterConsumption {
c := make(map[string]resourcev1.Counter, len(counters))
for k, v := range counters {
c[k] = resourcev1.Counter{Value: qty(v)}
}
return resourcev1.DeviceCounterConsumption{CounterSet: set, Counters: c}
}
func inUseSet(names ...string) map[string]struct{} {
s := make(map[string]struct{}, len(names))
for _, n := range names {
s[n] = struct{}{}
}
return s
}
// partitionDevice builds a counter-consuming device with its grouping attribute
// and partition type already resolved (as the controller does before calling
// the view functions).
func partitionDevice(name, partitionType string, cost ...resourcev1.DeviceCounterConsumption) deviceRecord {
return deviceRecord{
name: name,
partitionAttr: "gpu.example.com/profile",
partitionType: partitionType,
hasPartitionType: true,
consumesCounters: cost,
}
}
func TestComputePartitionSummary(t *testing.T) {
// One 80Gi counter set. Full costs 80Gi, Half costs 40Gi.
sc := []resourcev1.CounterSet{counterSet("gpu-0", map[string]string{"memory": "80Gi"})}
testCases := map[string]struct {
devices []deviceRecord
inUse map[string]struct{}
want map[string]int32 // type -> allocatable
total map[string]int32 // type -> total
wantErr string
}{
"all-fresh-independent-per-type": {
devices: []deviceRecord{
partitionDevice("full-0", "Full", consumes("gpu-0", map[string]string{"memory": "80Gi"})),
partitionDevice("half-0", "Half", consumes("gpu-0", map[string]string{"memory": "40Gi"})),
partitionDevice("half-1", "Half", consumes("gpu-0", map[string]string{"memory": "40Gi"})),
},
inUse: inUseSet(),
// Each type measured independently against the full 80Gi baseline.
want: map[string]int32{"Full": 1, "Half": 2},
total: map[string]int32{"Full": 1, "Half": 2},
},
"in-use-full-consumes-counter": {
devices: []deviceRecord{
partitionDevice("full-0", "Full", consumes("gpu-0", map[string]string{"memory": "80Gi"})),
partitionDevice("half-0", "Half", consumes("gpu-0", map[string]string{"memory": "40Gi"})),
partitionDevice("half-1", "Half", consumes("gpu-0", map[string]string{"memory": "40Gi"})),
},
inUse: inUseSet("full-0"),
// full-0 in use drains the counter; no headroom for either type.
want: map[string]int32{"Full": 0, "Half": 0},
total: map[string]int32{"Full": 1, "Half": 2},
},
"fresh-device-clamp-binds-before-counter": {
devices: []deviceRecord{
partitionDevice("full-0", "Full", consumes("gpu-1", map[string]string{"memory": "80Gi"})),
partitionDevice("full-1", "Full", consumes("gpu-1", map[string]string{"memory": "80Gi"})),
partitionDevice("full-2", "Full", consumes("gpu-1", map[string]string{"memory": "80Gi"})),
},
inUse: inUseSet(),
// 800Gi counter fits 10 Fulls, but only 3 fresh devices exist.
want: map[string]int32{"Full": 3},
total: map[string]int32{"Full": 3},
},
// A counter-consuming device whose slice resolved a grouping attribute
// but that lacks the attribute value is flagged; a non-partition device
// missing it would instead be skipped.
"missing-attribute": {
devices: []deviceRecord{
partitionDevice("full-0", "Full", consumes("gpu-0", map[string]string{"memory": "80Gi"})),
{
name: "mystery",
partitionAttr: "gpu.example.com/profile",
consumesCounters: []resourcev1.DeviceCounterConsumption{consumes("gpu-0", map[string]string{"memory": "40Gi"})},
hasPartitionType: false,
},
},
inUse: inUseSet(),
wantErr: prefixPartitionTypeMissing,
},
"cost-mismatch-within-type": {
devices: []deviceRecord{
partitionDevice("half-0", "Half", consumes("gpu-0", map[string]string{"memory": "40Gi"})),
partitionDevice("half-1", "Half", consumes("gpu-0", map[string]string{"memory": "50Gi"})),
},
inUse: inUseSet(),
wantErr: prefixPartitionCostMismatch,
},
}
for name, tc := range testCases {
t.Run(name, func(t *testing.T) {
in := poolViewInput{
driver: "gpu.example.com",
poolName: "pool-0",
devices: tc.devices,
sharedCounters: chooseCounters(name, sc),
inUse: tc.inUse,
}
got, gotErr := computePartitionSummary(in)
if tc.wantErr != "" {
if !strings.HasPrefix(gotErr, tc.wantErr) {
t.Fatalf("want error prefix %q, got %q", tc.wantErr, gotErr)
}
return
}
if gotErr != "" {
t.Fatalf("unexpected error: %s", gotErr)
}
for _, p := range got {
if want, ok := tc.want[p.Type]; ok && ptr.Deref(p.Allocatable, 0) != want {
t.Errorf("type %s: allocatable = %d, want %d", p.Type, ptr.Deref(p.Allocatable, 0), want)
}
if want, ok := tc.total[p.Type]; ok && ptr.Deref(p.Total, 0) != want {
t.Errorf("type %s: total = %d, want %d", p.Type, ptr.Deref(p.Total, 0), want)
}
}
if len(got) != len(tc.total) {
t.Errorf("got %d partition types, want %d", len(got), len(tc.total))
}
})
}
}
// chooseCounters gives the 800Gi baseline only to the fresh-device-clamp case.
func chooseCounters(name string, dflt []resourcev1.CounterSet) []resourcev1.CounterSet {
if strings.Contains(name, "clamp") {
return []resourcev1.CounterSet{counterSet("gpu-1", map[string]string{"memory": "800Gi"})}
}
return dflt
}
// A device drawing from two counter sets is bounded by the tighter one.
func TestComputePartitionSummary_MultiCounterSet(t *testing.T) {
sc := []resourcev1.CounterSet{
counterSet("mem", map[string]string{"memory": "160Gi"}),
counterSet("cores", map[string]string{"sm": "8"}),
}
full := func(name string) deviceRecord {
return partitionDevice(name, "Full",
consumes("mem", map[string]string{"memory": "80Gi"}),
consumes("cores", map[string]string{"sm": "8"}),
)
}
in := poolViewInput{
driver: "gpu.example.com", poolName: "p", sharedCounters: sc,
devices: []deviceRecord{full("full-0"), full("full-1")},
inUse: inUseSet(),
}
got, err := computePartitionSummary(in)
if err != "" {
t.Fatalf("unexpected error: %s", err)
}
// memory allows 2 (160/80), but the sm counter allows only 1 (8/8) -> min is 1.
if len(got) != 1 || ptr.Deref(got[0].Allocatable, 0) != 1 {
t.Errorf("allocatable = %+v, want 1 (bound by the sm counter)", got)
}
}
// A device that is both partition-typed and shareable yields both views.
func TestComputePoolViews_Hybrid(t *testing.T) {
in := poolViewInput{
driver: "gpu.example.com", poolName: "p",
sharedCounters: []resourcev1.CounterSet{counterSet("gpu-0", map[string]string{"memory": "80Gi"})},
devices: []deviceRecord{{
name: "full-0",
partitionAttr: "gpu.example.com/profile",
partitionType: "Full",
hasPartitionType: true,
consumesCounters: []resourcev1.DeviceCounterConsumption{consumes("gpu-0", map[string]string{"memory": "80Gi"})},
allowMultiple: true,
capacity: map[resourcev1.QualifiedName]resourcev1.DeviceCapacity{"memory": {Value: qty("80Gi")}},
}},
inUse: inUseSet(),
}
ps, sh, err := computePoolViews(in)
if err != "" {
t.Fatalf("unexpected error: %s", err)
}
if len(ps) != 1 || sh == nil {
t.Errorf("hybrid: want partitionSummary set and shareableSummary set; got ps=%d sh=%v", len(ps), sh)
}
if sh != nil && ptr.Deref(sh.FullyAvailableDevices, 0) != 1 {
t.Errorf("shareable fullyAvailable = %d, want 1", ptr.Deref(sh.FullyAvailableDevices, 0))
}
}
func TestComputeShareableSummary(t *testing.T) {
devices := []deviceRecord{
{
name: "shareable-0",
allowMultiple: true,
capacity: map[resourcev1.QualifiedName]resourcev1.DeviceCapacity{"memory": {Value: qty("40Gi")}},
},
{
name: "shareable-1",
allowMultiple: true,
capacity: map[resourcev1.QualifiedName]resourcev1.DeviceCapacity{"memory": {Value: qty("40Gi")}},
},
{name: "plain", allowMultiple: false},
}
in := poolViewInput{
driver: "gpu.example.com",
poolName: "pool-0",
devices: devices,
inUse: inUseSet("shareable-0"),
consumedCapacity: map[resourcev1.QualifiedName]resource.Quantity{"memory": qty("10Gi")},
}
got, err := computeShareableSummary(in)
if err != "" {
t.Fatalf("unexpected error: %s", err)
}
if got == nil {
t.Fatal("expected a shareable summary")
}
if ptr.Deref(got.FullyAvailableDevices, 0) != 1 {
t.Errorf("fullyAvailableDevices = %d, want 1", ptr.Deref(got.FullyAvailableDevices, 0))
}
if ptr.Deref(got.PartiallyAvailableDevices, 0) != 1 {
t.Errorf("partiallyAvailableDevices = %d, want 1", ptr.Deref(got.PartiallyAvailableDevices, 0))
}
if len(got.Capacity) != 1 {
t.Fatalf("want 1 capacity key, got %d", len(got.Capacity))
}
cap := got.Capacity[0]
if cap.Total.String() != "80Gi" || cap.Consumed.String() != "10Gi" || cap.Available.Cmp(qty("70Gi")) != 0 {
t.Errorf("capacity = %+v, want total=80Gi consumed=10Gi available=70Gi", cap)
}
}
// Shareable devices advertising multiple capacities: each key is summed across
// devices and reported independently, sorted by name.
func TestComputeShareableSummary_MultipleCapacities(t *testing.T) {
devices := []deviceRecord{
{
name: "shareable-0",
allowMultiple: true,
capacity: map[resourcev1.QualifiedName]resourcev1.DeviceCapacity{
"memory": {Value: qty("40Gi")},
"cores": {Value: qty("4")},
},
},
{
name: "shareable-1",
allowMultiple: true,
capacity: map[resourcev1.QualifiedName]resourcev1.DeviceCapacity{
"memory": {Value: qty("40Gi")},
"cores": {Value: qty("4")},
},
},
}
in := poolViewInput{
driver: "gpu.example.com",
poolName: "pool-0",
devices: devices,
inUse: inUseSet("shareable-0"),
consumedCapacity: map[resourcev1.QualifiedName]resource.Quantity{
"memory": qty("10Gi"),
"cores": qty("1"),
},
}
got, err := computeShareableSummary(in)
if err != "" {
t.Fatalf("unexpected error: %s", err)
}
if got == nil {
t.Fatal("expected a shareable summary")
}
if ptr.Deref(got.FullyAvailableDevices, 0) != 1 || ptr.Deref(got.PartiallyAvailableDevices, 0) != 1 {
t.Errorf("full/partial = %d/%d, want 1/1", ptr.Deref(got.FullyAvailableDevices, 0), ptr.Deref(got.PartiallyAvailableDevices, 0))
}
byKey := map[string][3]string{}
for _, c := range got.Capacity {
byKey[c.Name] = [3]string{c.Total.String(), c.Consumed.String(), c.Available.String()}
}
if want := [3]string{"8", "1", "7"}; byKey["cores"] != want {
t.Errorf("cores {total,consumed,available} = %v, want %v", byKey["cores"], want)
}
if want := [3]string{"80Gi", "10Gi", "70Gi"}; byKey["memory"] != want {
t.Errorf("memory {total,consumed,available} = %v, want %v", byKey["memory"], want)
}
}
func TestComputeShareableSummary_NoShareableDevices(t *testing.T) {
in := poolViewInput{devices: []deviceRecord{{name: "plain"}}, inUse: inUseSet()}
got, err := computeShareableSummary(in)
if err != "" || got != nil {
t.Errorf("want nil summary and no error, got %+v / %q", got, err)
}
}
// A resolved grouping attribute produces a partitionSummary; without one, a
// partitionable pool reports no partition view (option 2, no counterSets fallback).
func TestComputePoolViews_PartitionAttributeGatesView(t *testing.T) {
sc := []resourcev1.CounterSet{counterSet("gpu-0", map[string]string{"memory": "80Gi"})}
// With a grouping attribute -> partitionSummary.
typed := poolViewInput{
driver: "gpu.example.com", poolName: "p", sharedCounters: sc,
devices: []deviceRecord{partitionDevice("d0", "Full", consumes("gpu-0", map[string]string{"memory": "80Gi"}))},
inUse: inUseSet(),
}
ps, _, err := computePoolViews(typed)
if err != "" {
t.Fatalf("typed: unexpected error %s", err)
}
if len(ps) == 0 {
t.Errorf("typed pool: want partitionSummary set, got ps=%d", len(ps))
}
// Without a grouping attribute -> no partition view.
noAttr := poolViewInput{
driver: "gpu.example.com", poolName: "p", sharedCounters: sc,
devices: []deviceRecord{{name: "d0", consumesCounters: []resourcev1.DeviceCounterConsumption{consumes("gpu-0", map[string]string{"memory": "10Gi"})}}},
inUse: inUseSet(),
}
ps2, _, err := computePoolViews(noAttr)
if err != "" {
t.Fatalf("no-attr: unexpected error %s", err)
}
if ps2 != nil {
t.Errorf("no-attr pool: want no partition view, got ps=%d", len(ps2))
}
}
func TestResolveDevicePartitions(t *testing.T) {
const driver = "gpu.example.com"
// A device carrying profile=Full and shape=Small, so either attribute resolves.
deviceAttrs := map[resourcev1.QualifiedName]resourcev1.DeviceAttribute{
"gpu.example.com/profile": {StringValue: ptr.To("Full")},
"gpu.example.com/shape": {StringValue: ptr.To("Small")},
}
testCases := map[string]struct {
requestAttr *string
devices []deviceRecord
wantAttr []string // resolved partitionAttr per device
wantType []string // resolved partitionType per device ("" when unresolved)
}{
"slice declaration wins over request default": {
requestAttr: ptr.To("gpu.example.com/shape"),
devices: []deviceRecord{{name: "d0", partitionAttr: "gpu.example.com/profile", attributes: deviceAttrs}},
wantAttr: []string{"gpu.example.com/profile"},
wantType: []string{"Full"},
},
"request default fills devices when no slice declares": {
requestAttr: ptr.To("gpu.example.com/shape"),
devices: []deviceRecord{{name: "d0", attributes: deviceAttrs}},
wantAttr: []string{"gpu.example.com/shape"},
wantType: []string{"Small"},
},
"request default ignored when any slice declares": {
requestAttr: ptr.To("gpu.example.com/shape"),
devices: []deviceRecord{
{name: "d0", partitionAttr: "gpu.example.com/profile", attributes: deviceAttrs},
{name: "d1", attributes: deviceAttrs},
},
wantAttr: []string{"gpu.example.com/profile", ""},
wantType: []string{"Full", ""},
},
// liggitt's case: slices declaring differing attributes are legitimate,
// so each device keeps its own rather than being flagged a conflict.
"differing slice attributes are each kept": {
devices: []deviceRecord{
{name: "d0", partitionAttr: "gpu.example.com/profile", attributes: deviceAttrs},
{name: "d1", partitionAttr: "gpu.example.com/shape", attributes: deviceAttrs},
},
wantAttr: []string{"gpu.example.com/profile", "gpu.example.com/shape"},
wantType: []string{"Full", "Small"},
},
"neither slice nor request names one": {
devices: []deviceRecord{{name: "d0", attributes: deviceAttrs}},
wantAttr: []string{""},
wantType: []string{""},
},
}
for name, tc := range testCases {
t.Run(name, func(t *testing.T) {
in := poolViewInput{driver: driver, devices: tc.devices}
resolveDevicePartitions(tc.requestAttr, &in)
for i := range in.devices {
d := in.devices[i]
if d.partitionAttr != tc.wantAttr[i] {
t.Errorf("device %d: partitionAttr = %q, want %q", i, d.partitionAttr, tc.wantAttr[i])
}
gotType := ""
if d.hasPartitionType {
gotType = d.partitionType
}
if gotType != tc.wantType[i] {
t.Errorf("device %d: type = %q (has=%v), want %q", i, d.partitionType, d.hasPartitionType, tc.wantType[i])
}
}
})
}
}
func TestResolvePartitionType(t *testing.T) {
attrs := map[resourcev1.QualifiedName]resourcev1.DeviceAttribute{
"profile": {StringValue: ptr.To("Full")},
"other.com/x": {StringValue: ptr.To("ignore")},
"gpu.example.com/numeric-key": {IntValue: ptr.To(int64(3))},
}
// Bare "profile" resolves against the driver domain.
if v, ok := resolvePartitionType("gpu.example.com", attrs, "gpu.example.com/profile"); !ok || v != "Full" {
t.Errorf("driver-domain resolution: got (%q,%v), want (Full,true)", v, ok)
}
// A non-string attribute is treated as absent.
if _, ok := resolvePartitionType("gpu.example.com", attrs, "gpu.example.com/numeric-key"); ok {
t.Error("non-string attribute should resolve as absent")
}
// Unknown attribute.
if _, ok := resolvePartitionType("gpu.example.com", attrs, "gpu.example.com/missing"); ok {
t.Error("missing attribute should resolve as absent")
}
// A foreign-domain name only matches an explicit key, never a bare one.
if v, ok := resolvePartitionType("gpu.example.com", attrs, "other.com/x"); !ok || v != "ignore" {
t.Errorf("foreign-domain resolution: got (%q,%v), want (ignore,true)", v, ok)
}
// When both the explicit and bare form of the same attribute are present,
// the explicit key wins so the result does not depend on map iteration order.
both := map[resourcev1.QualifiedName]resourcev1.DeviceAttribute{
"profile": {StringValue: ptr.To("Bare")},
"gpu.example.com/profile": {StringValue: ptr.To("Explicit")},
}
for range 20 {
if v, ok := resolvePartitionType("gpu.example.com", both, "gpu.example.com/profile"); !ok || v != "Explicit" {
t.Fatalf("colliding keys: got (%q,%v), want (Explicit,true)", v, ok)
}
}
}