mirror of
https://github.com/hanzo-ml/kserve.git
synced 2026-10-30 20:53:49 +00:00
382 lines
13 KiB
Go
382 lines
13 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 v1alpha1
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
|
|
"k8s.io/utils/ptr"
|
|
|
|
apierrors "k8s.io/apimachinery/pkg/api/errors"
|
|
"k8s.io/apimachinery/pkg/runtime"
|
|
"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"
|
|
|
|
"github.com/kserve/kserve/pkg/apis/serving/v1alpha2"
|
|
"github.com/kserve/kserve/pkg/utils"
|
|
)
|
|
|
|
// +kubebuilder:webhook:path=/validate-serving-kserve-io-v1alpha1-llminferenceservice,mutating=false,failurePolicy=fail,sideEffects=None,groups=serving.kserve.io,resources=llminferenceservices,verbs=create;update,versions=v1alpha1,name=llminferenceservice.kserve-webhook-server.v1alpha1.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) {
|
|
warnings := admission.Warnings{}
|
|
llmSvc, err := utils.Convert[*LLMInferenceService](obj)
|
|
if err != nil {
|
|
return warnings, err
|
|
}
|
|
|
|
return warnings, l.validate(ctx, nil, llmSvc)
|
|
}
|
|
|
|
func (l *LLMInferenceServiceValidator) ValidateUpdate(ctx context.Context, oldObj, newObj runtime.Object) (admission.Warnings, error) {
|
|
warnings := admission.Warnings{}
|
|
llmSvc, err := utils.Convert[*LLMInferenceService](newObj)
|
|
if err != nil {
|
|
return warnings, err
|
|
}
|
|
prev, err := utils.Convert[*LLMInferenceService](oldObj)
|
|
if err != nil {
|
|
return warnings, err
|
|
}
|
|
if llmSvc.GetDeletionTimestamp() != nil {
|
|
return warnings, nil
|
|
}
|
|
|
|
return warnings, 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) error {
|
|
logger := log.FromContext(ctx)
|
|
logger.Info("Validating LLMInferenceService v1alpha1", "name", llmSvc.Name, "namespace", llmSvc.Namespace)
|
|
|
|
var allErrs field.ErrorList
|
|
|
|
allErrs = append(allErrs, l.validateRouterCrossFieldConstraints(llmSvc)...)
|
|
allErrs = append(allErrs, l.validateParallelismConstraints(llmSvc)...)
|
|
allErrs = append(allErrs, l.validateSchedulerConfig(llmSvc)...)
|
|
allErrs = append(allErrs, l.validateScaling(llmSvc)...)
|
|
allErrs = append(allErrs, l.validateImmutable(prev, llmSvc)...)
|
|
|
|
if len(allErrs) == 0 {
|
|
logger.V(2).Info("LLMInferenceService v1alpha1 is valid", "llmisvc", llmSvc)
|
|
return nil
|
|
}
|
|
|
|
return apierrors.NewInvalid(
|
|
LLMInferenceServiceGVK.GroupKind(),
|
|
llmSvc.Name, allErrs)
|
|
}
|
|
|
|
func (l *LLMInferenceServiceValidator) validateRouterCrossFieldConstraints(llmSvc *LLMInferenceService) field.ErrorList {
|
|
router := llmSvc.Spec.Router
|
|
if router == nil || router.Route == nil {
|
|
return field.ErrorList{}
|
|
}
|
|
|
|
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 field.ErrorList{}
|
|
}
|
|
|
|
var allErrs field.ErrorList
|
|
|
|
// 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,
|
|
),
|
|
))
|
|
}
|
|
|
|
// Managed route spec referring to the gateway cannot be used with user-defined gateway refs
|
|
if httpRoute.Spec != nil && len(httpRoute.Spec.ParentRefs) > 0 &&
|
|
router.Gateway != nil && len(router.Gateway.Refs) > 0 {
|
|
allErrs = append(allErrs, field.Invalid(
|
|
httpRoutePath.Child("spec"),
|
|
httpRoute.Spec,
|
|
fmt.Sprintf("unsupported configuration: managed HTTP route spec ('%s') cannot be used with custom gateway refs ('%s'); "+
|
|
"either remove '%s' or '%s.parentRefs'",
|
|
httpRouteSpec, gwRefsPath, gwRefsPath, httpRouteSpec,
|
|
),
|
|
))
|
|
}
|
|
|
|
return allErrs
|
|
}
|
|
|
|
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, immutable(
|
|
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) validateScaling(llmSvc *LLMInferenceService) field.ErrorList {
|
|
var allErrs field.ErrorList
|
|
|
|
// Validate scaling on the main (decode) workload
|
|
allErrs = append(allErrs, l.validateWorkloadScaling(field.NewPath("spec"), &llmSvc.Spec.WorkloadSpec)...)
|
|
|
|
// Validate scaling on the prefill workload, if present
|
|
if llmSvc.Spec.Prefill != nil {
|
|
allErrs = append(allErrs, l.validateWorkloadScaling(field.NewPath("spec").Child("prefill"), llmSvc.Spec.Prefill)...)
|
|
}
|
|
|
|
// Validate actuator backend consistency across decode and prefill.
|
|
// Convert to the hub (v1alpha2) type and delegate to its exported validator so
|
|
// the consistency rules live in exactly one place.
|
|
decode := convertWorkloadSpecToV1Alpha2(&llmSvc.Spec.WorkloadSpec)
|
|
var prefill *v1alpha2.WorkloadSpec
|
|
if llmSvc.Spec.Prefill != nil {
|
|
p := convertWorkloadSpecToV1Alpha2(llmSvc.Spec.Prefill)
|
|
prefill = &p
|
|
}
|
|
allErrs = append(allErrs, v1alpha2.ValidateActuatorConsistency(&decode, prefill)...)
|
|
|
|
return allErrs
|
|
}
|
|
|
|
func (l *LLMInferenceServiceValidator) validateWorkloadScaling(basePath *field.Path, workload *WorkloadSpec) field.ErrorList {
|
|
// Convert to the hub (v1alpha2) type and delegate to its exported validator so
|
|
// the scaling rules live in exactly one place.
|
|
w := convertWorkloadSpecToV1Alpha2(workload)
|
|
return v1alpha2.ValidateWorkloadScaling(basePath, &w)
|
|
}
|
|
|
|
// immutable returns a *Error indicating "unsupported mutation".
|
|
// This is used to report unsupported mutation of values.
|
|
func immutable(path *field.Path, value interface{}, detail string) *field.Error {
|
|
return &field.Error{Type: field.ErrorTypeNotSupported, Field: path.String(), BadValue: value, Detail: detail}
|
|
}
|