mirror of
https://github.com/cozystack/cozystack.git
synced 2026-10-29 16:17:20 +09:00
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>
201 lines
8.0 KiB
Go
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)
|
|
}
|
|
}
|