Files
kserve/pkg/apis/serving/v1alpha2/llm_inference_service_validation_test.go
2026-06-19 11:33:53 -04:00

1558 lines
42 KiB
Go

/*
Copyright 2026 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 (
"strings"
"testing"
kedav1alpha1 "github.com/kedacore/keda/v2/apis/keda/v1alpha1"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
autoscalingv2 "k8s.io/api/autoscaling/v2"
corev1 "k8s.io/api/core/v1"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/util/validation/field"
"k8s.io/utils/ptr"
"knative.dev/pkg/apis"
gwapiv1 "sigs.k8s.io/gateway-api/apis/v1"
)
func TestParentRefsMatchGatewayRefs(t *testing.T) {
tests := []struct {
name string
parentRefs []gwapiv1.ParentReference
gwRefs []GatewayObjectReference
want bool
}{
{
name: "both empty",
parentRefs: nil,
gwRefs: nil,
want: true,
},
{
name: "single ref, matching",
parentRefs: []gwapiv1.ParentReference{
{Name: "gw-1", Namespace: ptr.To(gwapiv1.Namespace("ns-a"))},
},
gwRefs: []GatewayObjectReference{
{UntypedObjectReference: UntypedObjectReference{Name: "gw-1", Namespace: "ns-a"}},
},
want: true,
},
{
name: "single ref, different name",
parentRefs: []gwapiv1.ParentReference{
{Name: "gw-1", Namespace: ptr.To(gwapiv1.Namespace("ns-a"))},
},
gwRefs: []GatewayObjectReference{
{UntypedObjectReference: UntypedObjectReference{Name: "gw-other", Namespace: "ns-a"}},
},
want: false,
},
{
name: "single ref, different namespace",
parentRefs: []gwapiv1.ParentReference{
{Name: "gw-1", Namespace: ptr.To(gwapiv1.Namespace("ns-a"))},
},
gwRefs: []GatewayObjectReference{
{UntypedObjectReference: UntypedObjectReference{Name: "gw-1", Namespace: "ns-b"}},
},
want: false,
},
{
name: "parentRef with nil namespace matches empty namespace",
parentRefs: []gwapiv1.ParentReference{
{Name: "gw-1"},
},
gwRefs: []GatewayObjectReference{
{UntypedObjectReference: UntypedObjectReference{Name: "gw-1", Namespace: ""}},
},
want: true,
},
{
name: "parentRef with nil namespace does not match non-empty namespace",
parentRefs: []gwapiv1.ParentReference{
{Name: "gw-1"},
},
gwRefs: []GatewayObjectReference{
{UntypedObjectReference: UntypedObjectReference{Name: "gw-1", Namespace: "ns-a"}},
},
want: false,
},
{
name: "different lengths",
parentRefs: []gwapiv1.ParentReference{
{Name: "gw-1", Namespace: ptr.To(gwapiv1.Namespace("ns-a"))},
{Name: "gw-2", Namespace: ptr.To(gwapiv1.Namespace("ns-a"))},
},
gwRefs: []GatewayObjectReference{
{UntypedObjectReference: UntypedObjectReference{Name: "gw-1", Namespace: "ns-a"}},
},
want: false,
},
{
name: "multiple refs, same order",
parentRefs: []gwapiv1.ParentReference{
{Name: "gw-1", Namespace: ptr.To(gwapiv1.Namespace("ns-a"))},
{Name: "gw-2", Namespace: ptr.To(gwapiv1.Namespace("ns-b"))},
},
gwRefs: []GatewayObjectReference{
{UntypedObjectReference: UntypedObjectReference{Name: "gw-1", Namespace: "ns-a"}},
{UntypedObjectReference: UntypedObjectReference{Name: "gw-2", Namespace: "ns-b"}},
},
want: true,
},
{
name: "multiple refs, different order",
parentRefs: []gwapiv1.ParentReference{
{Name: "gw-2", Namespace: ptr.To(gwapiv1.Namespace("ns-b"))},
{Name: "gw-1", Namespace: ptr.To(gwapiv1.Namespace("ns-a"))},
},
gwRefs: []GatewayObjectReference{
{UntypedObjectReference: UntypedObjectReference{Name: "gw-1", Namespace: "ns-a"}},
{UntypedObjectReference: UntypedObjectReference{Name: "gw-2", Namespace: "ns-b"}},
},
want: true,
},
{
name: "multiple refs, one mismatch",
parentRefs: []gwapiv1.ParentReference{
{Name: "gw-1", Namespace: ptr.To(gwapiv1.Namespace("ns-a"))},
{Name: "gw-3", Namespace: ptr.To(gwapiv1.Namespace("ns-b"))},
},
gwRefs: []GatewayObjectReference{
{UntypedObjectReference: UntypedObjectReference{Name: "gw-1", Namespace: "ns-a"}},
{UntypedObjectReference: UntypedObjectReference{Name: "gw-2", Namespace: "ns-b"}},
},
want: false,
},
{
name: "matching sectionName",
parentRefs: []gwapiv1.ParentReference{
{Name: "gw-1", Namespace: ptr.To(gwapiv1.Namespace("ns-a")), SectionName: ptr.To(gwapiv1.SectionName("https"))},
},
gwRefs: []GatewayObjectReference{
{
UntypedObjectReference: UntypedObjectReference{Name: "gw-1", Namespace: "ns-a"},
SectionName: ptr.To(gwapiv1.SectionName("https")),
},
},
want: true,
},
{
name: "different sectionName - same gateway otherwise",
parentRefs: []gwapiv1.ParentReference{
{Name: "gw-1", Namespace: ptr.To(gwapiv1.Namespace("ns-a")), SectionName: ptr.To(gwapiv1.SectionName("https"))},
},
gwRefs: []GatewayObjectReference{
{
UntypedObjectReference: UntypedObjectReference{Name: "gw-1", Namespace: "ns-a"},
SectionName: ptr.To(gwapiv1.SectionName("http")),
},
},
want: false,
},
{
name: "parentRef has sectionName, gwRef does not",
parentRefs: []gwapiv1.ParentReference{
{Name: "gw-1", Namespace: ptr.To(gwapiv1.Namespace("ns-a")), SectionName: ptr.To(gwapiv1.SectionName("https"))},
},
gwRefs: []GatewayObjectReference{
{UntypedObjectReference: UntypedObjectReference{Name: "gw-1", Namespace: "ns-a"}},
},
want: false,
},
{
name: "gwRef has sectionName, parentRef does not",
parentRefs: []gwapiv1.ParentReference{
{Name: "gw-1", Namespace: ptr.To(gwapiv1.Namespace("ns-a"))},
},
gwRefs: []GatewayObjectReference{
{
UntypedObjectReference: UntypedObjectReference{Name: "gw-1", Namespace: "ns-a"},
SectionName: ptr.To(gwapiv1.SectionName("https")),
},
},
want: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := parentRefsMatchGatewayRefs(tt.parentRefs, tt.gwRefs)
if got != tt.want {
t.Errorf("parentRefsMatchGatewayRefs() = %v, want %v", got, tt.want)
}
})
}
}
func newBaseLLMInferenceServiceV1Alpha2() *LLMInferenceService {
return &LLMInferenceService{
ObjectMeta: metav1.ObjectMeta{
Name: "test-llm-isvc",
Namespace: "default",
},
Spec: LLMInferenceServiceSpec{
Model: LLMModelSpec{
URI: apis.URL{Scheme: "hf", Host: "meta-llama/Llama-2-7b"},
},
},
}
}
func TestValidateUpdate_DeletionBypass(t *testing.T) {
validator := &LLMInferenceServiceValidator{}
oldSvc := newBaseLLMInferenceServiceV1Alpha2()
newSvc := newBaseLLMInferenceServiceV1Alpha2()
newSvc.Spec.WorkloadSpec = WorkloadSpec{
Replicas: ptr.To(int32(3)),
Scaling: &ScalingSpec{
MaxReplicas: 5,
WVA: &WVASpec{
ActuatorSpec: ActuatorSpec{
HPA: &HPAScalingSpec{},
},
},
},
}
// Without DeletionTimestamp, this should be rejected (replicas + scaling are mutually exclusive)
warnings, err := validator.ValidateUpdate(t.Context(), oldSvc, newSvc)
assert.Empty(t, warnings)
assert.Error(t, err)
// With DeletionTimestamp set, the same object should be accepted
deletingSvc := newSvc.DeepCopy()
now := metav1.Now()
deletingSvc.DeletionTimestamp = &now
warnings, err = validator.ValidateUpdate(t.Context(), oldSvc, deletingSvc)
assert.Empty(t, warnings)
assert.NoError(t, err)
}
func TestValidateWorkloadScaling(t *testing.T) {
tests := []struct {
name string
workload *WorkloadSpec
wantErrCount int
wantErrStrings []string
}{
{
name: "valid: scaling with WVA + HPA",
workload: &WorkloadSpec{
Scaling: &ScalingSpec{
MinReplicas: ptr.To(int32(1)),
MaxReplicas: 5,
WVA: &WVASpec{
VariantCost: "10.0",
ActuatorSpec: ActuatorSpec{
HPA: &HPAScalingSpec{},
},
},
},
},
wantErrCount: 0,
},
{
name: "valid: scaling with WVA + KEDA and idleReplicaCount",
workload: &WorkloadSpec{
Scaling: &ScalingSpec{
MinReplicas: ptr.To(int32(2)),
MaxReplicas: 10,
WVA: &WVASpec{
VariantCost: "5.0",
ActuatorSpec: ActuatorSpec{
KEDA: &KEDAScalingSpec{
PollingInterval: ptr.To(int32(30)),
CooldownPeriod: ptr.To(int32(60)),
IdleReplicaCount: ptr.To(int32(1)),
},
},
},
},
},
wantErrCount: 0,
},
{
name: "valid: no scaling configured",
workload: &WorkloadSpec{},
wantErrCount: 0,
},
{
name: "valid: scaling with only maxReplicas (no minReplicas)",
workload: &WorkloadSpec{
Scaling: &ScalingSpec{
MaxReplicas: 5,
WVA: &WVASpec{
ActuatorSpec: ActuatorSpec{
HPA: &HPAScalingSpec{},
},
},
},
},
wantErrCount: 0,
},
{
name: "valid: variantCost integer format",
workload: &WorkloadSpec{
Scaling: &ScalingSpec{
MaxReplicas: 5,
WVA: &WVASpec{
VariantCost: "10",
ActuatorSpec: ActuatorSpec{
HPA: &HPAScalingSpec{},
},
},
},
},
wantErrCount: 0,
},
{
name: "valid: variantCost decimal format",
workload: &WorkloadSpec{
Scaling: &ScalingSpec{
MaxReplicas: 5,
WVA: &WVASpec{
VariantCost: "0.5",
ActuatorSpec: ActuatorSpec{
HPA: &HPAScalingSpec{},
},
},
},
},
wantErrCount: 0,
},
{
name: "valid: HPA with behavior configured",
workload: &WorkloadSpec{
Scaling: &ScalingSpec{
MinReplicas: ptr.To(int32(1)),
MaxReplicas: 5,
WVA: &WVASpec{
ActuatorSpec: ActuatorSpec{
HPA: &HPAScalingSpec{
Behavior: &autoscalingv2.HorizontalPodAutoscalerBehavior{
ScaleUp: &autoscalingv2.HPAScalingRules{
StabilizationWindowSeconds: ptr.To(int32(60)),
},
},
},
},
},
},
},
wantErrCount: 0,
},
{
name: "valid: empty variantCost (uses default)",
workload: &WorkloadSpec{
Scaling: &ScalingSpec{
MaxReplicas: 5,
WVA: &WVASpec{
VariantCost: "",
ActuatorSpec: ActuatorSpec{
HPA: &HPAScalingSpec{},
},
},
},
},
wantErrCount: 0,
},
{
name: "valid: idleReplicaCount=1 minReplicas=2 (minimum valid combo)",
workload: &WorkloadSpec{
Scaling: &ScalingSpec{
MinReplicas: ptr.To(int32(2)),
MaxReplicas: 10,
WVA: &WVASpec{
ActuatorSpec: ActuatorSpec{
KEDA: &KEDAScalingSpec{
IdleReplicaCount: ptr.To(int32(1)),
},
},
},
},
},
wantErrCount: 0,
},
{
name: "valid: initialCooldownPeriod set",
workload: &WorkloadSpec{
Scaling: &ScalingSpec{
MaxReplicas: 5,
WVA: &WVASpec{
ActuatorSpec: ActuatorSpec{
KEDA: &KEDAScalingSpec{
InitialCooldownPeriod: ptr.To(int32(60)),
},
},
},
},
},
wantErrCount: 0,
},
{
name: "valid: fallback set",
workload: &WorkloadSpec{
Scaling: &ScalingSpec{
MaxReplicas: 5,
WVA: &WVASpec{
ActuatorSpec: ActuatorSpec{
KEDA: &KEDAScalingSpec{
Fallback: &kedav1alpha1.Fallback{
FailureThreshold: 3,
Replicas: 2,
},
},
},
},
},
},
wantErrCount: 0,
},
{
name: "valid: restoreToOriginalReplicaCount set in advanced",
workload: &WorkloadSpec{
Scaling: &ScalingSpec{
MaxReplicas: 5,
WVA: &WVASpec{
ActuatorSpec: ActuatorSpec{
KEDA: &KEDAScalingSpec{
Advanced: &kedav1alpha1.AdvancedConfig{
RestoreToOriginalReplicaCount: true,
},
},
},
},
},
},
wantErrCount: 0,
},
{
name: "error: scalingModifiers target set",
workload: &WorkloadSpec{
Scaling: &ScalingSpec{
MaxReplicas: 5,
WVA: &WVASpec{
ActuatorSpec: ActuatorSpec{
KEDA: &KEDAScalingSpec{
Advanced: &kedav1alpha1.AdvancedConfig{
ScalingModifiers: kedav1alpha1.ScalingModifiers{
Target: "10",
},
},
},
},
},
},
},
wantErrCount: 1,
wantErrStrings: []string{"scalingModifiers must not be set"},
},
{
name: "error: scalingModifiers activationTarget set",
workload: &WorkloadSpec{
Scaling: &ScalingSpec{
MaxReplicas: 5,
WVA: &WVASpec{
ActuatorSpec: ActuatorSpec{
KEDA: &KEDAScalingSpec{
Advanced: &kedav1alpha1.AdvancedConfig{
ScalingModifiers: kedav1alpha1.ScalingModifiers{
ActivationTarget: "1",
},
},
},
},
},
},
},
wantErrCount: 1,
wantErrStrings: []string{"scalingModifiers must not be set"},
},
{
name: "error: scalingModifiers metricType set",
workload: &WorkloadSpec{
Scaling: &ScalingSpec{
MaxReplicas: 5,
WVA: &WVASpec{
ActuatorSpec: ActuatorSpec{
KEDA: &KEDAScalingSpec{
Advanced: &kedav1alpha1.AdvancedConfig{
ScalingModifiers: kedav1alpha1.ScalingModifiers{
MetricType: autoscalingv2.AverageValueMetricType,
},
},
},
},
},
},
},
wantErrCount: 1,
wantErrStrings: []string{"scalingModifiers must not be set"},
},
{
name: "error: both scalingModifiers and hpa name set (2 errors)",
workload: &WorkloadSpec{
Scaling: &ScalingSpec{
MaxReplicas: 5,
WVA: &WVASpec{
ActuatorSpec: ActuatorSpec{
KEDA: &KEDAScalingSpec{
Advanced: &kedav1alpha1.AdvancedConfig{
ScalingModifiers: kedav1alpha1.ScalingModifiers{
Formula: "wva_desired_replicas",
},
HorizontalPodAutoscalerConfig: &kedav1alpha1.HorizontalPodAutoscalerConfig{
Name: "my-hpa",
},
},
},
},
},
},
},
wantErrCount: 2,
wantErrStrings: []string{"scalingModifiers must not be set", "horizontalPodAutoscalerConfig.name must not be set"},
},
{
name: "error: replicas and scaling both set",
workload: &WorkloadSpec{
Replicas: ptr.To(int32(3)),
Scaling: &ScalingSpec{
MaxReplicas: 5,
WVA: &WVASpec{
ActuatorSpec: ActuatorSpec{
HPA: &HPAScalingSpec{},
},
},
},
},
wantErrCount: 1,
wantErrStrings: []string{"scaling and replicas are mutually exclusive"},
},
{
name: "error: minReplicas > maxReplicas",
workload: &WorkloadSpec{
Scaling: &ScalingSpec{
MinReplicas: ptr.To(int32(10)),
MaxReplicas: 5,
WVA: &WVASpec{
ActuatorSpec: ActuatorSpec{
HPA: &HPAScalingSpec{},
},
},
},
},
wantErrCount: 1,
wantErrStrings: []string{"minReplicas (10) cannot exceed maxReplicas (5)"},
},
{
name: "error: scaling without WVA",
workload: &WorkloadSpec{
Scaling: &ScalingSpec{
MaxReplicas: 5,
},
},
wantErrCount: 1,
wantErrStrings: []string{"wva is required when scaling is configured"},
},
{
name: "error: WVA with both HPA and KEDA",
workload: &WorkloadSpec{
Scaling: &ScalingSpec{
MaxReplicas: 5,
WVA: &WVASpec{
ActuatorSpec: ActuatorSpec{
HPA: &HPAScalingSpec{},
KEDA: &KEDAScalingSpec{},
},
},
},
},
wantErrCount: 1,
wantErrStrings: []string{"hpa and keda are mutually exclusive"},
},
{
name: "error: WVA with neither HPA nor KEDA",
workload: &WorkloadSpec{
Scaling: &ScalingSpec{
MaxReplicas: 5,
WVA: &WVASpec{},
},
},
wantErrCount: 1,
wantErrStrings: []string{"either hpa or keda must be specified"},
},
{
name: "error: invalid variantCost - alphabetic",
workload: &WorkloadSpec{
Scaling: &ScalingSpec{
MaxReplicas: 5,
WVA: &WVASpec{
VariantCost: "abc",
ActuatorSpec: ActuatorSpec{
HPA: &HPAScalingSpec{},
},
},
},
},
wantErrCount: 1,
wantErrStrings: []string{"variantCost must be a non-negative numeric string"},
},
{
name: "error: invalid variantCost - negative",
workload: &WorkloadSpec{
Scaling: &ScalingSpec{
MaxReplicas: 5,
WVA: &WVASpec{
VariantCost: "-1",
ActuatorSpec: ActuatorSpec{
HPA: &HPAScalingSpec{},
},
},
},
},
wantErrCount: 1,
wantErrStrings: []string{"variantCost must be a non-negative numeric string"},
},
{
name: "error: invalid variantCost - multiple dots",
workload: &WorkloadSpec{
Scaling: &ScalingSpec{
MaxReplicas: 5,
WVA: &WVASpec{
VariantCost: "10.0.1",
ActuatorSpec: ActuatorSpec{
HPA: &HPAScalingSpec{},
},
},
},
},
wantErrCount: 1,
wantErrStrings: []string{"variantCost must be a non-negative numeric string"},
},
{
name: "error: KEDA idleReplicaCount without minReplicas",
workload: &WorkloadSpec{
Scaling: &ScalingSpec{
MaxReplicas: 10,
WVA: &WVASpec{
ActuatorSpec: ActuatorSpec{
KEDA: &KEDAScalingSpec{
IdleReplicaCount: ptr.To(int32(1)),
},
},
},
},
},
wantErrCount: 1,
wantErrStrings: []string{"minReplicas is required when idleReplicaCount is set"},
},
{
name: "error: KEDA idleReplicaCount >= minReplicas",
workload: &WorkloadSpec{
Scaling: &ScalingSpec{
MinReplicas: ptr.To(int32(2)),
MaxReplicas: 10,
WVA: &WVASpec{
ActuatorSpec: ActuatorSpec{
KEDA: &KEDAScalingSpec{
IdleReplicaCount: ptr.To(int32(3)),
},
},
},
},
},
wantErrCount: 1,
wantErrStrings: []string{"idleReplicaCount (3) must be less than minReplicas (2)"},
},
{
name: "error: KEDA idleReplicaCount == minReplicas",
workload: &WorkloadSpec{
Scaling: &ScalingSpec{
MinReplicas: ptr.To(int32(2)),
MaxReplicas: 10,
WVA: &WVASpec{
ActuatorSpec: ActuatorSpec{
KEDA: &KEDAScalingSpec{
IdleReplicaCount: ptr.To(int32(2)),
},
},
},
},
},
wantErrCount: 1,
wantErrStrings: []string{"idleReplicaCount (2) must be less than minReplicas (2)"},
},
{
name: "valid: scaling and worker both set with HPA (multi-node autoscaling)",
workload: &WorkloadSpec{
Worker: &corev1.PodSpec{},
Scaling: &ScalingSpec{
MaxReplicas: 5,
WVA: &WVASpec{
ActuatorSpec: ActuatorSpec{
HPA: &HPAScalingSpec{},
},
},
},
},
wantErrCount: 0,
},
{
name: "valid: scaling and worker both set with KEDA (multi-node autoscaling)",
workload: &WorkloadSpec{
Worker: &corev1.PodSpec{},
Scaling: &ScalingSpec{
MaxReplicas: 3,
WVA: &WVASpec{
ActuatorSpec: ActuatorSpec{
KEDA: &KEDAScalingSpec{},
},
},
},
},
wantErrCount: 0,
},
{
name: "valid: worker set with replicas (no scaling) - multi-node with static replicas",
workload: &WorkloadSpec{
Worker: &corev1.PodSpec{},
Replicas: ptr.To(int32(3)),
},
wantErrCount: 0,
},
{
name: "valid: worker set with no replicas and no scaling - multi-node defaults",
workload: &WorkloadSpec{Worker: &corev1.PodSpec{}},
wantErrCount: 0,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
errs := ValidateWorkloadScaling(field.NewPath("spec"), tt.workload)
assert.Len(t, errs, tt.wantErrCount, "expected %d errors, got %d: %v", tt.wantErrCount, len(errs), errs)
for _, wantStr := range tt.wantErrStrings {
found := false
for _, e := range errs {
if strings.Contains(e.Error(), wantStr) {
found = true
break
}
}
assert.True(t, found, "expected error containing %q, got: %v", wantStr, errs)
}
})
}
}
func TestValidateScaling_PrefillWorkload(t *testing.T) {
validator := &LLMInferenceServiceValidator{}
t.Run("error on prefill scaling uses correct field path", func(t *testing.T) {
svc := newBaseLLMInferenceServiceV1Alpha2()
svc.Spec.Prefill = &WorkloadSpec{
Replicas: ptr.To(int32(3)),
Scaling: &ScalingSpec{
MaxReplicas: 5,
WVA: &WVASpec{
ActuatorSpec: ActuatorSpec{
HPA: &HPAScalingSpec{},
},
},
},
}
errs := validator.validateScaling(svc)
require.Len(t, errs, 1)
assert.Contains(t, errs[0].Field, "spec.prefill.scaling",
"error should reference spec.prefill.scaling path")
assert.Contains(t, errs[0].Detail, "scaling and replicas are mutually exclusive")
})
t.Run("both decode and prefill with matching HPA backends", func(t *testing.T) {
svc := newBaseLLMInferenceServiceV1Alpha2()
svc.Spec.WorkloadSpec = WorkloadSpec{
Scaling: &ScalingSpec{
MinReplicas: ptr.To(int32(1)),
MaxReplicas: 5,
WVA: &WVASpec{
ActuatorSpec: ActuatorSpec{
HPA: &HPAScalingSpec{},
},
},
},
}
svc.Spec.Prefill = &WorkloadSpec{
Scaling: &ScalingSpec{
MinReplicas: ptr.To(int32(2)),
MaxReplicas: 8,
WVA: &WVASpec{
ActuatorSpec: ActuatorSpec{
HPA: &HPAScalingSpec{},
},
},
},
}
errs := validator.validateScaling(svc)
assert.Empty(t, errs, "expected no errors when both workloads use HPA")
})
t.Run("both decode and prefill with matching KEDA backends", func(t *testing.T) {
svc := newBaseLLMInferenceServiceV1Alpha2()
svc.Spec.WorkloadSpec = WorkloadSpec{
Scaling: &ScalingSpec{
MinReplicas: ptr.To(int32(1)),
MaxReplicas: 5,
WVA: &WVASpec{
ActuatorSpec: ActuatorSpec{
KEDA: &KEDAScalingSpec{},
},
},
},
}
svc.Spec.Prefill = &WorkloadSpec{
Scaling: &ScalingSpec{
MinReplicas: ptr.To(int32(2)),
MaxReplicas: 8,
WVA: &WVASpec{
ActuatorSpec: ActuatorSpec{
KEDA: &KEDAScalingSpec{
IdleReplicaCount: ptr.To(int32(1)),
},
},
},
},
}
errs := validator.validateScaling(svc)
assert.Empty(t, errs, "expected no errors when both workloads use KEDA")
})
t.Run("scalingModifiers set - rejected", func(t *testing.T) {
svc := newBaseLLMInferenceServiceV1Alpha2()
svc.Spec.WorkloadSpec = WorkloadSpec{
Scaling: &ScalingSpec{
MaxReplicas: 5,
WVA: &WVASpec{
ActuatorSpec: ActuatorSpec{
KEDA: &KEDAScalingSpec{
Advanced: &kedav1alpha1.AdvancedConfig{
ScalingModifiers: kedav1alpha1.ScalingModifiers{
Formula: "wva_desired_replicas",
},
},
},
},
},
},
}
errs := validator.validateScaling(svc)
require.Len(t, errs, 1)
assert.Equal(t, field.ErrorTypeForbidden, errs[0].Type)
assert.Contains(t, errs[0].Field, "scalingModifiers")
})
t.Run("horizontalPodAutoscalerConfig name set - rejected", func(t *testing.T) {
svc := newBaseLLMInferenceServiceV1Alpha2()
svc.Spec.WorkloadSpec = WorkloadSpec{
Scaling: &ScalingSpec{
MaxReplicas: 5,
WVA: &WVASpec{
ActuatorSpec: ActuatorSpec{
KEDA: &KEDAScalingSpec{
Advanced: &kedav1alpha1.AdvancedConfig{
HorizontalPodAutoscalerConfig: &kedav1alpha1.HorizontalPodAutoscalerConfig{
Name: "my-hpa",
},
},
},
},
},
},
}
errs := validator.validateScaling(svc)
require.Len(t, errs, 1)
assert.Equal(t, field.ErrorTypeForbidden, errs[0].Type)
assert.Contains(t, errs[0].Field, "horizontalPodAutoscalerConfig")
})
t.Run("keda advanced with only behavior - allowed", func(t *testing.T) {
svc := newBaseLLMInferenceServiceV1Alpha2()
svc.Spec.WorkloadSpec = WorkloadSpec{
Scaling: &ScalingSpec{
MaxReplicas: 5,
WVA: &WVASpec{
ActuatorSpec: ActuatorSpec{
KEDA: &KEDAScalingSpec{
Advanced: &kedav1alpha1.AdvancedConfig{
HorizontalPodAutoscalerConfig: &kedav1alpha1.HorizontalPodAutoscalerConfig{
Behavior: &autoscalingv2.HorizontalPodAutoscalerBehavior{},
},
},
},
},
},
},
}
errs := validator.validateScaling(svc)
assert.Empty(t, errs, "expected no errors when only behavior is set in advanced")
})
t.Run("errors on both decode and prefill are reported", func(t *testing.T) {
svc := newBaseLLMInferenceServiceV1Alpha2()
// Decode: missing WVA
svc.Spec.WorkloadSpec = WorkloadSpec{
Scaling: &ScalingSpec{
MaxReplicas: 5,
},
}
// Prefill: minReplicas > maxReplicas
svc.Spec.Prefill = &WorkloadSpec{
Scaling: &ScalingSpec{
MinReplicas: ptr.To(int32(10)),
MaxReplicas: 5,
WVA: &WVASpec{
ActuatorSpec: ActuatorSpec{
HPA: &HPAScalingSpec{},
},
},
},
}
errs := validator.validateScaling(svc)
require.Len(t, errs, 2, "expected errors from both decode and prefill workloads")
foundDecodeErr := false
foundPrefillErr := false
for _, e := range errs {
if strings.Contains(e.Field, "spec.scaling") && !strings.Contains(e.Field, "prefill") {
foundDecodeErr = true
}
if strings.Contains(e.Field, "spec.prefill.scaling") {
foundPrefillErr = true
}
}
assert.True(t, foundDecodeErr, "expected error on spec.scaling path for decode workload")
assert.True(t, foundPrefillErr, "expected error on spec.prefill.scaling path for prefill workload")
})
}
func TestValidateActuatorConsistency(t *testing.T) {
validator := &LLMInferenceServiceValidator{}
t.Run("valid: decode HPA, no prefill scaling", func(t *testing.T) {
svc := newBaseLLMInferenceServiceV1Alpha2()
svc.Spec.WorkloadSpec = WorkloadSpec{
Scaling: &ScalingSpec{
MaxReplicas: 5,
WVA: &WVASpec{ActuatorSpec: ActuatorSpec{HPA: &HPAScalingSpec{}}},
},
}
svc.Spec.Prefill = &WorkloadSpec{Replicas: ptr.To(int32(2))}
errs := validator.validateScaling(svc)
assert.Empty(t, errs)
})
t.Run("valid: no prefill at all", func(t *testing.T) {
svc := newBaseLLMInferenceServiceV1Alpha2()
svc.Spec.WorkloadSpec = WorkloadSpec{
Scaling: &ScalingSpec{
MaxReplicas: 5,
WVA: &WVASpec{ActuatorSpec: ActuatorSpec{HPA: &HPAScalingSpec{}}},
},
}
errs := validator.validateScaling(svc)
assert.Empty(t, errs)
})
t.Run("valid: decode scaling only, prefill has no scaling", func(t *testing.T) {
svc := newBaseLLMInferenceServiceV1Alpha2()
svc.Spec.WorkloadSpec = WorkloadSpec{
Scaling: &ScalingSpec{
MaxReplicas: 5,
WVA: &WVASpec{ActuatorSpec: ActuatorSpec{KEDA: &KEDAScalingSpec{}}},
},
}
svc.Spec.Prefill = &WorkloadSpec{Replicas: ptr.To(int32(3))}
errs := validator.validateScaling(svc)
assert.Empty(t, errs)
})
t.Run("valid: prefill scaling only, decode has no scaling", func(t *testing.T) {
svc := newBaseLLMInferenceServiceV1Alpha2()
svc.Spec.WorkloadSpec = WorkloadSpec{Replicas: ptr.To(int32(2))}
svc.Spec.Prefill = &WorkloadSpec{
Scaling: &ScalingSpec{
MaxReplicas: 5,
WVA: &WVASpec{ActuatorSpec: ActuatorSpec{HPA: &HPAScalingSpec{}}},
},
}
errs := validator.validateScaling(svc)
assert.Empty(t, errs)
})
t.Run("error: decode HPA, prefill KEDA", func(t *testing.T) {
svc := newBaseLLMInferenceServiceV1Alpha2()
svc.Spec.WorkloadSpec = WorkloadSpec{
Scaling: &ScalingSpec{
MaxReplicas: 5,
WVA: &WVASpec{ActuatorSpec: ActuatorSpec{HPA: &HPAScalingSpec{}}},
},
}
svc.Spec.Prefill = &WorkloadSpec{
Scaling: &ScalingSpec{
MaxReplicas: 5,
WVA: &WVASpec{ActuatorSpec: ActuatorSpec{KEDA: &KEDAScalingSpec{}}},
},
}
errs := validator.validateScaling(svc)
require.Len(t, errs, 1)
assert.Contains(t, errs[0].Field, "spec.prefill.scaling.wva")
assert.Contains(t, errs[0].Detail, "decode uses hpa but prefill uses keda")
})
t.Run("error: decode KEDA, prefill HPA", func(t *testing.T) {
svc := newBaseLLMInferenceServiceV1Alpha2()
svc.Spec.WorkloadSpec = WorkloadSpec{
Scaling: &ScalingSpec{
MaxReplicas: 5,
WVA: &WVASpec{ActuatorSpec: ActuatorSpec{KEDA: &KEDAScalingSpec{}}},
},
}
svc.Spec.Prefill = &WorkloadSpec{
Scaling: &ScalingSpec{
MaxReplicas: 5,
WVA: &WVASpec{ActuatorSpec: ActuatorSpec{HPA: &HPAScalingSpec{}}},
},
}
errs := validator.validateScaling(svc)
require.Len(t, errs, 1)
assert.Contains(t, errs[0].Field, "spec.prefill.scaling.wva")
assert.Contains(t, errs[0].Detail, "decode uses keda but prefill uses hpa")
})
t.Run("valid: scaling+worker on decode workload (multi-node autoscaling)", func(t *testing.T) {
svc := newBaseLLMInferenceServiceV1Alpha2()
svc.Spec.WorkloadSpec = WorkloadSpec{
Worker: &corev1.PodSpec{},
Scaling: &ScalingSpec{
MaxReplicas: 5,
WVA: &WVASpec{ActuatorSpec: ActuatorSpec{HPA: &HPAScalingSpec{}}},
},
}
errs := validator.validateScaling(svc)
require.Empty(t, errs)
})
t.Run("valid: scaling+worker on prefill workload (multi-node autoscaling)", func(t *testing.T) {
svc := newBaseLLMInferenceServiceV1Alpha2()
svc.Spec.WorkloadSpec = WorkloadSpec{Replicas: ptr.To(int32(2))}
svc.Spec.Prefill = &WorkloadSpec{
Worker: &corev1.PodSpec{},
Scaling: &ScalingSpec{
MaxReplicas: 5,
WVA: &WVASpec{ActuatorSpec: ActuatorSpec{KEDA: &KEDAScalingSpec{}}},
},
}
errs := validator.validateScaling(svc)
require.Empty(t, errs)
})
}
func TestValidateLoRAAdapters(t *testing.T) {
validator := &LLMInferenceServiceValidator{}
makeAdapter := func(name, uri string) LLMModelSpec {
return LLMModelSpec{URI: apis.URL{Scheme: "hf", Host: uri}, Name: ptr.To(name)}
}
makeSvc := func(modelName string, loraSpec *LoRASpec) *LLMInferenceService {
return &LLMInferenceService{
ObjectMeta: metav1.ObjectMeta{Name: modelName, Namespace: "default"},
Spec: LLMInferenceServiceSpec{
Model: LLMModelSpec{
URI: apis.URL{Scheme: "hf", Host: "base-model"},
Name: ptr.To(modelName),
LoRA: loraSpec,
},
},
}
}
t.Run("no lora", func(t *testing.T) {
errs := validator.validateLoRAAdapters(makeSvc("base", nil))
assert.Empty(t, errs)
})
t.Run("valid single adapter", func(t *testing.T) {
errs := validator.validateLoRAAdapters(makeSvc("base", &LoRASpec{
Adapters: []LLMModelSpec{makeAdapter("adapter-1", "adapter-1")},
}))
assert.Empty(t, errs)
})
t.Run("adapter name missing", func(t *testing.T) {
svc := makeSvc("base", &LoRASpec{
Adapters: []LLMModelSpec{{URI: apis.URL{Scheme: "hf", Host: "adapter-1"}}},
})
errs := validator.validateLoRAAdapters(svc)
require.Len(t, errs, 1)
assert.Contains(t, errs[0].Field, "spec.model.lora.adapters[0].name")
assert.Equal(t, field.ErrorTypeRequired, errs[0].Type)
})
t.Run("adapter name is dot (path traversal)", func(t *testing.T) {
errs := validator.validateLoRAAdapters(makeSvc("base", &LoRASpec{
Adapters: []LLMModelSpec{makeAdapter(".", "adapter-dot")},
}))
require.Len(t, errs, 1)
assert.Contains(t, errs[0].Field, "spec.model.lora.adapters[0].name")
assert.Contains(t, errs[0].Detail, "path traversal")
})
t.Run("adapter name is dotdot (path traversal)", func(t *testing.T) {
errs := validator.validateLoRAAdapters(makeSvc("base", &LoRASpec{
Adapters: []LLMModelSpec{makeAdapter("..", "adapter-dotdot")},
}))
require.Len(t, errs, 1)
assert.Contains(t, errs[0].Field, "spec.model.lora.adapters[0].name")
assert.Contains(t, errs[0].Detail, "path traversal")
})
t.Run("duplicate adapter names", func(t *testing.T) {
errs := validator.validateLoRAAdapters(makeSvc("base", &LoRASpec{
Adapters: []LLMModelSpec{
makeAdapter("dup", "adapter-1"),
makeAdapter("dup", "adapter-2"),
},
}))
require.Len(t, errs, 1)
assert.Contains(t, errs[0].Field, "spec.model.lora.adapters[1].name")
assert.Contains(t, errs[0].Detail, "duplicate")
})
t.Run("adapter name same as base model name", func(t *testing.T) {
errs := validator.validateLoRAAdapters(makeSvc("base-model", &LoRASpec{
Adapters: []LLMModelSpec{makeAdapter("base-model", "adapter-1")},
}))
require.Len(t, errs, 1)
assert.Contains(t, errs[0].Field, "spec.model.lora.adapters[0].name")
assert.Contains(t, errs[0].Detail, "adapter name must differ from base model name")
})
t.Run("maxRank zero is invalid", func(t *testing.T) {
errs := validator.validateLoRAAdapters(makeSvc("base", &LoRASpec{
MaxRank: ptr.To(int32(0)),
Adapters: []LLMModelSpec{makeAdapter("adapter-1", "adapter-1")},
}))
require.Len(t, errs, 1)
assert.Contains(t, errs[0].Field, "spec.model.lora.maxRank")
})
t.Run("maxAdapters zero is invalid", func(t *testing.T) {
errs := validator.validateLoRAAdapters(makeSvc("base", &LoRASpec{
MaxAdapters: ptr.To(int32(0)),
Adapters: []LLMModelSpec{makeAdapter("adapter-1", "adapter-1")},
}))
require.Len(t, errs, 1)
assert.Contains(t, errs[0].Field, "spec.model.lora.maxAdapters")
})
t.Run("maxCpuAdapters zero is invalid", func(t *testing.T) {
errs := validator.validateLoRAAdapters(makeSvc("base", &LoRASpec{
MaxCpuAdapters: ptr.To(int32(0)),
Adapters: []LLMModelSpec{makeAdapter("adapter-1", "adapter-1")},
}))
require.Len(t, errs, 1)
assert.Contains(t, errs[0].Field, "spec.model.lora.maxCpuAdapters")
})
t.Run("all lora params valid", func(t *testing.T) {
errs := validator.validateLoRAAdapters(makeSvc("base", &LoRASpec{
MaxRank: ptr.To(int32(128)),
MaxAdapters: ptr.To(int32(4)),
MaxCpuAdapters: ptr.To(int32(8)),
Adapters: []LLMModelSpec{
makeAdapter("adapter-1", "adapter-1"),
makeAdapter("adapter-2", "adapter-2"),
},
}))
assert.Empty(t, errs)
})
}
func TestValidateManagedDRAAnnotations(t *testing.T) {
validator := &LLMInferenceServiceValidator{}
const (
deviceClassKey = "serving.kserve.io/exp-dra-device-class"
deviceCountKey = "serving.kserve.io/exp-dra-device-count"
celSelectorKey = "serving.kserve.io/exp-dra-cel-selector"
containerNameKey = "serving.kserve.io/exp-dra-container-name"
)
tests := []struct {
name string
annotations map[string]string
wantErrCount int
wantErrField string
}{
{
name: "no DRA annotations",
annotations: nil,
wantErrCount: 0,
},
{
name: "valid: device class only",
annotations: map[string]string{
deviceClassKey: "gpu.nvidia.com",
},
wantErrCount: 0,
},
{
name: "valid: device class + device count + cel selectors",
annotations: map[string]string{
deviceClassKey: "gpu.nvidia.com",
deviceCountKey: "4",
celSelectorKey: "device.attributes['gpu.nvidia.com']['type'] == 'A100'\n" +
"device.capacity['gpu.nvidia.com']['memory'].compareTo(quantity('40Gi')) > 0",
},
wantErrCount: 0,
},
{
name: "valid: device class with dotted name",
annotations: map[string]string{
deviceClassKey: "mig-3g.40gb",
},
wantErrCount: 0,
},
{
name: "invalid: empty device class",
annotations: map[string]string{
deviceClassKey: " ",
},
wantErrCount: 1,
wantErrField: deviceClassKey,
},
{
name: "invalid: device class with uppercase",
annotations: map[string]string{
deviceClassKey: "GPU.Nvidia.com",
},
wantErrCount: 1,
wantErrField: deviceClassKey,
},
{
name: "invalid: device count without device class",
annotations: map[string]string{
deviceCountKey: "2",
},
wantErrCount: 1,
wantErrField: deviceClassKey,
},
{
name: "invalid: cel selector without device class",
annotations: map[string]string{
celSelectorKey: "device.attributes['gpu.nvidia.com']['type'] == 'A100'",
},
wantErrCount: 1,
wantErrField: deviceClassKey,
},
{
name: "invalid: device count is non-numeric (the foot-gun the webhook is meant to catch)",
annotations: map[string]string{
deviceClassKey: "gpu.nvidia.com",
deviceCountKey: "abc",
},
wantErrCount: 1,
wantErrField: deviceCountKey,
},
{
name: "invalid: device count is zero",
annotations: map[string]string{
deviceClassKey: "gpu.nvidia.com",
deviceCountKey: "0",
},
wantErrCount: 1,
wantErrField: deviceCountKey,
},
{
name: "invalid: device count is negative",
annotations: map[string]string{
deviceClassKey: "gpu.nvidia.com",
deviceCountKey: "-1",
},
wantErrCount: 1,
wantErrField: deviceCountKey,
},
{
name: "invalid: cel selector annotation set but contains no expressions",
annotations: map[string]string{
deviceClassKey: "gpu.nvidia.com",
celSelectorKey: "\n \n",
},
wantErrCount: 1,
wantErrField: celSelectorKey,
},
{
name: "invalid: multiple errors are surfaced together",
annotations: map[string]string{
deviceClassKey: "BAD CLASS",
deviceCountKey: "abc",
},
wantErrCount: 2,
},
{
name: "invalid: device class with consecutive dots (rejected by IsDNS1123Subdomain)",
annotations: map[string]string{
deviceClassKey: "gpu..nvidia.com",
},
wantErrCount: 1,
wantErrField: deviceClassKey,
},
{
name: "valid: device class + explicit container name",
annotations: map[string]string{
deviceClassKey: "gpu.nvidia.com",
containerNameKey: "vllm",
},
wantErrCount: 0,
},
{
name: "invalid: container name without device class",
annotations: map[string]string{
containerNameKey: "vllm",
},
wantErrCount: 1,
wantErrField: deviceClassKey,
},
{
name: "invalid: empty container name",
annotations: map[string]string{
deviceClassKey: "gpu.nvidia.com",
containerNameKey: " ",
},
wantErrCount: 1,
wantErrField: containerNameKey,
},
{
name: "invalid: container name with uppercase (not a DNS label)",
annotations: map[string]string{
deviceClassKey: "gpu.nvidia.com",
containerNameKey: "VLLM",
},
wantErrCount: 1,
wantErrField: containerNameKey,
},
{
name: "invalid: container name contains dots (DNS label disallows dots)",
annotations: map[string]string{
deviceClassKey: "gpu.nvidia.com",
containerNameKey: "vllm.main",
},
wantErrCount: 1,
wantErrField: containerNameKey,
},
{
name: "valid: hyphenated container name (normal DNS label)",
annotations: map[string]string{
deviceClassKey: "gpu.nvidia.com",
containerNameKey: "kserve-container",
},
wantErrCount: 0,
},
{
name: "valid: container name with surrounding whitespace is trimmed before validation",
annotations: map[string]string{
deviceClassKey: "gpu.nvidia.com",
containerNameKey: " vllm ",
},
wantErrCount: 0,
},
{
name: "invalid: container name with embedded space",
annotations: map[string]string{
deviceClassKey: "gpu.nvidia.com",
containerNameKey: "vllm main",
},
wantErrCount: 1,
wantErrField: containerNameKey,
},
{
name: "invalid: container name with underscore (DNS label disallows underscores)",
annotations: map[string]string{
deviceClassKey: "gpu.nvidia.com",
containerNameKey: "vllm_main",
},
wantErrCount: 1,
wantErrField: containerNameKey,
},
{
name: "invalid: container name with trailing hyphen",
annotations: map[string]string{
deviceClassKey: "gpu.nvidia.com",
containerNameKey: "vllm-",
},
wantErrCount: 1,
wantErrField: containerNameKey,
},
{
name: "invalid: container name longer than 63 characters",
annotations: map[string]string{
deviceClassKey: "gpu.nvidia.com",
containerNameKey: strings.Repeat("a", 64),
},
wantErrCount: 1,
wantErrField: containerNameKey,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
svc := newBaseLLMInferenceServiceV1Alpha2()
svc.Annotations = tt.annotations
errs := validator.validateManagedDRAAnnotations(svc)
require.Len(t, errs, tt.wantErrCount, "errors: %v", errs)
if tt.wantErrField != "" {
found := false
for _, e := range errs {
if strings.Contains(e.Field, tt.wantErrField) {
found = true
break
}
}
assert.True(t, found, "expected error on field %q, got: %v", tt.wantErrField, errs)
}
})
}
}
func TestValidateConfidential(t *testing.T) {
tests := []struct {
name string
confidential *ConfidentialSpec
modelURI apis.URL
wantErrCount int
wantErrStrings []string
wantWarnings []string
}{
{
name: "nil confidential spec",
confidential: nil,
modelURI: apis.URL{Scheme: "hf", Host: "meta-llama/Llama-2-7b"},
wantErrCount: 0,
},
{
name: "confidential disabled",
confidential: &ConfidentialSpec{Enabled: false},
modelURI: apis.URL{Scheme: "hf", Host: "meta-llama/Llama-2-7b"},
wantErrCount: 0,
},
{
name: "confidential enabled with valid resourceId",
confidential: &ConfidentialSpec{Enabled: true, ResourceId: ptr.To("kbs:///default/key/model-key")},
modelURI: apis.URL{Scheme: "hf", Host: "meta-llama/Llama-2-7b"},
wantErrCount: 0,
},
{
name: "confidential enabled without resourceId",
confidential: &ConfidentialSpec{Enabled: true},
modelURI: apis.URL{Scheme: "hf", Host: "meta-llama/Llama-2-7b"},
wantErrCount: 0,
},
{
name: "confidential enabled with OCI URI warns",
confidential: &ConfidentialSpec{Enabled: true},
modelURI: apis.URL{Scheme: "oci", Host: "registry/model:latest"},
wantErrCount: 0,
wantWarnings: []string{"OCI URIs"},
},
{
name: "confidential enabled with PVC URI warns",
confidential: &ConfidentialSpec{Enabled: true},
modelURI: apis.URL{Scheme: "pvc", Host: "my-pvc/model-dir"},
wantErrCount: 0,
wantWarnings: []string{"PVC URIs"},
},
{
name: "confidential with malformed resourceId",
confidential: &ConfidentialSpec{Enabled: true, ResourceId: ptr.To("invalid-id")},
modelURI: apis.URL{Scheme: "hf", Host: "meta-llama/Llama-2-7b"},
wantErrCount: 1,
wantErrStrings: []string{"kbs:///<repo>/<type>/<tag>"},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
validator := &LLMInferenceServiceValidator{}
llmSvc := &LLMInferenceService{
ObjectMeta: metav1.ObjectMeta{
Name: "test-llm-isvc",
Namespace: "default",
},
Spec: LLMInferenceServiceSpec{
Model: LLMModelSpec{
URI: tt.modelURI,
Confidential: tt.confidential,
},
},
}
warnings, errs := validator.validateConfidential(llmSvc)
assert.Len(t, errs, tt.wantErrCount, "expected %d errors, got %d: %v", tt.wantErrCount, len(errs), errs)
for _, wantStr := range tt.wantErrStrings {
found := false
for _, e := range errs {
if strings.Contains(e.Error(), wantStr) {
found = true
break
}
}
assert.True(t, found, "expected error containing %q, got: %v", wantStr, errs)
}
for _, wantWarning := range tt.wantWarnings {
found := false
for _, w := range warnings {
if strings.Contains(w, wantWarning) {
found = true
break
}
}
assert.True(t, found, "expected warning containing %q, got: %v", wantWarning, warnings)
}
})
}
}