Skip to content

Commit 530666d

Browse files
committed
Correct Flavor Assignment in Pod Importer
1 parent 61c3245 commit 530666d

2 files changed

Lines changed: 201 additions & 53 deletions

File tree

cmd/importer/pod/import.go

Lines changed: 48 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import (
2121
"errors"
2222
"fmt"
2323
"maps"
24+
"slices"
2425
"time"
2526

2627
corev1 "k8s.io/api/core/v1"
@@ -44,6 +45,23 @@ import (
4445

4546
var realClock = clock.RealClock{}
4647

48+
type resourceNotCoveredError struct {
49+
Resource corev1.ResourceName
50+
ClusterQueue string
51+
}
52+
53+
func (e *resourceNotCoveredError) Error() string {
54+
return fmt.Sprintf("resource %q is not covered by ClusterQueue %q", e.Resource, e.ClusterQueue)
55+
}
56+
57+
func (e *resourceNotCoveredError) Is(target error) bool {
58+
t, ok := target.(*resourceNotCoveredError)
59+
if !ok {
60+
return false
61+
}
62+
return e.Resource == t.Resource && e.ClusterQueue == t.ClusterQueue
63+
}
64+
4765
func Import(ctx context.Context, c client.Client, importCache *cache.ImportCache, jobs uint) error {
4866
ch := make(chan corev1.Pod)
4967
go func() {
@@ -186,8 +204,21 @@ func admitWorkload(ctx context.Context, c client.Client, wl *kueue.Workload, cq
186204
},
187205
},
188206
}
189-
flv := cq.Spec.ResourceGroups[0].Flavors[0].Name
207+
208+
// sort requestedResources for deterministic handling order. This does not affect flavor assignments
209+
// (the Flavors map is order-independent), but it makes uncovered-resource errors deterministic
210+
// when multiple resources are missing coverage.
211+
requestedResources := make([]corev1.ResourceName, 0, len(info.TotalRequests[0].Requests))
190212
for r := range info.TotalRequests[0].Requests {
213+
requestedResources = append(requestedResources, r)
214+
}
215+
slices.Sort(requestedResources)
216+
217+
for _, r := range requestedResources {
218+
flv := resourceFlavorForResource(cq, r)
219+
if flv == "" {
220+
return false, &resourceNotCoveredError{Resource: r, ClusterQueue: cq.Name}
221+
}
191222
admission.PodSetAssignments[0].Flavors[r] = flv
192223
}
193224

@@ -229,3 +260,19 @@ func admitWorkload(ctx context.Context, c client.Client, wl *kueue.Workload, cq
229260

230261
return nil
231262
}
263+
264+
// resourceFlavorForResource returns the first flavor from the first resource group
265+
// that covers the requested resource. It skips groups with no flavors and returns
266+
// an empty string when no matching group exists.
267+
func resourceFlavorForResource(cq *kueue.ClusterQueue, resource corev1.ResourceName) kueue.ResourceFlavorReference {
268+
for _, rg := range cq.Spec.ResourceGroups {
269+
if len(rg.Flavors) == 0 {
270+
continue
271+
}
272+
273+
if slices.Contains(rg.CoveredResources, resource) {
274+
return rg.Flavors[0].Name
275+
}
276+
}
277+
return ""
278+
}

cmd/importer/pod/import_test.go

Lines changed: 153 additions & 52 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,31 @@ func TestImportNamespace(t *testing.T) {
7777
ResourceGroup(
7878
*utiltestingapi.MakeFlavorQuotas("f1").Resource(corev1.ResourceCPU, "1", "0").Obj())
7979

80+
baseGpuPodWrapper := testingpod.MakePod("pod-gpu", testingNamespace).
81+
UID("pod-gpu").
82+
Label(testingQueueLabel, "q1").
83+
Image("img", nil).
84+
Request(corev1.ResourceCPU, "1").
85+
Request(corev1.ResourceName("nvidia.com/gpu"), "1")
86+
baseGpuManagedPodWrapper := baseGpuPodWrapper.Clone().
87+
Label(controllerconstants.QueueLabel, "lq1").
88+
ManagedByKueueLabel()
89+
90+
cpuOnlyClusterQueue :=
91+
*utiltestingapi.MakeClusterQueue("cq1").
92+
ResourceGroup(
93+
*utiltestingapi.MakeFlavorQuotas("cpu-flavor").
94+
Resource(corev1.ResourceCPU, "10", "0").
95+
Obj(),
96+
)
97+
98+
cpuAndGpuClusterQueue :=
99+
cpuOnlyClusterQueue.Clone().ResourceGroup(
100+
*utiltestingapi.MakeFlavorQuotas("gpu-flavor").
101+
Resource(corev1.ResourceName("nvidia.com/gpu"), "10", "0").
102+
Obj(),
103+
)
104+
80105
podCmpOpts := cmp.Options{
81106
cmpopts.EquateEmpty(),
82107
cmpopts.IgnoreFields(metav1.ObjectMeta{}, "ResourceVersion"),
@@ -88,47 +113,33 @@ func TestImportNamespace(t *testing.T) {
88113
cmpopts.IgnoreFields(metav1.Condition{}, "ObservedGeneration", "LastTransitionTime"),
89114
}
90115

116+
defaultMappingRules := mapping.Rules{{
117+
Match: mapping.Match{Labels: map[string]string{testingQueueLabel: "q1"}},
118+
ToLocalQueue: "lq1",
119+
}}
120+
91121
cases := map[string]struct {
92-
pods []corev1.Pod
93-
clusterQueues []kueue.ClusterQueue
94-
localQueues []kueue.LocalQueue
95-
mapping mapping.Rules
96-
addLabels map[string]string
97-
98-
wantPods []corev1.Pod
99-
wantWorkloads []kueue.Workload
100-
wantError error
122+
pods []corev1.Pod
123+
clusterQueue kueue.ClusterQueue
124+
localQueue kueue.LocalQueue
125+
addLabels map[string]string
126+
wantPods []corev1.Pod
127+
wantWorkloads []kueue.Workload
128+
wantError error
129+
wantAdmissionOnlyCompare bool
101130
}{
102-
103131
"create one": {
104132
pods: []corev1.Pod{
105133
*basePodWrapper.DeepCopy(),
106134
},
107-
mapping: mapping.Rules{
108-
mapping.Rule{
109-
Match: mapping.Match{
110-
PriorityClassName: "",
111-
Labels: map[string]string{
112-
testingQueueLabel: "q1",
113-
},
114-
},
115-
ToLocalQueue: "lq1",
116-
},
117-
},
118-
localQueues: []kueue.LocalQueue{
119-
*baseLocalQueue.Obj(),
120-
},
121-
clusterQueues: []kueue.ClusterQueue{
122-
*baseClusterQueue.Obj(),
123-
},
124-
135+
localQueue: *baseLocalQueue.Obj(),
136+
clusterQueue: *baseClusterQueue.Obj(),
125137
wantPods: []corev1.Pod{
126138
*basePodWrapper.Clone().
127139
Label(controllerconstants.QueueLabel, "lq1").
128140
ManagedByKueueLabel().
129141
Obj(),
130142
},
131-
132143
wantWorkloads: []kueue.Workload{
133144
*baseWlWrapper.DeepCopy(),
134145
},
@@ -137,48 +148,67 @@ func TestImportNamespace(t *testing.T) {
137148
pods: []corev1.Pod{
138149
*basePodWrapper.DeepCopy(),
139150
},
140-
mapping: mapping.Rules{
141-
mapping.Rule{
142-
Match: mapping.Match{
143-
PriorityClassName: "",
144-
Labels: map[string]string{
145-
testingQueueLabel: "q1",
146-
},
147-
},
148-
ToLocalQueue: "lq1",
149-
},
150-
},
151-
localQueues: []kueue.LocalQueue{
152-
*baseLocalQueue.Obj(),
153-
},
154-
clusterQueues: []kueue.ClusterQueue{
155-
*baseClusterQueue.Obj(),
156-
},
151+
localQueue: *baseLocalQueue.Obj(),
152+
clusterQueue: *baseClusterQueue.Obj(),
157153
addLabels: map[string]string{
158154
"new.lbl": "val",
159155
},
160-
161156
wantPods: []corev1.Pod{
162157
*basePodWrapper.Clone().
163158
Label(controllerconstants.QueueLabel, "lq1").
164159
ManagedByKueueLabel().
165160
Label("new.lbl", "val").
166161
Obj(),
167162
},
168-
169163
wantWorkloads: []kueue.Workload{
170164
*baseWlWrapper.Clone().
171165
Label("new.lbl", "val").
172166
Obj(),
173167
},
174168
},
169+
"imports a pod requesting cpu and gpu and assigns each resource to its matching resource-group flavor": {
170+
pods: []corev1.Pod{
171+
*baseGpuPodWrapper.DeepCopy(),
172+
},
173+
localQueue: *baseLocalQueue.Obj(),
174+
clusterQueue: *cpuAndGpuClusterQueue.Obj(),
175+
wantPods: []corev1.Pod{
176+
*baseGpuManagedPodWrapper.DeepCopy(),
177+
},
178+
wantAdmissionOnlyCompare: true,
179+
wantWorkloads: []kueue.Workload{{
180+
Status: kueue.WorkloadStatus{Admission: &kueue.Admission{
181+
ClusterQueue: "cq1",
182+
PodSetAssignments: []kueue.PodSetAssignment{{
183+
Name: kueue.DefaultPodSetName,
184+
Flavors: map[corev1.ResourceName]kueue.ResourceFlavorReference{
185+
corev1.ResourceCPU: "cpu-flavor",
186+
corev1.ResourceName("nvidia.com/gpu"): "gpu-flavor",
187+
},
188+
}},
189+
}},
190+
}},
191+
},
192+
"returns an error and leaves admission unset when a requested resource is not covered by the cluster queue": {
193+
pods: []corev1.Pod{
194+
*baseGpuPodWrapper.DeepCopy(),
195+
},
196+
localQueue: *baseLocalQueue.Obj(),
197+
clusterQueue: *cpuOnlyClusterQueue.Obj(),
198+
wantError: &resourceNotCoveredError{Resource: corev1.ResourceName("nvidia.com/gpu"), ClusterQueue: "cq1"},
199+
wantPods: []corev1.Pod{
200+
*baseGpuManagedPodWrapper.DeepCopy(),
201+
},
202+
wantAdmissionOnlyCompare: true,
203+
wantWorkloads: []kueue.Workload{{}},
204+
},
175205
}
176206

177207
for name, tc := range cases {
178208
t.Run(name, func(t *testing.T) {
179209
podsList := corev1.PodList{Items: tc.pods}
180-
cqList := kueue.ClusterQueueList{Items: tc.clusterQueues}
181-
lqList := kueue.LocalQueueList{Items: tc.localQueues}
210+
cqList := kueue.ClusterQueueList{Items: []kueue.ClusterQueue{tc.clusterQueue}}
211+
lqList := kueue.LocalQueueList{Items: []kueue.LocalQueue{tc.localQueue}}
182212

183213
builder := utiltesting.NewClientBuilder().
184214
WithInterceptorFuncs(interceptor.Funcs{SubResourcePatch: utiltesting.TreatSSAAsStrategicMerge}).WithStatusSubresource(&kueue.Workload{}).
@@ -187,7 +217,7 @@ func TestImportNamespace(t *testing.T) {
187217
client := builder.Build()
188218
ctx, _ := utiltesting.ContextWithLog(t)
189219

190-
mpc, _ := cache.Load(ctx, client, []string{testingNamespace}, tc.mapping, tc.addLabels)
220+
mpc, _ := cache.Load(ctx, client, []string{testingNamespace}, defaultMappingRules, tc.addLabels)
191221
gotErr := Import(ctx, client, mpc, 8)
192222

193223
if diff := cmp.Diff(tc.wantError, gotErr, cmpopts.EquateErrors()); diff != "" {
@@ -207,9 +237,80 @@ func TestImportNamespace(t *testing.T) {
207237
if err != nil {
208238
t.Errorf("Unexpected list workloads error: %s", err)
209239
}
210-
if diff := cmp.Diff(tc.wantWorkloads, wlList.Items, wlCmpOpts...); diff != "" {
240+
if tc.wantAdmissionOnlyCompare {
241+
workloads := make([]kueue.Workload, 0, len(wlList.Items))
242+
for _, wl := range wlList.Items {
243+
workloads = append(workloads, kueue.Workload{Status: kueue.WorkloadStatus{Admission: wl.Status.Admission}})
244+
}
245+
if diff := cmp.Diff(
246+
tc.wantWorkloads,
247+
workloads,
248+
cmpopts.EquateEmpty(),
249+
cmpopts.IgnoreFields(kueue.PodSetAssignment{}, "ResourceUsage", "Count"),
250+
); diff != "" {
251+
t.Errorf("Unexpected workloads (-want/+got)\n%s", diff)
252+
}
253+
} else if diff := cmp.Diff(tc.wantWorkloads, wlList.Items, wlCmpOpts...); diff != "" {
211254
t.Errorf("Unexpected workloads (-want/+got)\n%s", diff)
212255
}
213256
})
214257
}
215258
}
259+
260+
func TestResourceFlavorForResource(t *testing.T) {
261+
cases := map[string]struct {
262+
clusterQueue *kueue.ClusterQueue
263+
resource corev1.ResourceName
264+
wantFlavor kueue.ResourceFlavorReference
265+
}{
266+
"returns the flavor from the matching resource group": {
267+
clusterQueue: utiltestingapi.MakeClusterQueue("cq").
268+
ResourceGroup(
269+
*utiltestingapi.MakeFlavorQuotas("cpu-flavor").
270+
Resource(corev1.ResourceCPU, "10", "0").
271+
Resource(corev1.ResourceMemory, "10Gi", "0").
272+
Obj(),
273+
).
274+
ResourceGroup(
275+
*utiltestingapi.MakeFlavorQuotas("gpu-flavor").
276+
Resource(corev1.ResourceName("nvidia.com/gpu"), "10", "0").
277+
Obj(),
278+
).Obj(),
279+
resource: corev1.ResourceName("nvidia.com/gpu"),
280+
wantFlavor: "gpu-flavor",
281+
},
282+
"returns the first flavor from the matching resource group": {
283+
clusterQueue: utiltestingapi.MakeClusterQueue("cq").
284+
ResourceGroup(
285+
*utiltestingapi.MakeFlavorQuotas("on-demand").
286+
Resource(corev1.ResourceName("nvidia.com/gpu"), "10", "0").
287+
Obj(),
288+
*utiltestingapi.MakeFlavorQuotas("spot").
289+
Resource(corev1.ResourceName("nvidia.com/gpu"), "10", "0").
290+
Obj(),
291+
).Obj(),
292+
resource: corev1.ResourceName("nvidia.com/gpu"),
293+
wantFlavor: "on-demand",
294+
},
295+
"returns an empty string when the resource is not covered by any resource group": {
296+
clusterQueue: utiltestingapi.MakeClusterQueue("cq").
297+
ResourceGroup(
298+
*utiltestingapi.MakeFlavorQuotas("cpu-flavor").
299+
Resource(corev1.ResourceCPU, "10", "0").
300+
Obj(),
301+
).Obj(),
302+
resource: corev1.ResourceName("nvidia.com/gpu"),
303+
wantFlavor: "",
304+
},
305+
}
306+
307+
for name, tc := range cases {
308+
t.Run(name, func(t *testing.T) {
309+
gotFlavor := resourceFlavorForResource(tc.clusterQueue, tc.resource)
310+
311+
if gotFlavor != tc.wantFlavor {
312+
t.Fatalf("Unexpected flavor, got %q want %q", gotFlavor, tc.wantFlavor)
313+
}
314+
})
315+
}
316+
}

0 commit comments

Comments
 (0)