Files
Aleksei Sviridkin 612ff85079 feat(gpu): add the shared GPU names and capacity model
The class status controller, the admission webhooks and the API
server's budget check all need the same answers: which units of a
class are free on a node, which classes clash on a resource name,
how a budget key is spelled, and whether a namespace is entitled to
vGPU. Kept in one package of pure functions, each rule has a single
implementation that every component calls, tested without a cluster.

Free units come from Node allocatable and pod requests alone, so no
inventory kind or node agent is needed. A node counts only while it
is Ready, not cordoned and carries no NoSchedule or NoExecute taint
the class does not tolerate, except the three taints Kubernetes sets
for a cordon or a node that is not Ready, which the first two rules
already decide.

Budget keys follow DRA's class-scoped form, so a key never collides
with a resource name a device plugin advertises, the key without a
class prefix needs no reserved class name, and any key in the domain
is recognised as a budget even when it is malformed.

When two classes share a resource name, the older one keeps it and
the newer one is in conflict, so a mistyped new class cannot take an
offer tenants already run on out of service.

Assisted-by: LLM
Signed-off-by: Aleksei Sviridkin <f@lex.la>
2026-11-03 05:12:52 +03:00

125 lines
4.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 model
import (
"github.com/go-logr/logr"
corev1 "k8s.io/api/core/v1"
)
// Cordoned reports whether the node is cordoned. Only spec.unschedulable
// counts: a taint, such as the one Fence Agents Remediation sets, is not a
// cordon and goes through UntoleratedTaint instead.
func Cordoned(n *corev1.Node) bool {
return n.Spec.Unschedulable
}
// Ready reports whether the node's Ready condition is True.
func Ready(n *corev1.Node) bool {
for _, c := range n.Status.Conditions {
if c.Type == corev1.NodeReady {
return c.Status == corev1.ConditionTrue
}
}
return false
}
// mirrorsCordonOrReadiness reports whether the taint is one Kubernetes
// sets for a cordon or a node that is not Ready, which Cordoned and Ready
// already decide: the cordon taint as NoSchedule, the readiness taints with
// either effect. The node lifecycle controller removes these forms from a
// schedulable Ready node, a hand-applied one included, so seeing one there
// is a passing state. It never sets the cordon taint as NoExecute, so that
// one counts like any other taint.
func mirrorsCordonOrReadiness(t *corev1.Taint) bool {
switch t.Key {
case corev1.TaintNodeUnschedulable:
return t.Effect == corev1.TaintEffectNoSchedule
case corev1.TaintNodeNotReady, corev1.TaintNodeUnreachable:
return true
}
return false
}
// UntoleratedTaint returns the first NoSchedule or NoExecute taint of the
// node that class c does not tolerate, or nil, skipping the taints that
// mirror a cordon or readiness. Gt and Lt tolerations never match: where
// their feature gate is off the scheduler does not honour them, and a
// unit is better left out of free than counted in vain.
func UntoleratedTaint(n *corev1.Node, c Class) *corev1.Taint {
return untoleratedTaint(n.Spec.Taints, c.Tolerations)
}
func untoleratedTaint(taints []corev1.Taint, tolerations []corev1.Toleration) *corev1.Taint {
for i := range taints {
taint := &taints[i]
if taint.Effect != corev1.TaintEffectNoSchedule && taint.Effect != corev1.TaintEffectNoExecute {
continue
}
if mirrorsCordonOrReadiness(taint) {
continue
}
tolerated := false
for j := range tolerations {
if tolerations[j].ToleratesTaint(logr.Discard(), taint, false) {
tolerated = true
break
}
}
if !tolerated {
return taint
}
}
return nil
}
// Node is the part of a Node that capacity reads.
type Node struct {
Name string
Allocatable map[corev1.ResourceName]int64
// Usable is false for a cordoned or not Ready node, which adds nothing
// to any class whatever its taints.
Usable bool
Taints []corev1.Taint
}
// NodeFrom projects a Node.
func NodeFrom(n *corev1.Node) Node {
alloc := make(map[corev1.ResourceName]int64, len(n.Status.Allocatable))
for k, v := range n.Status.Allocatable {
alloc[k] = v.Value()
}
return Node{Name: n.Name, Allocatable: alloc, Usable: !Cordoned(n) && Ready(n), Taints: n.Spec.Taints}
}
// FreeOnNode returns the free units of class c on node n: its allocatable
// count of the class's resource name minus what the pods scheduled there
// request of it, never below zero. A cordoned or not Ready node, or one
// with a taint the class does not tolerate, has none.
func FreeOnNode(c Class, n Node, pods []Pod) int64 {
if !n.Usable || untoleratedTaint(n.Taints, c.Tolerations) != nil {
return 0
}
free := n.Allocatable[c.ResourceName]
for _, p := range pods {
if p.NodeName == n.Name {
free -= p.Units[c.ResourceName]
}
}
return max(free, 0)
}