Files
cozystack/internal/gpu/controller/status_test.go
Aleksei Sviridkin 2aaf7f6872 feat(gpu): count the KubeVirt table's GPU names as GPU resource names
The NVIDIA and mediated entries of the KubeVirt permittedHostDevices
table are GPU resource names besides the hoster's gpu.resourceNames
list, whether the hoster sets one or not. Without that, a
GPU name only the table carries, on a node whose agent reports nothing
and with no class of its own, would not be a GPU resource name: under
forbidden the raw-name policy would let a VM or a pod request it
directly, and the VMI webhook would take it for a non-GPU host device.

The binder adds those names to the effective set it publishes in the
settings ConfigMap, which the policy and cozystack-api read, and the VMI
webhook adds them when it looks for a VMI's own GPU entries. A PCI entry
of another vendor, such as a ConnectX VF, stays out. Whether a class's
resource name is in the table at all is still checked against every
PCI and mediated entry.

Assisted-by: LLM
Signed-off-by: Aleksei Sviridkin <f@lex.la>
2026-10-21 13:31:33 +03:00

201 lines
8.0 KiB
Go

/*
Copyright 2026 The Cozystack 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 controller
import (
"context"
"encoding/json"
"strings"
"testing"
"time"
"github.com/prometheus/client_golang/prometheus/testutil"
corev1 "k8s.io/api/core/v1"
apierrors "k8s.io/apimachinery/pkg/api/errors"
"k8s.io/apimachinery/pkg/api/meta"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
"sigs.k8s.io/controller-runtime/pkg/client"
"sigs.k8s.io/controller-runtime/pkg/client/fake"
"sigs.k8s.io/controller-runtime/pkg/client/interceptor"
gpuv1alpha1 "github.com/cozystack/cozystack/api/gpu/v1alpha1"
"github.com/cozystack/cozystack/internal/gpu"
)
func metaName(namespace, name string) metav1.ObjectMeta {
return metav1.ObjectMeta{Namespace: namespace, Name: name}
}
func TestClassStatusCarriesFreeAndMaxFreePerNodeOnly(t *testing.T) {
e := newEnv(t,
class("l40s", rnL40S),
node("n1", map[string]int64{rnL40S: 4}),
node("n2", map[string]int64{rnL40S: 2}),
node("cordoned", map[string]int64{rnL40S: 8}, cordoned),
device("a", "l40s", 1, bound("n1")),
device("l", "l40s", 5, lost("gone")),
device("p", "l40s", 9),
)
e.pass()
st := e.class("l40s").Status
if *st.Free != 5 || *st.MaxFreePerNode != 3 {
t.Fatalf("free %d, maxFreePerNode %d, want 5 and 3", *st.Free, *st.MaxFreePerNode)
}
raw, _ := json.Marshal(st)
var fields map[string]any
_ = json.Unmarshal(raw, &fields)
for f := range fields {
if f != "free" && f != "maxFreePerNode" && f != "conditions" {
t.Errorf("GPUClass status carries %q, which every authenticated user reads", f)
}
}
for _, c := range st.Conditions {
for _, n := range []string{"n1", "n2", "cordoned", "gone"} {
if strings.Contains(c.Message, n) {
t.Errorf("condition %s names node %s: %q", c.Type, n, c.Message)
}
}
}
if got := testutil.ToFloat64(classCapacity.WithLabelValues("l40s")); got != 6 {
t.Errorf("capacity metric %v, want 6: the cordoned node does not count", got)
}
if got := testutil.ToFloat64(classBound.WithLabelValues("l40s")); got != 1 {
t.Errorf("bound metric %v, want 1", got)
}
if got := testutil.ToFloat64(classLost.WithLabelValues("l40s")); got != 5 {
t.Errorf("lost metric %v, want 5", got)
}
if got := testutil.ToFloat64(classWaiting.WithLabelValues("l40s")); got != 14 {
t.Errorf("waiting metric %v, want 14 (Pending 9 and Lost 5)", got)
}
}
func TestTwoClassesSharingAResourceNameOnANodeConflict(t *testing.T) {
e := newEnv(t,
class("a", rnL40S, selecting("gpu", "l40s")),
class("b", rnL40S, selecting("gpu", "l40s")),
class("c", rnL40S, selecting("gpu", "other")),
node("n1", map[string]int64{rnL40S: 4}, labelled("gpu", "l40s")),
&gpuv1alpha1.GPUNode{ObjectMeta: metav1.ObjectMeta{Name: "n1"}},
device("order", "a", 1),
)
e.pass()
expectPlacement(t, e.device("order"), gpuv1alpha1.GPUDevicePending, "")
for name, want := range map[string]metav1.ConditionStatus{"a": metav1.ConditionTrue, "b": metav1.ConditionTrue, "c": metav1.ConditionFalse} {
c := meta.FindStatusCondition(e.class(name).Status.Conditions, gpu.ConditionResourceNameConflict)
if c == nil || c.Status != want {
t.Fatalf("class %s conflict condition %+v, want %s", name, c, want)
}
}
if msg := meta.FindStatusCondition(e.class("a").Status.Conditions, gpu.ConditionResourceNameConflict).Message; !strings.Contains(msg, "b") || strings.Contains(msg, "n1") {
t.Fatalf("class condition %q must name the other class and no node", msg)
}
gn := &gpuv1alpha1.GPUNode{}
_ = e.c.Get(t.Context(), key("", "n1"), gn)
c := meta.FindStatusCondition(gn.Status.Conditions, gpu.ConditionResourceNameConflict)
if c == nil || c.Status != metav1.ConditionTrue || !strings.Contains(c.Message, "a, b") {
t.Fatalf("GPUNode condition %+v must name both classes", c)
}
}
func TestClassOutsideThePermittedHostDevicesTableGetsACondition(t *testing.T) {
e := newEnv(t, class("l40s", rnL40S), class("h100", rnH100), kubeVirt(rnH100))
e.pass()
for name, want := range map[string]metav1.ConditionStatus{"l40s": metav1.ConditionTrue, "h100": metav1.ConditionFalse} {
c := meta.FindStatusCondition(e.class(name).Status.Conditions, ConditionResourceNameNotPermitted)
if c == nil || c.Status != want {
t.Fatalf("class %s: %+v, want %s", name, c, want)
}
}
}
func TestAnUnsetPermittedHostDevicesTablePermitsEveryName(t *testing.T) {
kv := kubeVirt()
delete(kv.Object, "spec")
e := newEnv(t, class("l40s", rnL40S), kv)
e.pass()
if c := meta.FindStatusCondition(e.class("l40s").Status.Conditions, ConditionResourceNameNotPermitted); c.Status != metav1.ConditionFalse {
t.Fatalf("%+v", c)
}
}
func TestSettingsConfigMapHoldsTheEffectiveResourceNames(t *testing.T) {
gn := &gpuv1alpha1.GPUNode{ObjectMeta: metav1.ObjectMeta{Name: "n1"}}
gn.Status.ResourceNames = []string{"nvidia.com/gpu"}
e := newEnv(t, class("l40s", rnL40S), gn)
e.r.Config.ResourceNames = []string{rnH100}
e.r.Config.RawResourceNames = gpu.RawNamesForbidden
e.pass()
cm := &corev1.ConfigMap{}
if err := e.c.Get(t.Context(), key(gpu.SettingsNamespace, gpu.SettingsConfigMap), cm); err != nil {
t.Fatalf("settings: %v", err)
}
if got := cm.Data[gpu.SettingsKeyResourceNames]; got != "nvidia.com/H100,nvidia.com/L40S,nvidia.com/gpu" {
t.Fatalf("resourceNames = %q", got)
}
if got := cm.Data[gpu.SettingsKeyRawResourceNames]; got != "forbidden" {
t.Fatalf("rawResourceNames = %q", got)
}
e.r.Config.RawResourceNames = ""
e.now = e.now.Add(time.Minute)
e.pass()
_ = e.c.Get(t.Context(), key(gpu.SettingsNamespace, gpu.SettingsConfigMap), cm)
if got := cm.Data[gpu.SettingsKeyRawResourceNames]; got != "allowed" {
t.Fatalf("rawResourceNames default = %q", got)
}
}
// The NVIDIA and mdev names of the KubeVirt table are GPU names besides the
// hoster's list, also when the hoster sets one: a name only the table
// carries is a GPU name too; a NIC there is not.
func TestSettingsConfigMapTakesTheKubeVirtTableGPUNames(t *testing.T) {
kv := kubeVirt("nvidia.com/GA102GL_A10")
entries, _, _ := unstructured.NestedSlice(kv.Object, "spec", "configuration", "permittedHostDevices", "pciHostDevices")
entries = append(entries, map[string]any{"resourceName": "nvidia.com/hostdev", "pciVendorSelector": "15B3:101E"})
_ = unstructured.SetNestedSlice(kv.Object, entries, "spec", "configuration", "permittedHostDevices", "pciHostDevices")
e := newEnv(t, class("l40s", rnL40S), kv)
e.r.Config.ResourceNames = []string{"nvidia.com/gpu"}
e.pass()
cm := &corev1.ConfigMap{}
if err := e.c.Get(t.Context(), key(gpu.SettingsNamespace, gpu.SettingsConfigMap), cm); err != nil {
t.Fatalf("settings: %v", err)
}
if got := cm.Data[gpu.SettingsKeyResourceNames]; got != "nvidia.com/GA102GL_A10,nvidia.com/L40S,nvidia.com/gpu" {
t.Fatalf("resourceNames = %q", got)
}
}
// Right after the first create a stale cache still reads NotFound.
func TestSettingsCreateOnAStaleCacheIsNotAnError(t *testing.T) {
existing := &corev1.ConfigMap{ObjectMeta: metav1.ObjectMeta{Namespace: gpu.SettingsNamespace, Name: gpu.SettingsConfigMap}}
c := fake.NewClientBuilder().WithScheme(testScheme(true)).WithObjects(existing).
WithInterceptorFuncs(interceptor.Funcs{Get: func(ctx context.Context, cl client.WithWatch, k client.ObjectKey, obj client.Object, opts ...client.GetOption) error {
if _, ok := obj.(*corev1.ConfigMap); ok {
return apierrors.NewNotFound(corev1.Resource("configmaps"), k.Name)
}
return cl.Get(ctx, k, obj, opts...)
}}).Build()
r := &Reconciler{Client: c, Reader: c, Config: gpu.Config{Enabled: true}}
if err := r.settings(t.Context(), &world{}); err != nil {
t.Fatalf("settings: %v", err)
}
}