mirror of
https://github.com/cozystack/cozystack.git
synced 2026-10-29 16:17:20 +09:00
The binder re-checks what admission checked, and cozystack-api shows the remaining budget a later order is checked against; computing these in three places would let them disagree. The model is pure, so each caller builds a snapshot from its own cache. GPU budgets cap the whole subtree of the tenant that declares them, walked over the namespace ancestry and independent of the tenant quota pools. A raw GPU key blocks a class until the same tenant also declares a GPU key, and then caps devices and raw-name pods together. Pod usage follows the scheduler's arithmetic, sidecars included, so the binder never books a unit a raw-name pod holds. A node takes binds only when it is schedulable, Ready and carries no NoSchedule or NoExecute taint the pod does not tolerate; the unschedulable, not-ready and unreachable taints are left to the first two checks, and a taint never counts as a cordon. Gt and Lt tolerations do not count, since the scheduler ignores them where their feature gate is off. Template device names are derived from the VMI and shortened with a hash when the VMI name leaves no room, so they stay valid subdomains. Assisted-by: LLM Signed-off-by: Aleksei Sviridkin <f@lex.la>
389 lines
11 KiB
Go
389 lines
11 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 (
|
|
"fmt"
|
|
"sort"
|
|
|
|
corev1 "k8s.io/api/core/v1"
|
|
"k8s.io/apimachinery/pkg/labels"
|
|
"k8s.io/apimachinery/pkg/util/sets"
|
|
|
|
gpuv1alpha1 "github.com/cozystack/cozystack/api/gpu/v1alpha1"
|
|
"github.com/cozystack/cozystack/internal/gpu"
|
|
)
|
|
|
|
// Class is the part of a GPUClass that placement and budgets read.
|
|
type Class struct {
|
|
Name string
|
|
Mode gpuv1alpha1.GPUMode
|
|
ResourceName corev1.ResourceName
|
|
NodeSelector map[string]string
|
|
}
|
|
|
|
// ClassFrom projects a GPUClass.
|
|
func ClassFrom(c *gpuv1alpha1.GPUClass) Class {
|
|
out := Class{Name: c.Name, Mode: c.Spec.Mode, NodeSelector: c.Spec.NodeSelector}
|
|
if dp := c.Spec.Source.DevicePlugin; dp != nil {
|
|
out.ResourceName = corev1.ResourceName(dp.ResourceName)
|
|
}
|
|
return out
|
|
}
|
|
|
|
// Device is the part of a GPUDevice that placement and budgets read.
|
|
type Device struct {
|
|
Namespace string
|
|
Name string
|
|
Class string
|
|
Count int64
|
|
Phase gpuv1alpha1.GPUDevicePhase
|
|
NodeName string
|
|
}
|
|
|
|
// DeviceFrom projects a GPUDevice.
|
|
func DeviceFrom(d *gpuv1alpha1.GPUDevice) Device {
|
|
return Device{
|
|
Namespace: d.Namespace,
|
|
Name: d.Name,
|
|
Class: d.Spec.ClassName,
|
|
Count: int64(d.Spec.Count),
|
|
Phase: d.Status.Phase,
|
|
NodeName: d.Status.NodeName,
|
|
}
|
|
}
|
|
|
|
// Node is the part of a Node that placement reads.
|
|
type Node struct {
|
|
Name string
|
|
Labels map[string]string
|
|
Allocatable map[corev1.ResourceName]int64
|
|
// Eligible is Eligible() of the node, evaluated by the caller with the
|
|
// tolerations that apply.
|
|
Eligible bool
|
|
}
|
|
|
|
// NodeFrom projects a Node; tolerations are passed to Eligible.
|
|
func NodeFrom(n *corev1.Node, tolerations []corev1.Toleration) 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, Labels: n.Labels, Allocatable: alloc, Eligible: Eligible(n, tolerations)}
|
|
}
|
|
|
|
// Pod is the GPU usage of a pod beyond what its attached devices cover:
|
|
// the units of VMs on raw resource names.
|
|
type Pod struct {
|
|
Namespace string
|
|
// NodeName is empty for a pod not yet scheduled; such a pod counts
|
|
// against raw quota keys but not against any node's capacity.
|
|
NodeName string
|
|
Units map[corev1.ResourceName]int64
|
|
}
|
|
|
|
// PodFrom returns the GPU units p requests under gpuNames beyond covered,
|
|
// the units its attached devices account for, and false when nothing is
|
|
// left or p is finished.
|
|
func PodFrom(p *corev1.Pod, gpuNames sets.Set[corev1.ResourceName], covered map[corev1.ResourceName]int64) (Pod, bool) {
|
|
if p.Status.Phase == corev1.PodSucceeded || p.Status.Phase == corev1.PodFailed {
|
|
return Pod{}, false
|
|
}
|
|
units := map[corev1.ResourceName]int64{}
|
|
for name := range gpuNames {
|
|
if left := podUnits(p, name) - covered[name]; left > 0 {
|
|
units[name] = left
|
|
}
|
|
}
|
|
if len(units) == 0 {
|
|
return Pod{}, false
|
|
}
|
|
return Pod{Namespace: p.Namespace, NodeName: p.Spec.NodeName, Units: units}, true
|
|
}
|
|
|
|
// podUnits is the pod's effective request of name, as the scheduler
|
|
// computes it: sidecars (init containers that always restart) run beside
|
|
// the later init containers and the main ones.
|
|
func podUnits(p *corev1.Pod, name corev1.ResourceName) int64 {
|
|
var sum, sidecars, initPeak int64
|
|
for i := range p.Spec.Containers {
|
|
sum += containerUnits(&p.Spec.Containers[i], name)
|
|
}
|
|
for i := range p.Spec.InitContainers {
|
|
c := &p.Spec.InitContainers[i]
|
|
u := containerUnits(c, name)
|
|
if c.RestartPolicy != nil && *c.RestartPolicy == corev1.ContainerRestartPolicyAlways {
|
|
sidecars += u
|
|
initPeak = max(initPeak, sidecars)
|
|
continue
|
|
}
|
|
initPeak = max(initPeak, sidecars+u)
|
|
}
|
|
return max(sum+sidecars, initPeak)
|
|
}
|
|
|
|
// containerUnits reads the limit first: an extended resource may be given
|
|
// as a limit alone, and when both are set they are equal.
|
|
func containerUnits(c *corev1.Container, name corev1.ResourceName) int64 {
|
|
if q, ok := c.Resources.Limits[name]; ok {
|
|
return q.Value()
|
|
}
|
|
if q, ok := c.Resources.Requests[name]; ok {
|
|
return q.Value()
|
|
}
|
|
return 0
|
|
}
|
|
|
|
// Snapshot is one consistent view of what budgets and capacity depend on.
|
|
type Snapshot struct {
|
|
Classes map[string]Class
|
|
Devices []Device
|
|
Nodes []Node
|
|
Pods []Pod
|
|
// Quotas maps a tenant namespace to its declared resourceQuotas, all
|
|
// keys as written.
|
|
Quotas map[string]corev1.ResourceList
|
|
}
|
|
|
|
// RefusalReason says which limit refused an order.
|
|
type RefusalReason string
|
|
|
|
const (
|
|
ReasonClassNotFound RefusalReason = "ClassNotFound"
|
|
ReasonNotEntitled RefusalReason = "NotEntitled"
|
|
// ReasonBudget is a GPU key, or a raw key, that the order would exceed.
|
|
ReasonBudget RefusalReason = "Budget"
|
|
// ReasonRawKey is a raw key on a tenant that declares neither the
|
|
// class key nor total; it blocks the class until one is declared.
|
|
ReasonRawKey RefusalReason = "RawKey"
|
|
)
|
|
|
|
// Refusal is an order a budget or the entitlement refuses.
|
|
type Refusal struct {
|
|
Reason RefusalReason
|
|
Class string
|
|
Count int64
|
|
// Namespace is the tenant whose key refused the order.
|
|
Namespace string
|
|
Key corev1.ResourceName
|
|
Limit int64
|
|
Used int64
|
|
}
|
|
|
|
func (r *Refusal) Error() string {
|
|
switch r.Reason {
|
|
case ReasonClassNotFound:
|
|
return fmt.Sprintf("GPUClass %s does not exist", r.Class)
|
|
case ReasonNotEntitled:
|
|
return fmt.Sprintf("cannot order GPUClass %s: the tenant is not entitled to vGPU", r.Class)
|
|
case ReasonRawKey:
|
|
return fmt.Sprintf("cannot order %d of GPUClass %s: tenant %s limits %s directly and needs %s or %s first",
|
|
r.Count, r.Class, r.Namespace, r.Key, ClassQuotaKey(r.Class), gpu.QuotaKeyTotal)
|
|
default:
|
|
return fmt.Sprintf("cannot order %d of GPUClass %s: budget %s of tenant %s is %d and %d is in use",
|
|
r.Count, r.Class, r.Key, r.Namespace, r.Limit, r.Used)
|
|
}
|
|
}
|
|
|
|
type limit struct {
|
|
namespace string
|
|
key corev1.ResourceName
|
|
limit int64
|
|
used int64
|
|
blocked bool
|
|
}
|
|
|
|
// limits lists every key on ns or an ancestor that applies to orders of c,
|
|
// nearest tenant first.
|
|
func (s *Snapshot) limits(ns string, c Class) []limit {
|
|
var out []limit
|
|
for _, a := range Ancestors(ns) {
|
|
q := s.Quotas[a]
|
|
classQ, hasClass := q[ClassQuotaKey(c.Name)]
|
|
totalQ, hasTotal := q[gpu.QuotaKeyTotal]
|
|
if hasClass {
|
|
out = append(out, limit{namespace: a, key: ClassQuotaKey(c.Name), limit: classQ.Value(),
|
|
used: s.devicesUsed(a, func(d Device) bool { return d.Class == c.Name })})
|
|
}
|
|
if hasTotal {
|
|
out = append(out, limit{namespace: a, key: gpu.QuotaKeyTotal, limit: totalQ.Value(),
|
|
used: s.devicesUsed(a, func(Device) bool { return true })})
|
|
}
|
|
if c.ResourceName == "" {
|
|
continue
|
|
}
|
|
raw, ok := RawQuotaLimit(q, c.ResourceName)
|
|
if !ok {
|
|
continue
|
|
}
|
|
used := s.devicesUsed(a, func(d Device) bool { return s.Classes[d.Class].ResourceName == c.ResourceName }) +
|
|
s.podUnits(a, c.ResourceName)
|
|
out = append(out, limit{namespace: a, key: c.ResourceName, limit: raw, used: used, blocked: !hasClass && !hasTotal})
|
|
}
|
|
return out
|
|
}
|
|
|
|
func (s *Snapshot) devicesUsed(root string, match func(Device) bool) int64 {
|
|
var n int64
|
|
for _, d := range s.Devices {
|
|
if CountsAgainstBudget(d.Phase) && InSubtree(d.Namespace, root) && match(d) {
|
|
n += d.Count
|
|
}
|
|
}
|
|
return n
|
|
}
|
|
|
|
func (s *Snapshot) podUnits(root string, name corev1.ResourceName) int64 {
|
|
var n int64
|
|
for _, p := range s.Pods {
|
|
if InSubtree(p.Namespace, root) {
|
|
n += p.Units[name]
|
|
}
|
|
}
|
|
return n
|
|
}
|
|
|
|
// CheckOrder returns a *Refusal when an order of count GPUs of class in
|
|
// tenant namespace ns would exceed a budget of ns or an ancestor, or needs
|
|
// a vGPU entitlement ns lacks. Pending devices in the snapshot, the order
|
|
// itself included, do not count.
|
|
func (s *Snapshot) CheckOrder(ns, class string, count int64, entitled bool) error {
|
|
c, ok := s.Classes[class]
|
|
if !ok {
|
|
return &Refusal{Reason: ReasonClassNotFound, Class: class, Count: count}
|
|
}
|
|
if c.Mode == gpuv1alpha1.GPUModeVGPU && !entitled {
|
|
return &Refusal{Reason: ReasonNotEntitled, Class: class, Count: count}
|
|
}
|
|
for _, l := range s.limits(ns, c) {
|
|
r := &Refusal{Class: class, Count: count, Namespace: l.namespace, Key: l.key, Limit: l.limit, Used: l.used}
|
|
switch {
|
|
case l.blocked:
|
|
r.Reason = ReasonRawKey
|
|
return r
|
|
case l.used+count > l.limit:
|
|
r.Reason = ReasonBudget
|
|
return r
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// RemainingBudget returns how many GPUs of class tenant ns may still
|
|
// order, and false when no budget limits it. It is 0 for a vGPU class
|
|
// without the entitlement.
|
|
func (s *Snapshot) RemainingBudget(ns, class string, entitled bool) (int64, bool) {
|
|
c, ok := s.Classes[class]
|
|
if !ok || (c.Mode == gpuv1alpha1.GPUModeVGPU && !entitled) {
|
|
return 0, true
|
|
}
|
|
var left int64
|
|
limited := false
|
|
for _, l := range s.limits(ns, c) {
|
|
v := l.limit - l.used
|
|
if l.blocked {
|
|
v = 0
|
|
}
|
|
if !limited || v < left {
|
|
left = v
|
|
}
|
|
limited = true
|
|
}
|
|
return max(left, 0), limited
|
|
}
|
|
|
|
func (s *Snapshot) node(name string) *Node {
|
|
for i := range s.Nodes {
|
|
if s.Nodes[i].Name == name {
|
|
return &s.Nodes[i]
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func matches(c Class, n *Node) bool {
|
|
return labels.SelectorFromSet(c.NodeSelector).Matches(labels.Set(n.Labels))
|
|
}
|
|
|
|
// Conflicts returns, sorted, the classes that select the node and share a
|
|
// resource name with another class that selects it. Such a node serves
|
|
// none of them.
|
|
func (s *Snapshot) Conflicts(nodeName string) []string {
|
|
n := s.node(nodeName)
|
|
if n == nil {
|
|
return nil
|
|
}
|
|
byName := map[corev1.ResourceName][]string{}
|
|
for _, c := range s.Classes {
|
|
if c.ResourceName != "" && matches(c, n) {
|
|
byName[c.ResourceName] = append(byName[c.ResourceName], c.Name)
|
|
}
|
|
}
|
|
var out []string
|
|
for _, names := range byName {
|
|
if len(names) > 1 {
|
|
out = append(out, names...)
|
|
}
|
|
}
|
|
sort.Strings(out)
|
|
return out
|
|
}
|
|
|
|
// Serves reports whether the node is eligible, selected by the class and
|
|
// free of a resource-name conflict for it.
|
|
func (s *Snapshot) Serves(class, nodeName string) bool {
|
|
c, ok := s.Classes[class]
|
|
n := s.node(nodeName)
|
|
if !ok || n == nil || c.ResourceName == "" || !n.Eligible || !matches(c, n) {
|
|
return false
|
|
}
|
|
return !sets.New(s.Conflicts(nodeName)...).Has(class)
|
|
}
|
|
|
|
// FreeOnNode returns the free units of class on the node: allocatable,
|
|
// minus units held by devices there, minus units pods hold beyond their
|
|
// devices. It is 0 on a node that does not serve the class.
|
|
func (s *Snapshot) FreeOnNode(class, nodeName string) int64 {
|
|
if !s.Serves(class, nodeName) {
|
|
return 0
|
|
}
|
|
name := s.Classes[class].ResourceName
|
|
free := s.node(nodeName).Allocatable[name]
|
|
for _, d := range s.Devices {
|
|
if d.NodeName == nodeName && HoldsGPUs(d.Phase) && s.Classes[d.Class].ResourceName == name {
|
|
free -= d.Count
|
|
}
|
|
}
|
|
for _, p := range s.Pods {
|
|
if p.NodeName == nodeName {
|
|
free -= p.Units[name]
|
|
}
|
|
}
|
|
return max(free, 0)
|
|
}
|
|
|
|
// ClassCapacity returns the class's free units over the nodes that serve
|
|
// it and the most free units on one of them.
|
|
func (s *Snapshot) ClassCapacity(class string) (free, maxFreePerNode int64) {
|
|
for i := range s.Nodes {
|
|
f := s.FreeOnNode(class, s.Nodes[i].Name)
|
|
free += f
|
|
maxFreePerNode = max(maxFreePerNode, f)
|
|
}
|
|
return free, maxFreePerNode
|
|
}
|