Files
cozystack/internal/gpu/webhook/state.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

241 lines
7.8 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 webhook
import (
"context"
"fmt"
"slices"
helmv2 "github.com/fluxcd/helm-controller/api/v2"
corev1 "k8s.io/api/core/v1"
apierrors "k8s.io/apimachinery/pkg/api/errors"
"k8s.io/apimachinery/pkg/api/meta"
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
"k8s.io/apimachinery/pkg/runtime/schema"
"k8s.io/apimachinery/pkg/types"
"k8s.io/apimachinery/pkg/util/sets"
"sigs.k8s.io/controller-runtime/pkg/client"
gpuv1alpha1 "github.com/cozystack/cozystack/api/gpu/v1alpha1"
"github.com/cozystack/cozystack/internal/gpu/model"
)
var (
vmiGVK = schema.GroupVersionKind{Group: "kubevirt.io", Version: "v1", Kind: "VirtualMachineInstance"}
kubeVirtGVK = schema.GroupVersionKind{Group: "kubevirt.io", Version: "v1", Kind: "KubeVirt"}
)
const (
// labelMode is the GPU mode a Kairos pool's chart sets on its worker
// VMI template.
labelMode = "gpu.cozystack.io/mode"
)
func (h *Handler) classes(ctx context.Context) (map[string]*gpuv1alpha1.GPUClass, error) {
l := &gpuv1alpha1.GPUClassList{}
if err := h.Client.List(ctx, l); err != nil {
return nil, fmt.Errorf("list GPUClasses: %w", err)
}
out := make(map[string]*gpuv1alpha1.GPUClass, len(l.Items))
for i := range l.Items {
out[l.Items[i].Name] = &l.Items[i]
}
return out, nil
}
func resourceName(c *gpuv1alpha1.GPUClass) string {
if dp := c.Spec.Source.DevicePlugin; dp != nil {
return dp.ResourceName
}
return ""
}
// gpuNames is the effective GPU resource-name set; tableGPUs are the GPU
// names of the KubeVirt permittedHostDevices table, added besides the
// hoster's list.
func (h *Handler) gpuNames(ctx context.Context, classes map[string]*gpuv1alpha1.GPUClass, tableGPUs []string) (sets.Set[string], error) {
nodes := &gpuv1alpha1.GPUNodeList{}
if err := h.Client.List(ctx, nodes); err != nil {
return nil, fmt.Errorf("list GPUNodes: %w", err)
}
list := make([]gpuv1alpha1.GPUClass, 0, len(classes))
for _, c := range classes {
list = append(list, *c)
}
return sets.New(model.ResourceNames(list, append(slices.Clone(h.Config.ResourceNames), tableGPUs...), nodes.Items)...), nil
}
// permitted returns the KubeVirt permittedHostDevices table, nil when it
// is unset and KubeVirt checks nothing, and the table's GPU names.
func (h *Handler) permitted(ctx context.Context) (sets.Set[string], []string, error) {
kv := &unstructured.Unstructured{}
kv.SetGroupVersionKind(kubeVirtGVK)
err := h.Cache.Get(ctx, client.ObjectKey{Namespace: model.KubeVirtNamespace, Name: model.KubeVirtName}, kv)
if apierrors.IsNotFound(err) || meta.IsNoMatchError(err) {
return nil, nil, nil
}
if err != nil {
return nil, nil, fmt.Errorf("get KubeVirt %s/%s: %w", model.KubeVirtNamespace, model.KubeVirtName, err)
}
return model.PermittedResourceNames(kv), model.PermittedGPUNames(kv), nil
}
// quotas reads the declared resourceQuotas of ns and its ancestors from
// their tenant HelmReleases, which keep GPU keys the namespace
// ResourceQuota never renders. Budgets are read past the cache, so a
// replica that is not the leader needs no HelmRelease watch.
func (h *Handler) quotas(ctx context.Context, ns string) (map[string]corev1.ResourceList, error) {
out := map[string]corev1.ResourceList{}
for _, a := range model.Ancestors(ns) {
hrName, hrNamespace, ok := model.TenantReleaseRef(a, model.TenantPrefix)
if !ok {
continue
}
hr := &helmv2.HelmRelease{}
err := h.Reader.Get(ctx, client.ObjectKey{Namespace: hrNamespace, Name: hrName}, hr)
if apierrors.IsNotFound(err) {
continue
}
if err != nil {
return nil, fmt.Errorf("get tenant HelmRelease %s/%s: %w", hrNamespace, hrName, err)
}
if hr.Spec.Values == nil {
continue
}
rl, err := model.DeclaredQuotas(hr.Spec.Values.Raw)
if err != nil {
return nil, fmt.Errorf("tenant %s/%s: %w", hrNamespace, hrName, err)
}
out[a] = rl
}
return out, nil
}
func (h *Handler) entitled(ctx context.Context, ns string) (bool, error) {
n := &corev1.Namespace{}
err := h.Client.Get(ctx, client.ObjectKey{Name: ns}, n)
if apierrors.IsNotFound(err) {
return false, nil
}
if err != nil {
return false, fmt.Errorf("get namespace %s: %w", ns, err)
}
return model.Entitled(n.Labels), nil
}
// snapshot is the budget view of tenant ns: every device, the declared
// quotas of ns and its ancestors, and, where an ancestor carries a raw
// GPU key, the raw-name pods of that ancestor's subtree.
func (h *Handler) snapshot(ctx context.Context, ns string, classes map[string]*gpuv1alpha1.GPUClass) (*model.Snapshot, error) {
s := &model.Snapshot{Classes: map[string]model.Class{}}
for name, c := range classes {
s.Classes[name] = model.ClassFrom(c)
}
devices := &gpuv1alpha1.GPUDeviceList{}
if err := h.Client.List(ctx, devices); err != nil {
return nil, fmt.Errorf("list GPUDevices: %w", err)
}
for i := range devices.Items {
s.Devices = append(s.Devices, model.DeviceFrom(&devices.Items[i]))
}
var err error
if s.Quotas, err = h.quotas(ctx, ns); err != nil {
return nil, err
}
if s.Pods, err = h.rawPods(ctx, s, devices.Items); err != nil {
return nil, err
}
return s, nil
}
// rawPods lists, past the cache, the pods of each subtree whose root
// carries a raw GPU key: no replica caches every pod, and only such a key
// needs them.
func (h *Handler) rawPods(ctx context.Context, s *model.Snapshot, devices []gpuv1alpha1.GPUDevice) ([]model.Pod, error) {
names := sets.New[corev1.ResourceName]()
for _, c := range s.Classes {
if c.ResourceName != "" {
names.Insert(c.ResourceName)
}
}
var roots []string
for a, q := range s.Quotas {
for n := range names {
if _, ok := model.RawQuotaLimit(q, n); ok {
roots = append(roots, a)
break
}
}
}
if len(roots) == 0 {
return nil, nil
}
namespaces := &corev1.NamespaceList{}
if err := h.Client.List(ctx, namespaces); err != nil {
return nil, fmt.Errorf("list namespaces: %w", err)
}
ptrs := make([]*gpuv1alpha1.GPUDevice, len(devices))
for i := range devices {
ptrs[i] = &devices[i]
}
covered := model.CoveredForBudget(ptrs, s.Classes)
var out []model.Pod
for _, n := range namespaces.Items {
inSubtree := false
for _, r := range roots {
inSubtree = inSubtree || model.InSubtree(n.Name, r)
}
if !inSubtree {
continue
}
pods := &corev1.PodList{}
if err := h.Reader.List(ctx, pods, client.InNamespace(n.Name)); err != nil {
return nil, fmt.Errorf("list pods in %s: %w", n.Name, err)
}
for i := range pods.Items {
p := &pods.Items[i]
vmi, _, err := model.LauncherVMI(p, h.activePods(ctx))
if err != nil {
return nil, err
}
if mp, ok := model.PodFrom(p, names, covered[vmi]); ok {
out = append(out, mp)
}
}
}
return out, nil
}
// activePods reads a VMI's active pods through the cache, which every
// replica warms with a VMI informer once KubeVirt serves the kind; nil for
// a VMI that does not exist or a kind not served yet.
func (h *Handler) activePods(ctx context.Context) func(types.NamespacedName) (sets.Set[types.UID], error) {
return func(k types.NamespacedName) (sets.Set[types.UID], error) {
vmi := &unstructured.Unstructured{}
vmi.SetGroupVersionKind(vmiGVK)
err := h.Cache.Get(ctx, k, vmi)
if apierrors.IsNotFound(err) || meta.IsNoMatchError(err) {
return nil, nil
}
if err != nil {
return nil, fmt.Errorf("get VMI %s: %w", k, err)
}
return model.ActivePods(vmi), nil
}
}