mirror of
https://github.com/hanzo-ml/kserve.git
synced 2026-10-30 20:53:49 +00:00
776 lines
26 KiB
Go
776 lines
26 KiB
Go
/*
|
|
Copyright 2025 The KServe 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 v1alpha2
|
|
|
|
import (
|
|
"cmp"
|
|
"context"
|
|
"fmt"
|
|
"regexp"
|
|
"slices"
|
|
"strings"
|
|
|
|
"k8s.io/utils/ptr"
|
|
|
|
apierrors "k8s.io/apimachinery/pkg/api/errors"
|
|
"k8s.io/apimachinery/pkg/runtime"
|
|
"k8s.io/apimachinery/pkg/util/validation"
|
|
"k8s.io/apimachinery/pkg/util/validation/field"
|
|
ctrl "sigs.k8s.io/controller-runtime"
|
|
"sigs.k8s.io/controller-runtime/pkg/log"
|
|
"sigs.k8s.io/controller-runtime/pkg/webhook"
|
|
"sigs.k8s.io/controller-runtime/pkg/webhook/admission"
|
|
gwapiv1 "sigs.k8s.io/gateway-api/apis/v1"
|
|
|
|
"github.com/kserve/kserve/pkg/constants"
|
|
"github.com/kserve/kserve/pkg/utils"
|
|
kservevalidation "github.com/kserve/kserve/pkg/validation"
|
|
)
|
|
|
|
// variantCostPattern is compiled once at package init to avoid recompilation on every webhook call.
|
|
var variantCostPattern = regexp.MustCompile(`^\d+(\.\d+)?$`)
|
|
|
|
// +kubebuilder:webhook:path=/validate-serving-kserve-io-v1alpha2-llminferenceservice,mutating=false,failurePolicy=fail,sideEffects=None,groups=serving.kserve.io,resources=llminferenceservices,verbs=create;update,versions=v1alpha2,name=llminferenceservice.kserve-webhook-server.v1alpha2.validator,admissionReviewVersions=v1
|
|
|
|
// LLMInferenceServiceValidator is responsible for validating the LLMInferenceService resource
|
|
// when it is created, updated, or deleted.
|
|
// +kubebuilder:object:generate=false
|
|
type LLMInferenceServiceValidator struct{}
|
|
|
|
var _ webhook.CustomValidator = &LLMInferenceServiceValidator{}
|
|
|
|
func (l *LLMInferenceServiceValidator) SetupWithManager(mgr ctrl.Manager) error {
|
|
return ctrl.NewWebhookManagedBy(mgr).
|
|
For(&LLMInferenceService{}).
|
|
WithValidator(l).
|
|
Complete()
|
|
}
|
|
|
|
func (l *LLMInferenceServiceValidator) ValidateCreate(ctx context.Context, obj runtime.Object) (admission.Warnings, error) {
|
|
llmSvc, err := utils.Convert[*LLMInferenceService](obj)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return l.validate(ctx, nil, llmSvc)
|
|
}
|
|
|
|
func (l *LLMInferenceServiceValidator) ValidateUpdate(ctx context.Context, oldObj, newObj runtime.Object) (admission.Warnings, error) {
|
|
llmSvc, err := utils.Convert[*LLMInferenceService](newObj)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
prev, err := utils.Convert[*LLMInferenceService](oldObj)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if llmSvc.GetDeletionTimestamp() != nil {
|
|
return nil, nil
|
|
}
|
|
|
|
return l.validate(ctx, prev, llmSvc)
|
|
}
|
|
|
|
func (l *LLMInferenceServiceValidator) ValidateDelete(_ context.Context, _ runtime.Object) (admission.Warnings, error) {
|
|
// No validation needed for deletion
|
|
return admission.Warnings{}, nil
|
|
}
|
|
|
|
func (l *LLMInferenceServiceValidator) validate(ctx context.Context, prev *LLMInferenceService, llmSvc *LLMInferenceService) (admission.Warnings, error) {
|
|
logger := log.FromContext(ctx)
|
|
logger.Info("Validating LLMInferenceService v1alpha2", "name", llmSvc.Name, "namespace", llmSvc.Namespace)
|
|
|
|
var allErrs field.ErrorList
|
|
var warnings admission.Warnings
|
|
|
|
routerWarnings, routerErrs := l.validateRouterCrossFieldConstraints(llmSvc)
|
|
warnings = append(warnings, routerWarnings...)
|
|
allErrs = append(allErrs, routerErrs...)
|
|
allErrs = append(allErrs, l.validateParallelismConstraints(llmSvc)...)
|
|
allErrs = append(allErrs, l.validateSchedulerConfig(llmSvc)...)
|
|
|
|
allErrs = append(allErrs, l.validateScaling(llmSvc)...)
|
|
allErrs = append(allErrs, l.validateLoRAAdapters(llmSvc)...)
|
|
allErrs = append(allErrs, l.validateManagedDRAAnnotations(llmSvc)...)
|
|
|
|
allErrs = append(allErrs, l.validateImmutable(prev, llmSvc)...)
|
|
|
|
confidentialWarnings, confidentialErrs := l.validateConfidential(llmSvc)
|
|
warnings = append(warnings, confidentialWarnings...)
|
|
allErrs = append(allErrs, confidentialErrs...)
|
|
|
|
if len(allErrs) == 0 {
|
|
logger.V(2).Info("LLMInferenceService v1alpha2 is valid", "llmisvc", llmSvc)
|
|
return warnings, nil
|
|
}
|
|
|
|
return warnings, apierrors.NewInvalid(
|
|
LLMInferenceServiceGVK.GroupKind(),
|
|
llmSvc.Name, allErrs)
|
|
}
|
|
|
|
func (l *LLMInferenceServiceValidator) validateRouterCrossFieldConstraints(llmSvc *LLMInferenceService) (admission.Warnings, field.ErrorList) {
|
|
router := llmSvc.Spec.Router
|
|
if router == nil || router.Route == nil {
|
|
return nil, nil
|
|
}
|
|
|
|
routerPath := field.NewPath("spec").Child("router")
|
|
gatewayPath := routerPath.Child("gateway")
|
|
gwRefsPath := gatewayPath.Child("refs")
|
|
routePath := routerPath.Child("route")
|
|
httpRoutePath := routePath.Child("http")
|
|
httpRouteRefs := httpRoutePath.Child("refs")
|
|
httpRouteSpec := httpRoutePath.Child("spec")
|
|
|
|
httpRoute := router.Route.HTTP
|
|
if httpRoute == nil {
|
|
return nil, nil
|
|
}
|
|
|
|
var allErrs field.ErrorList
|
|
var warnings admission.Warnings
|
|
|
|
// Both refs and spec cannot be used together
|
|
if len(httpRoute.Refs) > 0 && httpRoute.Spec != nil {
|
|
allErrs = append(allErrs, field.Invalid(
|
|
httpRoutePath,
|
|
httpRoute,
|
|
fmt.Sprintf("unsupported configuration: cannot use both custom HTTPRoute refs ('%s') and an inline route spec ('%s'); "+
|
|
"choose one",
|
|
httpRouteRefs, httpRouteSpec,
|
|
),
|
|
),
|
|
)
|
|
}
|
|
|
|
// User-defined routes (refs) cannot be used with managed gateway (empty gateway config)
|
|
if len(httpRoute.Refs) > 0 && router.Gateway != nil && len(router.Gateway.Refs) == 0 {
|
|
allErrs = append(allErrs, field.Invalid(
|
|
httpRouteRefs,
|
|
httpRoute.Refs,
|
|
fmt.Sprintf("unsupported configuration: custom HTTP routes ('%s') cannot be used with a managed gateway ('%s'); "+
|
|
"either remove '%s' or set '%s'",
|
|
httpRouteRefs, gatewayPath, httpRouteRefs, gwRefsPath,
|
|
),
|
|
))
|
|
}
|
|
|
|
// When both parentRefs and gateway refs are set, check for consistency.
|
|
// If they match, it's redundant but valid — the controller derives parentRefs from gateway.refs automatically.
|
|
// If they conflict, reject.
|
|
if httpRoute.Spec != nil && len(httpRoute.Spec.ParentRefs) > 0 &&
|
|
router.Gateway != nil && len(router.Gateway.Refs) > 0 {
|
|
if parentRefsMatchGatewayRefs(httpRoute.Spec.ParentRefs, router.Gateway.Refs) {
|
|
warnings = append(warnings,
|
|
fmt.Sprintf("%s.parentRefs can be omitted when %s is set; parentRefs will be derived automatically from gateway refs",
|
|
httpRouteSpec, gwRefsPath))
|
|
} else {
|
|
allErrs = append(allErrs, field.Invalid(
|
|
httpRoutePath.Child("spec"),
|
|
httpRoute.Spec,
|
|
fmt.Sprintf("unsupported configuration: managed HTTP route spec ('%s') has parentRefs that conflict with custom gateway refs ('%s'); "+
|
|
"either remove '%s' or '%s.parentRefs'",
|
|
httpRouteSpec, gwRefsPath, gwRefsPath, httpRouteSpec,
|
|
),
|
|
))
|
|
}
|
|
}
|
|
|
|
return warnings, allErrs
|
|
}
|
|
|
|
// parentRefsMatchGatewayRefs checks whether the parentRefs on an HTTPRoute are
|
|
// consistent with the gateway refs. A parentRef matches a gateway ref if the
|
|
// name, namespace, and sectionName are equal (Group and Kind are always Gateway).
|
|
// Both slices are sorted before comparison so order does not matter.
|
|
func parentRefsMatchGatewayRefs(parentRefs []gwapiv1.ParentReference, gatewayRefs []GatewayObjectReference) bool {
|
|
if len(parentRefs) != len(gatewayRefs) {
|
|
return false
|
|
}
|
|
|
|
// Build comparable keys and sort so that order-independent matching works.
|
|
type key struct{ ns, name, sectionName string }
|
|
toKey := func(name gwapiv1.ObjectName, ns gwapiv1.Namespace, sn *gwapiv1.SectionName) key {
|
|
sectionName := ""
|
|
if sn != nil {
|
|
sectionName = string(*sn)
|
|
}
|
|
return key{ns: string(ns), name: string(name), sectionName: sectionName}
|
|
}
|
|
cmpKey := func(a, b key) int {
|
|
if c := cmp.Compare(a.ns, b.ns); c != 0 {
|
|
return c
|
|
}
|
|
if c := cmp.Compare(a.name, b.name); c != 0 {
|
|
return c
|
|
}
|
|
return cmp.Compare(a.sectionName, b.sectionName)
|
|
}
|
|
|
|
pKeys := make([]key, len(parentRefs))
|
|
for i, ref := range parentRefs {
|
|
pKeys[i] = toKey(ref.Name, ptr.Deref(ref.Namespace, ""), ref.SectionName)
|
|
}
|
|
gKeys := make([]key, len(gatewayRefs))
|
|
for i, ref := range gatewayRefs {
|
|
gKeys[i] = toKey(ref.Name, ref.Namespace, ref.SectionName)
|
|
}
|
|
|
|
slices.SortFunc(pKeys, cmpKey)
|
|
slices.SortFunc(gKeys, cmpKey)
|
|
|
|
return slices.Equal(pKeys, gKeys)
|
|
}
|
|
|
|
func (l *LLMInferenceServiceValidator) validateParallelismConstraints(llmSvc *LLMInferenceService) field.ErrorList {
|
|
var allErrs field.ErrorList
|
|
|
|
allErrs = append(allErrs, l.validateWorkloadParallelism(field.NewPath("spec"), &llmSvc.Spec.WorkloadSpec)...)
|
|
|
|
if llmSvc.Spec.Prefill != nil {
|
|
allErrs = append(allErrs, l.validateWorkloadParallelism(field.NewPath("spec").Child("prefill"), llmSvc.Spec.Prefill)...)
|
|
}
|
|
|
|
return allErrs
|
|
}
|
|
|
|
func (l *LLMInferenceServiceValidator) validateWorkloadParallelism(basePath *field.Path, workload *WorkloadSpec) field.ErrorList {
|
|
var allErrs field.ErrorList
|
|
|
|
if workload.Worker != nil && workload.Parallelism == nil {
|
|
allErrs = append(allErrs, field.Invalid(
|
|
basePath.Child("worker"),
|
|
workload.Worker,
|
|
"when worker is specified, parallelism must be configured for either data parallelism or pipeline parallelism",
|
|
))
|
|
return allErrs
|
|
}
|
|
|
|
if workload.Parallelism == nil {
|
|
return field.ErrorList{}
|
|
}
|
|
|
|
parallelismPath := basePath.Child("parallelism")
|
|
parallelism := workload.Parallelism
|
|
|
|
if workload.Worker != nil && !parallelism.IsDataParallel() && !parallelism.IsPipelineParallel() {
|
|
allErrs = append(allErrs, field.Invalid(
|
|
basePath.Child("worker"),
|
|
workload.Worker,
|
|
"when worker is specified, parallelism must be configured for either data parallelism or pipeline parallelism",
|
|
))
|
|
}
|
|
|
|
if parallelism.IsPipelineParallel() && parallelism.IsDataParallel() {
|
|
allErrs = append(allErrs, field.Invalid(
|
|
parallelismPath,
|
|
parallelism,
|
|
"cannot set both pipeline parallelism and data parallelism (data or dataLocal) simultaneously",
|
|
))
|
|
}
|
|
|
|
// Data and DataLocal must always be set together
|
|
if (parallelism.Data != nil) != (parallelism.DataLocal != nil) {
|
|
if parallelism.Data != nil && parallelism.DataLocal == nil {
|
|
allErrs = append(allErrs, field.Invalid(
|
|
parallelismPath.Child("dataLocal"),
|
|
parallelism.DataLocal,
|
|
"dataLocal must be set when data is set",
|
|
))
|
|
}
|
|
if parallelism.DataLocal != nil && parallelism.Data == nil {
|
|
allErrs = append(allErrs, field.Invalid(
|
|
parallelismPath.Child("data"),
|
|
parallelism.Data,
|
|
"data must be set when dataLocal is set",
|
|
))
|
|
}
|
|
}
|
|
|
|
if parallelism.Pipeline != nil && *parallelism.Pipeline <= 0 {
|
|
allErrs = append(allErrs, field.Invalid(
|
|
parallelismPath.Child("pipeline"),
|
|
*parallelism.Pipeline,
|
|
"pipeline parallelism must be greater than 0",
|
|
))
|
|
}
|
|
|
|
if parallelism.Data != nil && *parallelism.Data <= 0 {
|
|
allErrs = append(allErrs, field.Invalid(
|
|
parallelismPath.Child("data"),
|
|
*parallelism.Data,
|
|
"data parallelism must be greater than 0",
|
|
))
|
|
}
|
|
|
|
if parallelism.DataLocal != nil && *parallelism.DataLocal <= 0 {
|
|
allErrs = append(allErrs, field.Invalid(
|
|
parallelismPath.Child("dataLocal"),
|
|
*parallelism.DataLocal,
|
|
"dataLocal parallelism must be greater than 0",
|
|
))
|
|
}
|
|
|
|
return allErrs
|
|
}
|
|
|
|
func (l *LLMInferenceServiceValidator) validateImmutable(prev *LLMInferenceService, curr *LLMInferenceService) field.ErrorList {
|
|
var allErrs field.ErrorList
|
|
if prev == nil {
|
|
return allErrs
|
|
}
|
|
|
|
specPath := field.NewPath("spec")
|
|
|
|
allErrs = append(allErrs, l.validateImmutableParallelism(specPath, prev.Spec.Parallelism, curr.Spec.Parallelism)...)
|
|
if curr.Spec.Prefill != nil && prev.Spec.Prefill != nil {
|
|
allErrs = append(allErrs, l.validateImmutableParallelism(specPath.Child("prefill"), prev.Spec.Prefill.Parallelism, curr.Spec.Prefill.Parallelism)...)
|
|
}
|
|
|
|
return allErrs
|
|
}
|
|
|
|
func (l *LLMInferenceServiceValidator) validateImmutableParallelism(basePath *field.Path, prev *ParallelismSpec, curr *ParallelismSpec) field.ErrorList {
|
|
var allErrs field.ErrorList
|
|
if pSize, cSize := ptr.Deref(prev.GetSize(), 1), ptr.Deref(curr.GetSize(), 1); cSize != pSize {
|
|
allErrs = append(allErrs, immutableField(
|
|
basePath.Child("parallelism"),
|
|
cSize,
|
|
fmt.Sprintf("total parallelism size is immutable, previous size %d, curr size %d", pSize, cSize),
|
|
))
|
|
}
|
|
return allErrs
|
|
}
|
|
|
|
func (l *LLMInferenceServiceValidator) validateSchedulerConfig(svc *LLMInferenceService) field.ErrorList {
|
|
var allErrs field.ErrorList
|
|
|
|
if svc.Spec.Router == nil || svc.Spec.Router.Scheduler == nil {
|
|
return allErrs
|
|
}
|
|
|
|
schedulerPath := field.NewPath("spec", "router", "scheduler")
|
|
|
|
if svc.Spec.Router.Scheduler.Replicas != nil && *svc.Spec.Router.Scheduler.Replicas <= 0 {
|
|
allErrs = append(allErrs, field.Invalid(schedulerPath.Child("replicas"), *svc.Spec.Router.Scheduler.Replicas, "scheduler replicas must be greater than zero"))
|
|
}
|
|
|
|
if svc.Spec.Router.Scheduler.Config == nil {
|
|
return allErrs
|
|
}
|
|
|
|
configPath := schedulerPath.Child("config")
|
|
|
|
if svc.Spec.Router.Scheduler.Config.Ref == nil && svc.Spec.Router.Scheduler.Config.Inline == nil {
|
|
allErrs = append(allErrs, field.Invalid(
|
|
configPath,
|
|
svc.Spec.Router.Scheduler.Config,
|
|
"either inline or ref is required",
|
|
))
|
|
}
|
|
|
|
if svc.Spec.Router.Scheduler.Config.Inline != nil && svc.Spec.Router.Scheduler.Config.Ref != nil {
|
|
allErrs = append(allErrs, field.Invalid(
|
|
configPath,
|
|
svc.Spec.Router.Scheduler.Config,
|
|
"both inline and ref are set, either specify inline or ref",
|
|
))
|
|
}
|
|
|
|
if svc.Spec.Router.Scheduler.Config.Inline != nil && len(svc.Spec.Router.Scheduler.Config.Inline.Raw) < 3 /* we expect at least a few characters '{...}' */ {
|
|
allErrs = append(allErrs, field.Invalid(
|
|
configPath.Child("inline"),
|
|
svc.Spec.Router.Scheduler.Config.Inline,
|
|
"inline configuration is invalid",
|
|
))
|
|
}
|
|
|
|
if svc.Spec.Router.Scheduler.Config.Ref != nil {
|
|
if svc.Spec.Router.Scheduler.Config.Ref.Name == "" {
|
|
allErrs = append(allErrs, field.Invalid(
|
|
configPath.Child("ref", "name"),
|
|
svc.Spec.Router.Scheduler.Config.Ref,
|
|
"name is empty",
|
|
))
|
|
}
|
|
}
|
|
|
|
return allErrs
|
|
}
|
|
|
|
func (l *LLMInferenceServiceValidator) validateLoRAAdapters(llmSvc *LLMInferenceService) field.ErrorList {
|
|
var allErrs field.ErrorList
|
|
|
|
if llmSvc.Spec.Model.LoRA == nil {
|
|
return allErrs
|
|
}
|
|
|
|
loraPath := field.NewPath("spec").Child("model", "lora")
|
|
loraSpec := llmSvc.Spec.Model.LoRA
|
|
|
|
if loraSpec.MaxRank != nil && *loraSpec.MaxRank < 1 {
|
|
allErrs = append(allErrs, field.Invalid(loraPath.Child("maxRank"), *loraSpec.MaxRank, "maxRank must be at least 1"))
|
|
}
|
|
if loraSpec.MaxAdapters != nil && *loraSpec.MaxAdapters < 1 {
|
|
allErrs = append(allErrs, field.Invalid(loraPath.Child("maxAdapters"), *loraSpec.MaxAdapters, "maxAdapters must be at least 1"))
|
|
}
|
|
if loraSpec.MaxCpuAdapters != nil && *loraSpec.MaxCpuAdapters < 1 {
|
|
allErrs = append(allErrs, field.Invalid(loraPath.Child("maxCpuAdapters"), *loraSpec.MaxCpuAdapters, "maxCpuAdapters must be at least 1"))
|
|
}
|
|
|
|
if len(llmSvc.Spec.Model.LoRA.Adapters) == 0 {
|
|
return allErrs
|
|
}
|
|
|
|
adaptersPath := loraPath.Child("adapters")
|
|
baseModelName := ptr.Deref(llmSvc.Spec.Model.Name, llmSvc.Name)
|
|
seen := make(map[string]int, len(llmSvc.Spec.Model.LoRA.Adapters))
|
|
|
|
for i, adapter := range llmSvc.Spec.Model.LoRA.Adapters {
|
|
namePath := adaptersPath.Index(i).Child("name")
|
|
|
|
if adapter.Name == nil || *adapter.Name == "" {
|
|
allErrs = append(allErrs, field.Required(namePath, "adapter name is required"))
|
|
continue
|
|
}
|
|
|
|
adapterName := *adapter.Name
|
|
|
|
if adapterName == "." || adapterName == ".." {
|
|
allErrs = append(allErrs, field.Invalid(
|
|
namePath,
|
|
adapterName,
|
|
"adapter name must not include \".\" or \"..\" (path traversal risk)",
|
|
))
|
|
continue
|
|
}
|
|
|
|
if prevIdx, dup := seen[adapterName]; dup {
|
|
allErrs = append(allErrs, field.Invalid(
|
|
namePath,
|
|
adapterName,
|
|
fmt.Sprintf("duplicate name (same as adapters[%d])", prevIdx),
|
|
))
|
|
} else {
|
|
seen[adapterName] = i
|
|
}
|
|
|
|
if adapterName == baseModelName {
|
|
allErrs = append(allErrs, field.Invalid(
|
|
namePath,
|
|
adapterName,
|
|
fmt.Sprintf("adapter name must differ from base model name %q", baseModelName),
|
|
))
|
|
}
|
|
}
|
|
|
|
return allErrs
|
|
}
|
|
|
|
func (l *LLMInferenceServiceValidator) validateScaling(llmSvc *LLMInferenceService) field.ErrorList {
|
|
var allErrs field.ErrorList
|
|
|
|
// Validate scaling on the main (decode) workload
|
|
allErrs = append(allErrs, ValidateWorkloadScaling(field.NewPath("spec"), &llmSvc.Spec.WorkloadSpec)...)
|
|
|
|
// Validate scaling on the prefill workload, if present
|
|
if llmSvc.Spec.Prefill != nil {
|
|
allErrs = append(allErrs, ValidateWorkloadScaling(field.NewPath("spec").Child("prefill"), llmSvc.Spec.Prefill)...)
|
|
}
|
|
|
|
allErrs = append(allErrs, l.validateActuatorConsistency(llmSvc)...)
|
|
|
|
return allErrs
|
|
}
|
|
|
|
func (l *LLMInferenceServiceValidator) validateActuatorConsistency(llmSvc *LLMInferenceService) field.ErrorList {
|
|
return ValidateActuatorConsistency(&llmSvc.Spec.WorkloadSpec, llmSvc.Spec.Prefill)
|
|
}
|
|
|
|
// ValidateActuatorConsistency ensures that when both decode and prefill workloads
|
|
// have autoscaling configured, they use the same actuator backend (both HPA or both KEDA).
|
|
// Mixing backends is not supported because:
|
|
// - HPA requires a Prometheus Adapter to expose metrics to the Kubernetes Metrics API
|
|
// - KEDA queries Prometheus directly without an adapter
|
|
//
|
|
// Using different backends forces operators to maintain two different metric pipelines
|
|
// and results in independent, unsynchronised scaling decisions across the two sides
|
|
// of a disaggregated deployment.
|
|
//
|
|
// It is exported so that v1alpha1 can reuse it via conversion.
|
|
func ValidateActuatorConsistency(decode *WorkloadSpec, prefill *WorkloadSpec) field.ErrorList {
|
|
if prefill == nil {
|
|
return nil
|
|
}
|
|
|
|
// Both sides must have scaling.wva configured for a mismatch to be possible.
|
|
decodeScaling := decode.Scaling
|
|
prefillScaling := prefill.Scaling
|
|
if decodeScaling == nil || decodeScaling.WVA == nil || prefillScaling == nil || prefillScaling.WVA == nil {
|
|
return nil
|
|
}
|
|
|
|
decodeUsesHPA := decodeScaling.WVA.HPA != nil
|
|
prefillUsesHPA := prefillScaling.WVA.HPA != nil
|
|
|
|
if decodeUsesHPA == prefillUsesHPA {
|
|
return nil
|
|
}
|
|
|
|
decodeBackend := "keda"
|
|
prefillBackend := "hpa"
|
|
if decodeUsesHPA {
|
|
decodeBackend = "hpa"
|
|
prefillBackend = "keda"
|
|
}
|
|
|
|
return field.ErrorList{
|
|
field.Invalid(
|
|
field.NewPath("spec").Child("prefill", "scaling", "wva"),
|
|
prefillScaling.WVA,
|
|
fmt.Sprintf(
|
|
"decode and prefill must use the same actuator backend; "+
|
|
"decode uses %s but prefill uses %s — "+
|
|
"mixing backends requires two separate metric pipelines and leads to independent, unsynchronised scaling decisions",
|
|
decodeBackend, prefillBackend,
|
|
),
|
|
),
|
|
}
|
|
}
|
|
|
|
// ValidateWorkloadScaling validates the scaling configuration of a single workload
|
|
// (decode or prefill). It is exported so that v1alpha1 can reuse it via conversion.
|
|
func ValidateWorkloadScaling(basePath *field.Path, workload *WorkloadSpec) field.ErrorList {
|
|
var allErrs field.ErrorList
|
|
|
|
scaling := workload.Scaling
|
|
if scaling == nil {
|
|
return allErrs
|
|
}
|
|
|
|
scalingPath := basePath.Child("scaling")
|
|
|
|
// Replicas and scaling are mutually exclusive
|
|
if workload.Replicas != nil {
|
|
allErrs = append(allErrs, field.Invalid(
|
|
scalingPath,
|
|
scaling,
|
|
"scaling and replicas are mutually exclusive; use scaling for autoscaled deployments or replicas for static deployments",
|
|
))
|
|
}
|
|
|
|
// Validate replica bounds
|
|
if scaling.MinReplicas != nil && *scaling.MinReplicas > scaling.MaxReplicas {
|
|
allErrs = append(allErrs, field.Invalid(
|
|
scalingPath.Child("minReplicas"),
|
|
*scaling.MinReplicas,
|
|
fmt.Sprintf("minReplicas (%d) cannot exceed maxReplicas (%d)", *scaling.MinReplicas, scaling.MaxReplicas),
|
|
))
|
|
}
|
|
|
|
// WVA is required when scaling is configured — it provides the scaling mechanism
|
|
if scaling.WVA == nil {
|
|
allErrs = append(allErrs, field.Required(
|
|
scalingPath.Child("wva"),
|
|
"wva is required when scaling is configured; it provides the autoscaling mechanism",
|
|
))
|
|
return allErrs
|
|
}
|
|
|
|
// Validate WVA configuration
|
|
wvaPath := scalingPath.Child("wva")
|
|
|
|
// HPA and KEDA are mutually exclusive
|
|
if scaling.WVA.HPA != nil && scaling.WVA.KEDA != nil {
|
|
allErrs = append(allErrs, field.Invalid(
|
|
wvaPath,
|
|
scaling.WVA,
|
|
"hpa and keda are mutually exclusive; choose one actuator backend",
|
|
))
|
|
}
|
|
|
|
// Must specify at least one actuator
|
|
if scaling.WVA.HPA == nil && scaling.WVA.KEDA == nil {
|
|
allErrs = append(allErrs, field.Required(
|
|
wvaPath,
|
|
"either hpa or keda must be specified as the actuator backend",
|
|
))
|
|
}
|
|
|
|
// Validate variantCost format (must be a non-negative numeric string, e.g., "10", "10.0", "0.5")
|
|
if scaling.WVA.VariantCost != "" {
|
|
if !variantCostPattern.MatchString(scaling.WVA.VariantCost) {
|
|
allErrs = append(allErrs, field.Invalid(
|
|
wvaPath.Child("variantCost"),
|
|
scaling.WVA.VariantCost,
|
|
"variantCost must be a non-negative numeric string (e.g., \"10\", \"10.0\", \"0.5\")",
|
|
))
|
|
}
|
|
}
|
|
|
|
// Validate KEDA advanced fields that are controller-owned and must not be set by users
|
|
if scaling.WVA.KEDA != nil && scaling.WVA.KEDA.Advanced != nil {
|
|
kedaPath := wvaPath.Child("keda")
|
|
sm := scaling.WVA.KEDA.Advanced.ScalingModifiers
|
|
if sm.Formula != "" || sm.Target != "" || sm.ActivationTarget != "" || string(sm.MetricType) != "" {
|
|
allErrs = append(allErrs, field.Forbidden(
|
|
kedaPath.Child("advanced", "scalingModifiers"),
|
|
"scalingModifiers must not be set; WVA controls the scaling metric formula and logic",
|
|
))
|
|
}
|
|
if scaling.WVA.KEDA.Advanced.HorizontalPodAutoscalerConfig != nil &&
|
|
scaling.WVA.KEDA.Advanced.HorizontalPodAutoscalerConfig.Name != "" {
|
|
allErrs = append(allErrs, field.Forbidden(
|
|
kedaPath.Child("advanced", "horizontalPodAutoscalerConfig", "name"),
|
|
"horizontalPodAutoscalerConfig.name must not be set; the controller manages the HPA name",
|
|
))
|
|
}
|
|
}
|
|
|
|
// Validate KEDA idleReplicaCount requires minReplicas and must be less than it
|
|
if scaling.WVA.KEDA != nil && scaling.WVA.KEDA.IdleReplicaCount != nil {
|
|
if scaling.MinReplicas == nil {
|
|
allErrs = append(allErrs, field.Required(
|
|
scalingPath.Child("minReplicas"),
|
|
fmt.Sprintf("minReplicas is required when idleReplicaCount is set; "+
|
|
"idleReplicaCount (%d) must be less than minReplicas",
|
|
*scaling.WVA.KEDA.IdleReplicaCount),
|
|
))
|
|
} else if *scaling.WVA.KEDA.IdleReplicaCount >= *scaling.MinReplicas {
|
|
allErrs = append(allErrs, field.Invalid(
|
|
wvaPath.Child("keda").Child("idleReplicaCount"),
|
|
*scaling.WVA.KEDA.IdleReplicaCount,
|
|
fmt.Sprintf("idleReplicaCount (%d) must be less than minReplicas (%d); "+
|
|
"idleReplicaCount defines the replica floor when no triggers are active",
|
|
*scaling.WVA.KEDA.IdleReplicaCount, *scaling.MinReplicas),
|
|
))
|
|
}
|
|
}
|
|
|
|
return allErrs
|
|
}
|
|
|
|
// immutableField returns a *Error indicating "unsupported mutation".
|
|
// This is used to report unsupported mutation of values.
|
|
func immutableField(path *field.Path, value interface{}, detail string) *field.Error {
|
|
return &field.Error{Type: field.ErrorTypeNotSupported, Field: path.String(), BadValue: value, Detail: detail}
|
|
}
|
|
|
|
// validateManagedDRAAnnotations performs admission-time validation of the
|
|
// serving.kserve.io/exp-dra-* annotations to catch user mistakes early.
|
|
func (l *LLMInferenceServiceValidator) validateManagedDRAAnnotations(llmSvc *LLMInferenceService) field.ErrorList {
|
|
var allErrs field.ErrorList
|
|
annotations := llmSvc.GetAnnotations()
|
|
if len(annotations) == 0 {
|
|
return allErrs
|
|
}
|
|
|
|
annotationsPath := field.NewPath("metadata").Child("annotations")
|
|
|
|
hasDeviceClass := llmSvc.HasManagedDRA()
|
|
_, hasDeviceCount := annotations[constants.ManagedDRADeviceCountAnnotationKey]
|
|
_, hasCelSelector := annotations[constants.ManagedDRACelSelectorAnnotationKey]
|
|
_, hasContainerName := annotations[constants.ManagedDRAContainerNameAnnotationKey]
|
|
|
|
// Require device-class if any other DRA annotation is set.
|
|
if !hasDeviceClass && (hasDeviceCount || hasCelSelector || hasContainerName) {
|
|
allErrs = append(allErrs, field.Required(
|
|
annotationsPath.Key(constants.ManagedDRADeviceClassAnnotationKey),
|
|
fmt.Sprintf("%s is required to enable managed DRA when %s, %s, or %s is set",
|
|
constants.ManagedDRADeviceClassAnnotationKey,
|
|
constants.ManagedDRADeviceCountAnnotationKey,
|
|
constants.ManagedDRACelSelectorAnnotationKey,
|
|
constants.ManagedDRAContainerNameAnnotationKey),
|
|
))
|
|
}
|
|
|
|
if trimmed, present := llmSvc.ManagedDRADeviceClass(); present {
|
|
raw := annotations[constants.ManagedDRADeviceClassAnnotationKey]
|
|
if trimmed == "" {
|
|
allErrs = append(allErrs, field.Invalid(
|
|
annotationsPath.Key(constants.ManagedDRADeviceClassAnnotationKey),
|
|
raw,
|
|
"device class must not be empty",
|
|
))
|
|
} else if errs := validation.IsDNS1123Subdomain(trimmed); len(errs) > 0 {
|
|
allErrs = append(allErrs, field.Invalid(
|
|
annotationsPath.Key(constants.ManagedDRADeviceClassAnnotationKey),
|
|
raw,
|
|
"device class must be a DNS subdomain: "+strings.Join(errs, "; "),
|
|
))
|
|
}
|
|
}
|
|
|
|
if hasDeviceCount {
|
|
if _, err := llmSvc.ManagedDRADeviceCount(); err != nil {
|
|
allErrs = append(allErrs, field.Invalid(
|
|
annotationsPath.Key(constants.ManagedDRADeviceCountAnnotationKey),
|
|
annotations[constants.ManagedDRADeviceCountAnnotationKey],
|
|
err.Error(),
|
|
))
|
|
}
|
|
}
|
|
|
|
if hasCelSelector && len(llmSvc.ManagedDRACelSelectors()) == 0 {
|
|
allErrs = append(allErrs, field.Invalid(
|
|
annotationsPath.Key(constants.ManagedDRACelSelectorAnnotationKey),
|
|
annotations[constants.ManagedDRACelSelectorAnnotationKey],
|
|
"cel selector must contain at least one non-empty CEL expression",
|
|
))
|
|
}
|
|
|
|
if trimmed, present := llmSvc.ManagedDRAContainerName(); present {
|
|
raw := annotations[constants.ManagedDRAContainerNameAnnotationKey]
|
|
if trimmed == "" {
|
|
allErrs = append(allErrs, field.Invalid(
|
|
annotationsPath.Key(constants.ManagedDRAContainerNameAnnotationKey),
|
|
raw,
|
|
"container name must not be empty",
|
|
))
|
|
} else if errs := validation.IsDNS1123Label(trimmed); len(errs) > 0 {
|
|
allErrs = append(allErrs, field.Invalid(
|
|
annotationsPath.Key(constants.ManagedDRAContainerNameAnnotationKey),
|
|
raw,
|
|
"container name must be a DNS label: "+strings.Join(errs, "; "),
|
|
))
|
|
}
|
|
}
|
|
|
|
return allErrs
|
|
}
|
|
|
|
// validateConfidential validates the confidential spec on the model.
|
|
func (l *LLMInferenceServiceValidator) validateConfidential(llmSvc *LLMInferenceService) (admission.Warnings, field.ErrorList) {
|
|
confidential := llmSvc.Spec.Model.Confidential
|
|
if confidential == nil {
|
|
return nil, nil
|
|
}
|
|
|
|
var resourceId *string
|
|
if confidential.ResourceId != nil {
|
|
resourceId = confidential.ResourceId
|
|
}
|
|
|
|
return kservevalidation.ValidateConfidentialSpec(
|
|
confidential.Enabled,
|
|
resourceId,
|
|
llmSvc.Spec.Model.URI.String(),
|
|
field.NewPath("spec", "model"),
|
|
)
|
|
}
|