mirror of
https://github.com/hanzo-ml/kserve.git
synced 2026-10-30 20:53:49 +00:00
1558 lines
42 KiB
Go
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)
|
|
}
|
|
})
|
|
}
|
|
}
|