Files
Aleksei Sviridkin 7f6d6ae9a4 feat(gpu): serve the GPUClaim, VMI and virt-launcher pod webhooks
The VMI webhook resolves a VMI's claim references, refuses what needs
no placement to judge, records the attachment of each held claim and
writes one gpus entry per unit with the class's resource name. It
binds, moves and creates nothing: budgets, capacity and template
claims are the controller's, so a VM whose set does not fit yet waits
at start instead of being refused into KubeVirt's backoff.

The pod webhook holds the launcher pod, and the temporary pod for
WaitForFirstConsumer volumes, at the claims' scheduling gate and adds
the tolerations of the claims' classes, which binding already counted
on. A repeated call adds neither twice, since a duplicate gate fails
pod validation. Migration targets are left alone, because KubeVirt
deletes a target pod that stays pending.

The order webhook refuses a tenant's GPUClaim that cannot be met now:
class, maxCount, published free units, budgets and the vGPU
entitlement. Template claims skip it; the tamper policy keeps their
mark out of tenants' hands.

Assisted-by: LLM
Signed-off-by: Aleksei Sviridkin <f@lex.la>
2026-10-26 13:16:19 +03:00

234 lines
7.6 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"}
)
func (h *Handler) classes(ctx context.Context) (map[string]*gpuv1alpha1.GPUClass, error) {
l := &gpuv1alpha1.GPUClassList{}
if err := h.Reader.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.Reader.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.Reader.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)
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.Reader.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
}
// budgetSnapshot is the budget view of tenant ns: every claim, 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) budgetSnapshot(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)
}
claims := &gpuv1alpha1.GPUClaimList{}
if err := h.Reader.List(ctx, claims); err != nil {
return nil, fmt.Errorf("list GPUClaims: %w", err)
}
for i := range claims.Items {
s.Claims = append(s.Claims, model.ClaimFrom(&claims.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, claims.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, claims []gpuv1alpha1.GPUClaim) ([]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.Reader.List(ctx, namespaces); err != nil {
return nil, fmt.Errorf("list namespaces: %w", err)
}
ptrs := make([]*gpuv1alpha1.GPUClaim, len(claims))
for i := range claims {
ptrs[i] = &claims[i]
}
covered := model.CoveredForBudget(ptrs, s.Classes)
var out []model.Pod
for _, n := range namespaces.Items {
if !slices.ContainsFunc(roots, func(r string) bool { return model.InSubtree(n.Name, r) }) {
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]
// Only a pod that requests a GPU can need its VMI read.
if _, ok := model.PodFrom(p, names, nil); !ok {
continue
}
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; 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.Reader.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
}
}