mirror of
https://github.com/hanzo-ml/kserve.git
synced 2026-10-30 20:53:49 +00:00
Signed-off-by: Vivek Karunai Kiri Ragavan <[email protected]>
1269 lines
46 KiB
Go
1269 lines
46 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 llmisvc
|
|
|
|
import (
|
|
"context"
|
|
"testing"
|
|
|
|
kedav1alpha1 "github.com/kedacore/keda/v2/apis/keda/v1alpha1"
|
|
wvav1alpha1 "github.com/llm-d/llm-d-workload-variant-autoscaler/api/v1alpha1"
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/require"
|
|
autoscalingv2 "k8s.io/api/autoscaling/v2"
|
|
corev1 "k8s.io/api/core/v1"
|
|
"k8s.io/apimachinery/pkg/api/resource"
|
|
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
|
"k8s.io/apimachinery/pkg/runtime"
|
|
"k8s.io/client-go/tools/record"
|
|
"k8s.io/utils/ptr"
|
|
"knative.dev/pkg/apis"
|
|
"sigs.k8s.io/controller-runtime/pkg/client/fake"
|
|
lwsapi "sigs.k8s.io/lws/api/leaderworkerset/v1"
|
|
|
|
"github.com/kserve/kserve/pkg/apis/serving/v1alpha2"
|
|
)
|
|
|
|
func deploymentScaleTargetRef(name string) autoscalingv2.CrossVersionObjectReference {
|
|
return autoscalingv2.CrossVersionObjectReference{
|
|
APIVersion: "apps/v1",
|
|
Kind: "Deployment",
|
|
Name: name,
|
|
}
|
|
}
|
|
|
|
func lwsScaleTargetRef(name string) autoscalingv2.CrossVersionObjectReference {
|
|
return autoscalingv2.CrossVersionObjectReference{
|
|
APIVersion: lwsapi.GroupVersion.String(),
|
|
Kind: "LeaderWorkerSet",
|
|
Name: name,
|
|
}
|
|
}
|
|
|
|
// newTestLLMISVC creates a minimal LLMInferenceService for testing.
|
|
func newTestLLMISVC(name, namespace string) *v1alpha2.LLMInferenceService {
|
|
return &v1alpha2.LLMInferenceService{
|
|
TypeMeta: metav1.TypeMeta{
|
|
APIVersion: "serving.kserve.io/v1alpha2",
|
|
Kind: "LLMInferenceService",
|
|
},
|
|
ObjectMeta: metav1.ObjectMeta{
|
|
Name: name,
|
|
Namespace: namespace,
|
|
UID: "test-uid-1234",
|
|
},
|
|
Spec: v1alpha2.LLMInferenceServiceSpec{
|
|
Model: v1alpha2.LLMModelSpec{
|
|
URI: apis.URL{Scheme: "hf", Host: "meta-llama/Llama-3.1-8B"},
|
|
Name: ptr.To("meta-llama/Llama-3.1-8B"),
|
|
},
|
|
},
|
|
}
|
|
}
|
|
|
|
func TestExpectedHPA(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
llmSvc *v1alpha2.LLMInferenceService
|
|
scaling *v1alpha2.ScalingSpec
|
|
scaleTargetRef autoscalingv2.CrossVersionObjectReference
|
|
vaName string
|
|
hpaName string
|
|
validate func(t *testing.T, hpa *autoscalingv2.HorizontalPodAutoscaler)
|
|
}{
|
|
{
|
|
name: "default minReplicas when nil",
|
|
llmSvc: newTestLLMISVC("test-svc", "test-ns"),
|
|
scaling: &v1alpha2.ScalingSpec{MaxReplicas: 5, WVA: &v1alpha2.WVASpec{ActuatorSpec: v1alpha2.ActuatorSpec{HPA: &v1alpha2.HPAScalingSpec{}}}},
|
|
scaleTargetRef: deploymentScaleTargetRef("test-svc-kserve"),
|
|
vaName: "test-svc-kserve-va",
|
|
hpaName: "test-svc-kserve-hpa",
|
|
validate: func(t *testing.T, hpa *autoscalingv2.HorizontalPodAutoscaler) {
|
|
require.NotNil(t, hpa.Spec.MinReplicas)
|
|
assert.Equal(t, int32(1), *hpa.Spec.MinReplicas, "default minReplicas should be 1")
|
|
},
|
|
},
|
|
{
|
|
name: "explicit minReplicas",
|
|
llmSvc: newTestLLMISVC("test-svc", "test-ns"),
|
|
scaling: &v1alpha2.ScalingSpec{
|
|
MinReplicas: ptr.To(int32(3)),
|
|
MaxReplicas: 10,
|
|
WVA: &v1alpha2.WVASpec{ActuatorSpec: v1alpha2.ActuatorSpec{HPA: &v1alpha2.HPAScalingSpec{}}},
|
|
},
|
|
scaleTargetRef: deploymentScaleTargetRef("test-svc-kserve"),
|
|
vaName: "test-svc-kserve-va",
|
|
hpaName: "test-svc-kserve-hpa",
|
|
validate: func(t *testing.T, hpa *autoscalingv2.HorizontalPodAutoscaler) {
|
|
require.NotNil(t, hpa.Spec.MinReplicas)
|
|
assert.Equal(t, int32(3), *hpa.Spec.MinReplicas)
|
|
assert.Equal(t, int32(10), hpa.Spec.MaxReplicas)
|
|
},
|
|
},
|
|
{
|
|
name: "scaleTargetRef points to correct deployment",
|
|
llmSvc: newTestLLMISVC("my-model", "prod"),
|
|
scaling: &v1alpha2.ScalingSpec{MaxReplicas: 5, WVA: &v1alpha2.WVASpec{ActuatorSpec: v1alpha2.ActuatorSpec{HPA: &v1alpha2.HPAScalingSpec{}}}},
|
|
scaleTargetRef: deploymentScaleTargetRef("my-model-kserve"),
|
|
vaName: "my-model-kserve-va",
|
|
hpaName: "my-model-kserve-hpa",
|
|
validate: func(t *testing.T, hpa *autoscalingv2.HorizontalPodAutoscaler) {
|
|
assert.Equal(t, "apps/v1", hpa.Spec.ScaleTargetRef.APIVersion)
|
|
assert.Equal(t, "Deployment", hpa.Spec.ScaleTargetRef.Kind)
|
|
assert.Equal(t, "my-model-kserve", hpa.Spec.ScaleTargetRef.Name)
|
|
},
|
|
},
|
|
{
|
|
name: "scaleTargetRef points to LeaderWorkerSet for multi-node",
|
|
llmSvc: newTestLLMISVC("my-model", "prod"),
|
|
scaling: &v1alpha2.ScalingSpec{MaxReplicas: 5, WVA: &v1alpha2.WVASpec{ActuatorSpec: v1alpha2.ActuatorSpec{HPA: &v1alpha2.HPAScalingSpec{}}}},
|
|
scaleTargetRef: lwsScaleTargetRef("my-model-kserve-mn"),
|
|
vaName: "my-model-kserve-va",
|
|
hpaName: "my-model-kserve-hpa",
|
|
validate: func(t *testing.T, hpa *autoscalingv2.HorizontalPodAutoscaler) {
|
|
assert.Equal(t, lwsapi.GroupVersion.String(), hpa.Spec.ScaleTargetRef.APIVersion)
|
|
assert.Equal(t, "LeaderWorkerSet", hpa.Spec.ScaleTargetRef.Kind)
|
|
assert.Equal(t, "my-model-kserve-mn", hpa.Spec.ScaleTargetRef.Name)
|
|
},
|
|
},
|
|
{
|
|
name: "metric selector uses variant_name from VA",
|
|
llmSvc: newTestLLMISVC("test-svc", "test-ns"),
|
|
scaling: &v1alpha2.ScalingSpec{MaxReplicas: 5, WVA: &v1alpha2.WVASpec{ActuatorSpec: v1alpha2.ActuatorSpec{HPA: &v1alpha2.HPAScalingSpec{}}}},
|
|
scaleTargetRef: deploymentScaleTargetRef("test-svc-kserve"),
|
|
vaName: "test-svc-kserve-va",
|
|
hpaName: "test-svc-kserve-hpa",
|
|
validate: func(t *testing.T, hpa *autoscalingv2.HorizontalPodAutoscaler) {
|
|
require.Len(t, hpa.Spec.Metrics, 1)
|
|
metric := hpa.Spec.Metrics[0]
|
|
assert.Equal(t, autoscalingv2.ExternalMetricSourceType, metric.Type)
|
|
require.NotNil(t, metric.External)
|
|
assert.Equal(t, "wva_desired_replicas", metric.External.Metric.Name)
|
|
require.NotNil(t, metric.External.Metric.Selector)
|
|
assert.Equal(t, "test-svc-kserve-va", metric.External.Metric.Selector.MatchLabels["variant_name"])
|
|
assert.Equal(t, autoscalingv2.ValueMetricType, metric.External.Target.Type)
|
|
assert.Equal(t, resource.NewQuantity(1, resource.DecimalSI), metric.External.Target.Value)
|
|
},
|
|
},
|
|
{
|
|
name: "custom HPA behavior is forwarded",
|
|
llmSvc: newTestLLMISVC("test-svc", "test-ns"),
|
|
scaling: &v1alpha2.ScalingSpec{
|
|
MaxReplicas: 5,
|
|
WVA: &v1alpha2.WVASpec{
|
|
ActuatorSpec: v1alpha2.ActuatorSpec{
|
|
HPA: &v1alpha2.HPAScalingSpec{
|
|
Behavior: &autoscalingv2.HorizontalPodAutoscalerBehavior{
|
|
ScaleDown: &autoscalingv2.HPAScalingRules{
|
|
StabilizationWindowSeconds: ptr.To(int32(120)),
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
scaleTargetRef: deploymentScaleTargetRef("test-svc-kserve"),
|
|
vaName: "test-svc-kserve-va",
|
|
hpaName: "test-svc-kserve-hpa",
|
|
validate: func(t *testing.T, hpa *autoscalingv2.HorizontalPodAutoscaler) {
|
|
require.NotNil(t, hpa.Spec.Behavior)
|
|
require.NotNil(t, hpa.Spec.Behavior.ScaleDown)
|
|
assert.Equal(t, int32(120), *hpa.Spec.Behavior.ScaleDown.StabilizationWindowSeconds)
|
|
},
|
|
},
|
|
{
|
|
name: "nil behavior when not specified",
|
|
llmSvc: newTestLLMISVC("test-svc", "test-ns"),
|
|
scaling: &v1alpha2.ScalingSpec{MaxReplicas: 5, WVA: &v1alpha2.WVASpec{ActuatorSpec: v1alpha2.ActuatorSpec{HPA: &v1alpha2.HPAScalingSpec{}}}},
|
|
scaleTargetRef: deploymentScaleTargetRef("test-svc-kserve"),
|
|
vaName: "test-svc-kserve-va",
|
|
hpaName: "test-svc-kserve-hpa",
|
|
validate: func(t *testing.T, hpa *autoscalingv2.HorizontalPodAutoscaler) {
|
|
assert.Nil(t, hpa.Spec.Behavior)
|
|
},
|
|
},
|
|
{
|
|
name: "owner reference is set",
|
|
llmSvc: newTestLLMISVC("test-svc", "test-ns"),
|
|
scaling: &v1alpha2.ScalingSpec{MaxReplicas: 5, WVA: &v1alpha2.WVASpec{ActuatorSpec: v1alpha2.ActuatorSpec{HPA: &v1alpha2.HPAScalingSpec{}}}},
|
|
scaleTargetRef: deploymentScaleTargetRef("test-svc-kserve"),
|
|
vaName: "test-svc-kserve-va",
|
|
hpaName: "test-svc-kserve-hpa",
|
|
validate: func(t *testing.T, hpa *autoscalingv2.HorizontalPodAutoscaler) {
|
|
require.Len(t, hpa.OwnerReferences, 1)
|
|
assert.Equal(t, "test-svc", hpa.OwnerReferences[0].Name)
|
|
assert.Equal(t, "LLMInferenceService", hpa.OwnerReferences[0].Kind)
|
|
assert.True(t, *hpa.OwnerReferences[0].Controller)
|
|
},
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
hpa := expectedHPA(tt.llmSvc, tt.scaling, tt.scaleTargetRef, tt.vaName, tt.hpaName)
|
|
assert.Equal(t, tt.hpaName, hpa.Name)
|
|
assert.Equal(t, tt.llmSvc.Namespace, hpa.Namespace)
|
|
tt.validate(t, hpa)
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestExpectedScaledObject(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
llmSvc *v1alpha2.LLMInferenceService
|
|
scaling *v1alpha2.ScalingSpec
|
|
config *Config
|
|
scaleTargetRef autoscalingv2.CrossVersionObjectReference
|
|
vaName string
|
|
soName string
|
|
validate func(t *testing.T, so *kedav1alpha1.ScaledObject)
|
|
}{
|
|
{
|
|
name: "default minReplicas when nil",
|
|
llmSvc: newTestLLMISVC("test-svc", "test-ns"),
|
|
scaling: &v1alpha2.ScalingSpec{
|
|
MaxReplicas: 5,
|
|
WVA: &v1alpha2.WVASpec{ActuatorSpec: v1alpha2.ActuatorSpec{KEDA: &v1alpha2.KEDAScalingSpec{}}},
|
|
},
|
|
config: &Config{WVAAutoscalingConfig: &WVAAutoscalingConfig{Prometheus: PrometheusConfig{URL: "http://prom:9090"}}},
|
|
scaleTargetRef: deploymentScaleTargetRef("test-svc-kserve"),
|
|
vaName: "test-svc-kserve-va",
|
|
soName: "test-svc-kserve-keda",
|
|
validate: func(t *testing.T, so *kedav1alpha1.ScaledObject) {
|
|
require.NotNil(t, so.Spec.MinReplicaCount)
|
|
assert.Equal(t, int32(1), *so.Spec.MinReplicaCount)
|
|
},
|
|
},
|
|
{
|
|
name: "explicit minReplicas and maxReplicas",
|
|
llmSvc: newTestLLMISVC("test-svc", "test-ns"),
|
|
scaling: &v1alpha2.ScalingSpec{
|
|
MinReplicas: ptr.To(int32(2)),
|
|
MaxReplicas: 8,
|
|
WVA: &v1alpha2.WVASpec{ActuatorSpec: v1alpha2.ActuatorSpec{KEDA: &v1alpha2.KEDAScalingSpec{}}},
|
|
},
|
|
config: &Config{WVAAutoscalingConfig: &WVAAutoscalingConfig{Prometheus: PrometheusConfig{URL: "http://prom:9090"}}},
|
|
scaleTargetRef: deploymentScaleTargetRef("test-svc-kserve"),
|
|
vaName: "test-svc-kserve-va",
|
|
soName: "test-svc-kserve-keda",
|
|
validate: func(t *testing.T, so *kedav1alpha1.ScaledObject) {
|
|
assert.Equal(t, int32(2), *so.Spec.MinReplicaCount)
|
|
require.NotNil(t, so.Spec.MaxReplicaCount)
|
|
assert.Equal(t, int32(8), *so.Spec.MaxReplicaCount)
|
|
},
|
|
},
|
|
{
|
|
name: "scaleTargetRef points to correct deployment",
|
|
llmSvc: newTestLLMISVC("my-model", "prod"),
|
|
scaling: &v1alpha2.ScalingSpec{
|
|
MaxReplicas: 5,
|
|
WVA: &v1alpha2.WVASpec{ActuatorSpec: v1alpha2.ActuatorSpec{KEDA: &v1alpha2.KEDAScalingSpec{}}},
|
|
},
|
|
config: &Config{WVAAutoscalingConfig: &WVAAutoscalingConfig{Prometheus: PrometheusConfig{URL: "http://prom:9090"}}},
|
|
scaleTargetRef: deploymentScaleTargetRef("my-model-kserve-prefill"),
|
|
vaName: "my-model-kserve-prefill-va",
|
|
soName: "my-model-kserve-prefill-keda",
|
|
validate: func(t *testing.T, so *kedav1alpha1.ScaledObject) {
|
|
require.NotNil(t, so.Spec.ScaleTargetRef)
|
|
assert.Equal(t, "apps/v1", so.Spec.ScaleTargetRef.APIVersion)
|
|
assert.Equal(t, "Deployment", so.Spec.ScaleTargetRef.Kind)
|
|
assert.Equal(t, "my-model-kserve-prefill", so.Spec.ScaleTargetRef.Name)
|
|
},
|
|
},
|
|
{
|
|
name: "scaleTargetRef points to LeaderWorkerSet for multi-node",
|
|
llmSvc: newTestLLMISVC("my-model", "prod"),
|
|
scaling: &v1alpha2.ScalingSpec{
|
|
MaxReplicas: 5,
|
|
WVA: &v1alpha2.WVASpec{ActuatorSpec: v1alpha2.ActuatorSpec{KEDA: &v1alpha2.KEDAScalingSpec{}}},
|
|
},
|
|
config: &Config{WVAAutoscalingConfig: &WVAAutoscalingConfig{Prometheus: PrometheusConfig{URL: "http://prom:9090"}}},
|
|
scaleTargetRef: lwsScaleTargetRef("my-model-kserve-mn"),
|
|
vaName: "my-model-kserve-va",
|
|
soName: "my-model-kserve-keda",
|
|
validate: func(t *testing.T, so *kedav1alpha1.ScaledObject) {
|
|
require.NotNil(t, so.Spec.ScaleTargetRef)
|
|
assert.Equal(t, lwsapi.GroupVersion.String(), so.Spec.ScaleTargetRef.APIVersion)
|
|
assert.Equal(t, "LeaderWorkerSet", so.Spec.ScaleTargetRef.Kind)
|
|
assert.Equal(t, "my-model-kserve-mn", so.Spec.ScaleTargetRef.Name)
|
|
},
|
|
},
|
|
{
|
|
name: "prometheus trigger has correct metadata",
|
|
llmSvc: newTestLLMISVC("test-svc", "test-ns"),
|
|
scaling: &v1alpha2.ScalingSpec{
|
|
MaxReplicas: 5,
|
|
WVA: &v1alpha2.WVASpec{ActuatorSpec: v1alpha2.ActuatorSpec{KEDA: &v1alpha2.KEDAScalingSpec{}}},
|
|
},
|
|
config: &Config{WVAAutoscalingConfig: &WVAAutoscalingConfig{Prometheus: PrometheusConfig{URL: "https://prom.monitoring:9090", TLSInsecureSkipVerify: true}}},
|
|
scaleTargetRef: deploymentScaleTargetRef("test-svc-kserve"),
|
|
vaName: "test-svc-kserve-va",
|
|
soName: "test-svc-kserve-keda",
|
|
validate: func(t *testing.T, so *kedav1alpha1.ScaledObject) {
|
|
require.Len(t, so.Spec.Triggers, 1)
|
|
trigger := so.Spec.Triggers[0]
|
|
assert.Equal(t, "prometheus", trigger.Type)
|
|
assert.Equal(t, "wva-desired-replicas", trigger.Name)
|
|
assert.Equal(t, "https://prom.monitoring:9090", trigger.Metadata["serverAddress"])
|
|
assert.Equal(t, `wva_desired_replicas{variant_name="test-svc-kserve-va",exported_namespace="test-ns"}`, trigger.Metadata["query"])
|
|
assert.Equal(t, "1", trigger.Metadata["threshold"])
|
|
assert.Equal(t, "true", trigger.Metadata["unsafeSsl"])
|
|
},
|
|
},
|
|
{
|
|
name: "unsafeSsl is false when TLS verification enabled",
|
|
llmSvc: newTestLLMISVC("test-svc", "test-ns"),
|
|
scaling: &v1alpha2.ScalingSpec{
|
|
MaxReplicas: 5,
|
|
WVA: &v1alpha2.WVASpec{ActuatorSpec: v1alpha2.ActuatorSpec{KEDA: &v1alpha2.KEDAScalingSpec{}}},
|
|
},
|
|
config: &Config{WVAAutoscalingConfig: &WVAAutoscalingConfig{Prometheus: PrometheusConfig{URL: "http://prom:9090", TLSInsecureSkipVerify: false}}},
|
|
scaleTargetRef: deploymentScaleTargetRef("test-svc-kserve"),
|
|
vaName: "test-svc-kserve-va",
|
|
soName: "test-svc-kserve-keda",
|
|
validate: func(t *testing.T, so *kedav1alpha1.ScaledObject) {
|
|
assert.Equal(t, "false", so.Spec.Triggers[0].Metadata["unsafeSsl"])
|
|
},
|
|
},
|
|
{
|
|
name: "optional KEDA fields forwarded",
|
|
llmSvc: newTestLLMISVC("test-svc", "test-ns"),
|
|
scaling: &v1alpha2.ScalingSpec{
|
|
MaxReplicas: 5,
|
|
WVA: &v1alpha2.WVASpec{ActuatorSpec: v1alpha2.ActuatorSpec{KEDA: &v1alpha2.KEDAScalingSpec{
|
|
PollingInterval: ptr.To(int32(15)),
|
|
CooldownPeriod: ptr.To(int32(60)),
|
|
}}},
|
|
},
|
|
config: &Config{WVAAutoscalingConfig: &WVAAutoscalingConfig{Prometheus: PrometheusConfig{URL: "http://prom:9090"}}},
|
|
scaleTargetRef: deploymentScaleTargetRef("test-svc-kserve"),
|
|
vaName: "test-svc-kserve-va",
|
|
soName: "test-svc-kserve-keda",
|
|
validate: func(t *testing.T, so *kedav1alpha1.ScaledObject) {
|
|
require.NotNil(t, so.Spec.PollingInterval)
|
|
assert.Equal(t, int32(15), *so.Spec.PollingInterval)
|
|
require.NotNil(t, so.Spec.CooldownPeriod)
|
|
assert.Equal(t, int32(60), *so.Spec.CooldownPeriod)
|
|
},
|
|
},
|
|
{
|
|
name: "owner reference is set",
|
|
llmSvc: newTestLLMISVC("test-svc", "test-ns"),
|
|
scaling: &v1alpha2.ScalingSpec{
|
|
MaxReplicas: 5,
|
|
WVA: &v1alpha2.WVASpec{ActuatorSpec: v1alpha2.ActuatorSpec{KEDA: &v1alpha2.KEDAScalingSpec{}}},
|
|
},
|
|
config: &Config{WVAAutoscalingConfig: &WVAAutoscalingConfig{Prometheus: PrometheusConfig{URL: "http://prom:9090"}}},
|
|
scaleTargetRef: deploymentScaleTargetRef("test-svc-kserve"),
|
|
vaName: "test-svc-kserve-va",
|
|
soName: "test-svc-kserve-keda",
|
|
validate: func(t *testing.T, so *kedav1alpha1.ScaledObject) {
|
|
require.Len(t, so.OwnerReferences, 1)
|
|
assert.Equal(t, "test-svc", so.OwnerReferences[0].Name)
|
|
assert.Equal(t, "LLMInferenceService", so.OwnerReferences[0].Kind)
|
|
assert.True(t, *so.OwnerReferences[0].Controller)
|
|
},
|
|
},
|
|
{
|
|
name: "no auth fields when not configured",
|
|
llmSvc: newTestLLMISVC("test-svc", "test-ns"),
|
|
scaling: &v1alpha2.ScalingSpec{
|
|
MaxReplicas: 5,
|
|
WVA: &v1alpha2.WVASpec{ActuatorSpec: v1alpha2.ActuatorSpec{KEDA: &v1alpha2.KEDAScalingSpec{}}},
|
|
},
|
|
config: &Config{WVAAutoscalingConfig: &WVAAutoscalingConfig{Prometheus: PrometheusConfig{URL: "http://prom:9090"}}},
|
|
scaleTargetRef: deploymentScaleTargetRef("test-svc-kserve"),
|
|
vaName: "test-svc-kserve-va",
|
|
soName: "test-svc-kserve-keda",
|
|
validate: func(t *testing.T, so *kedav1alpha1.ScaledObject) {
|
|
trigger := so.Spec.Triggers[0]
|
|
assert.NotContains(t, trigger.Metadata, "authModes")
|
|
assert.Nil(t, trigger.AuthenticationRef)
|
|
},
|
|
},
|
|
{
|
|
name: "TriggerAuthentication auth fields are wired into trigger",
|
|
llmSvc: newTestLLMISVC("test-svc", "test-ns"),
|
|
scaling: &v1alpha2.ScalingSpec{
|
|
MaxReplicas: 5,
|
|
WVA: &v1alpha2.WVASpec{ActuatorSpec: v1alpha2.ActuatorSpec{KEDA: &v1alpha2.KEDAScalingSpec{}}},
|
|
},
|
|
config: &Config{WVAAutoscalingConfig: &WVAAutoscalingConfig{
|
|
Prometheus: PrometheusConfig{
|
|
URL: "https://prom.monitoring:9090",
|
|
AuthModes: "bearer",
|
|
TriggerAuthName: "prom-bearer-auth",
|
|
TriggerAuthKind: "TriggerAuthentication",
|
|
},
|
|
}},
|
|
scaleTargetRef: deploymentScaleTargetRef("test-svc-kserve"),
|
|
vaName: "test-svc-kserve-va",
|
|
soName: "test-svc-kserve-keda",
|
|
validate: func(t *testing.T, so *kedav1alpha1.ScaledObject) {
|
|
trigger := so.Spec.Triggers[0]
|
|
assert.Equal(t, "bearer", trigger.Metadata["authModes"])
|
|
require.NotNil(t, trigger.AuthenticationRef)
|
|
assert.Equal(t, "prom-bearer-auth", trigger.AuthenticationRef.Name)
|
|
assert.Equal(t, "TriggerAuthentication", trigger.AuthenticationRef.Kind)
|
|
},
|
|
},
|
|
{
|
|
name: "ClusterTriggerAuthentication auth fields are wired into trigger",
|
|
llmSvc: newTestLLMISVC("test-svc", "test-ns"),
|
|
scaling: &v1alpha2.ScalingSpec{
|
|
MaxReplicas: 5,
|
|
WVA: &v1alpha2.WVASpec{ActuatorSpec: v1alpha2.ActuatorSpec{KEDA: &v1alpha2.KEDAScalingSpec{}}},
|
|
},
|
|
config: &Config{WVAAutoscalingConfig: &WVAAutoscalingConfig{
|
|
Prometheus: PrometheusConfig{
|
|
URL: "https://prom.monitoring:9090",
|
|
AuthModes: "bearer",
|
|
TriggerAuthName: "cluster-prom-auth",
|
|
TriggerAuthKind: "ClusterTriggerAuthentication",
|
|
},
|
|
}},
|
|
scaleTargetRef: deploymentScaleTargetRef("test-svc-kserve"),
|
|
vaName: "test-svc-kserve-va",
|
|
soName: "test-svc-kserve-keda",
|
|
validate: func(t *testing.T, so *kedav1alpha1.ScaledObject) {
|
|
trigger := so.Spec.Triggers[0]
|
|
assert.Equal(t, "bearer", trigger.Metadata["authModes"])
|
|
require.NotNil(t, trigger.AuthenticationRef)
|
|
assert.Equal(t, "cluster-prom-auth", trigger.AuthenticationRef.Name)
|
|
assert.Equal(t, "ClusterTriggerAuthentication", trigger.AuthenticationRef.Kind)
|
|
},
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
so := expectedScaledObject(tt.llmSvc, tt.scaling, tt.config, tt.scaleTargetRef, tt.vaName, tt.soName)
|
|
assert.Equal(t, tt.soName, so.Name)
|
|
assert.Equal(t, tt.llmSvc.Namespace, so.Namespace)
|
|
tt.validate(t, so)
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestExpectedVA(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
llmSvc *v1alpha2.LLMInferenceService
|
|
scaling *v1alpha2.ScalingSpec
|
|
scaleTargetRef autoscalingv2.CrossVersionObjectReference
|
|
vaName string
|
|
workloadLabels map[string]string
|
|
validate func(t *testing.T, va *wvav1alpha1.VariantAutoscaling)
|
|
}{
|
|
{
|
|
name: "scaleTargetRef points to deployment",
|
|
llmSvc: newTestLLMISVC("my-model", "prod"),
|
|
scaling: &v1alpha2.ScalingSpec{
|
|
WVA: &v1alpha2.WVASpec{VariantCost: "10.0"},
|
|
},
|
|
scaleTargetRef: deploymentScaleTargetRef("my-model-kserve"),
|
|
vaName: "my-model-kserve-va",
|
|
validate: func(t *testing.T, va *wvav1alpha1.VariantAutoscaling) {
|
|
assert.Equal(t, autoscalingv2.CrossVersionObjectReference{
|
|
APIVersion: "apps/v1",
|
|
Kind: "Deployment",
|
|
Name: "my-model-kserve",
|
|
}, va.Spec.ScaleTargetRef)
|
|
},
|
|
},
|
|
{
|
|
name: "scaleTargetRef points to LeaderWorkerSet for multi-node",
|
|
llmSvc: newTestLLMISVC("my-model", "prod"),
|
|
scaling: &v1alpha2.ScalingSpec{
|
|
WVA: &v1alpha2.WVASpec{VariantCost: "10.0"},
|
|
},
|
|
scaleTargetRef: lwsScaleTargetRef("my-model-kserve-mn"),
|
|
vaName: "my-model-kserve-va",
|
|
validate: func(t *testing.T, va *wvav1alpha1.VariantAutoscaling) {
|
|
assert.Equal(t, autoscalingv2.CrossVersionObjectReference{
|
|
APIVersion: lwsapi.GroupVersion.String(),
|
|
Kind: "LeaderWorkerSet",
|
|
Name: "my-model-kserve-mn",
|
|
}, va.Spec.ScaleTargetRef)
|
|
},
|
|
},
|
|
{
|
|
name: "modelID from model.name when set",
|
|
llmSvc: newTestLLMISVC("test-svc", "test-ns"),
|
|
scaling: &v1alpha2.ScalingSpec{
|
|
WVA: &v1alpha2.WVASpec{VariantCost: "5.0"},
|
|
},
|
|
scaleTargetRef: deploymentScaleTargetRef("test-svc-kserve"),
|
|
vaName: "test-svc-kserve-va",
|
|
validate: func(t *testing.T, va *wvav1alpha1.VariantAutoscaling) {
|
|
assert.Equal(t, "meta-llama/Llama-3.1-8B", va.Spec.ModelID)
|
|
},
|
|
},
|
|
{
|
|
name: "modelID falls back to URI when name is nil",
|
|
llmSvc: func() *v1alpha2.LLMInferenceService {
|
|
svc := newTestLLMISVC("test-svc", "test-ns")
|
|
svc.Spec.Model.Name = nil
|
|
return svc
|
|
}(),
|
|
scaling: &v1alpha2.ScalingSpec{
|
|
WVA: &v1alpha2.WVASpec{VariantCost: "10.0"},
|
|
},
|
|
scaleTargetRef: deploymentScaleTargetRef("test-svc-kserve"),
|
|
vaName: "test-svc-kserve-va",
|
|
validate: func(t *testing.T, va *wvav1alpha1.VariantAutoscaling) {
|
|
expectedURI := apis.URL{Scheme: "hf", Host: "meta-llama/Llama-3.1-8B"}
|
|
assert.Equal(t, expectedURI.String(), va.Spec.ModelID, "should match URI.String() output")
|
|
},
|
|
},
|
|
{
|
|
name: "variantCost forwarded correctly",
|
|
llmSvc: newTestLLMISVC("test-svc", "test-ns"),
|
|
scaling: &v1alpha2.ScalingSpec{
|
|
WVA: &v1alpha2.WVASpec{VariantCost: "42.5"},
|
|
},
|
|
scaleTargetRef: deploymentScaleTargetRef("test-svc-kserve"),
|
|
vaName: "test-svc-kserve-va",
|
|
validate: func(t *testing.T, va *wvav1alpha1.VariantAutoscaling) {
|
|
assert.Equal(t, "42.5", va.Spec.VariantCost)
|
|
},
|
|
},
|
|
{
|
|
name: "owner reference is set",
|
|
llmSvc: newTestLLMISVC("test-svc", "test-ns"),
|
|
scaling: &v1alpha2.ScalingSpec{
|
|
WVA: &v1alpha2.WVASpec{VariantCost: "10.0"},
|
|
},
|
|
scaleTargetRef: deploymentScaleTargetRef("test-svc-kserve"),
|
|
vaName: "test-svc-kserve-va",
|
|
validate: func(t *testing.T, va *wvav1alpha1.VariantAutoscaling) {
|
|
require.Len(t, va.OwnerReferences, 1)
|
|
assert.Equal(t, "test-svc", va.OwnerReferences[0].Name)
|
|
assert.Equal(t, "LLMInferenceService", va.OwnerReferences[0].Kind)
|
|
},
|
|
},
|
|
{
|
|
name: "acceleratorName label taken from workload labels when present",
|
|
llmSvc: newTestLLMISVC("test-svc", "test-ns"),
|
|
scaling: &v1alpha2.ScalingSpec{
|
|
WVA: &v1alpha2.WVASpec{VariantCost: "10.0"},
|
|
},
|
|
scaleTargetRef: deploymentScaleTargetRef("test-svc-kserve"),
|
|
vaName: "test-svc-kserve-va",
|
|
workloadLabels: map[string]string{
|
|
acceleratorNameLabelKey: "H100",
|
|
},
|
|
validate: func(t *testing.T, va *wvav1alpha1.VariantAutoscaling) {
|
|
assert.Equal(t, "H100", va.Labels[acceleratorNameLabelKey])
|
|
},
|
|
},
|
|
{
|
|
name: "acceleratorName label is unknown when not present in workload labels",
|
|
llmSvc: newTestLLMISVC("test-svc", "test-ns"),
|
|
scaling: &v1alpha2.ScalingSpec{
|
|
WVA: &v1alpha2.WVASpec{VariantCost: "10.0"},
|
|
},
|
|
scaleTargetRef: deploymentScaleTargetRef("test-svc-kserve"),
|
|
vaName: "test-svc-kserve-va",
|
|
workloadLabels: nil,
|
|
validate: func(t *testing.T, va *wvav1alpha1.VariantAutoscaling) {
|
|
assert.Equal(t, "unknown", va.Labels[acceleratorNameLabelKey])
|
|
},
|
|
},
|
|
{
|
|
name: "acceleratorName label is unknown when present but empty in workload labels",
|
|
llmSvc: newTestLLMISVC("test-svc", "test-ns"),
|
|
scaling: &v1alpha2.ScalingSpec{
|
|
WVA: &v1alpha2.WVASpec{VariantCost: "10.0"},
|
|
},
|
|
scaleTargetRef: deploymentScaleTargetRef("test-svc-kserve"),
|
|
vaName: "test-svc-kserve-va",
|
|
workloadLabels: map[string]string{
|
|
acceleratorNameLabelKey: "",
|
|
},
|
|
validate: func(t *testing.T, va *wvav1alpha1.VariantAutoscaling) {
|
|
assert.Equal(t, "unknown", va.Labels[acceleratorNameLabelKey])
|
|
},
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
va := expectedVA(tt.llmSvc, tt.scaling, tt.scaleTargetRef, tt.vaName, tt.workloadLabels)
|
|
assert.Equal(t, tt.vaName, va.Name)
|
|
assert.Equal(t, tt.llmSvc.Namespace, va.Namespace)
|
|
tt.validate(t, va)
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestValidateAutoscalingConfig(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
cfg *WVAAutoscalingConfig
|
|
wantErr string
|
|
}{
|
|
{
|
|
name: "nil config returns error",
|
|
cfg: nil,
|
|
wantErr: "autoscaling-wva-controller-config.prometheus.url is required",
|
|
},
|
|
{
|
|
name: "missing prometheus.url returns error",
|
|
cfg: &WVAAutoscalingConfig{},
|
|
wantErr: "autoscaling-wva-controller-config.prometheus.url is required",
|
|
},
|
|
{
|
|
name: "no auth fields is valid",
|
|
cfg: &WVAAutoscalingConfig{Prometheus: PrometheusConfig{URL: "http://prom:9090"}},
|
|
},
|
|
{
|
|
name: "both auth fields set is valid",
|
|
cfg: &WVAAutoscalingConfig{
|
|
Prometheus: PrometheusConfig{
|
|
URL: "https://prom:9090",
|
|
AuthModes: "bearer",
|
|
TriggerAuthName: "prom-auth",
|
|
},
|
|
},
|
|
},
|
|
{
|
|
name: "authModes set without triggerAuthName returns error",
|
|
cfg: &WVAAutoscalingConfig{
|
|
Prometheus: PrometheusConfig{
|
|
URL: "https://prom:9090",
|
|
AuthModes: "bearer",
|
|
},
|
|
},
|
|
wantErr: "autoscaling-wva-controller-config.prometheus.authModes and autoscaling-wva-controller-config.prometheus.triggerAuthName must both be set or both be empty",
|
|
},
|
|
{
|
|
name: "triggerAuthName set without authModes returns error",
|
|
cfg: &WVAAutoscalingConfig{
|
|
Prometheus: PrometheusConfig{
|
|
URL: "https://prom:9090",
|
|
TriggerAuthName: "prom-auth",
|
|
},
|
|
},
|
|
wantErr: "autoscaling-wva-controller-config.prometheus.authModes and autoscaling-wva-controller-config.prometheus.triggerAuthName must both be set or both be empty",
|
|
},
|
|
{
|
|
name: "ClusterTriggerAuthentication kind with both auth fields is valid",
|
|
cfg: &WVAAutoscalingConfig{
|
|
Prometheus: PrometheusConfig{
|
|
URL: "https://prom:9090",
|
|
AuthModes: "bearer",
|
|
TriggerAuthName: "cluster-prom-auth",
|
|
TriggerAuthKind: "ClusterTriggerAuthentication",
|
|
},
|
|
},
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
err := validateAutoscalingConfig(tt.cfg)
|
|
if tt.wantErr != "" {
|
|
require.Error(t, err)
|
|
assert.Contains(t, err.Error(), tt.wantErr)
|
|
} else {
|
|
assert.NoError(t, err)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestSemanticHPAIsEqual(t *testing.T) {
|
|
base := func() *autoscalingv2.HorizontalPodAutoscaler {
|
|
return &autoscalingv2.HorizontalPodAutoscaler{
|
|
ObjectMeta: metav1.ObjectMeta{Labels: map[string]string{"app": "test"}},
|
|
Spec: autoscalingv2.HorizontalPodAutoscalerSpec{
|
|
MaxReplicas: 5,
|
|
MinReplicas: ptr.To(int32(1)),
|
|
},
|
|
}
|
|
}
|
|
|
|
t.Run("equal specs returns true", func(t *testing.T) {
|
|
assert.True(t, semanticHPAIsEqual(base(), base()))
|
|
})
|
|
|
|
t.Run("different maxReplicas returns false", func(t *testing.T) {
|
|
modified := base()
|
|
modified.Spec.MaxReplicas = 10
|
|
assert.False(t, semanticHPAIsEqual(base(), modified))
|
|
})
|
|
|
|
t.Run("different labels returns false", func(t *testing.T) {
|
|
modified := base()
|
|
modified.Labels = map[string]string{"app": "other"}
|
|
assert.False(t, semanticHPAIsEqual(base(), modified))
|
|
})
|
|
|
|
t.Run("removed field in expected is detected", func(t *testing.T) {
|
|
expected := base()
|
|
expected.Spec.MinReplicas = nil
|
|
assert.False(t, semanticHPAIsEqual(expected, base()))
|
|
})
|
|
|
|
t.Run("extra label on curr is detected", func(t *testing.T) {
|
|
curr := base()
|
|
curr.Labels["extra"] = "value"
|
|
assert.False(t, semanticHPAIsEqual(base(), curr))
|
|
})
|
|
|
|
t.Run("removed annotation in expected is detected", func(t *testing.T) {
|
|
expected := base()
|
|
expected.Annotations = nil
|
|
curr := base()
|
|
curr.Annotations = map[string]string{"note": "old"}
|
|
assert.False(t, semanticHPAIsEqual(expected, curr))
|
|
})
|
|
}
|
|
|
|
func TestSemanticScaledObjectIsEqual(t *testing.T) {
|
|
base := func() *kedav1alpha1.ScaledObject {
|
|
return &kedav1alpha1.ScaledObject{
|
|
ObjectMeta: metav1.ObjectMeta{Labels: map[string]string{"app": "test"}},
|
|
Spec: kedav1alpha1.ScaledObjectSpec{
|
|
MaxReplicaCount: ptr.To(int32(5)),
|
|
Triggers: []kedav1alpha1.ScaleTriggers{
|
|
{Type: "prometheus", Metadata: map[string]string{"query": "up"}},
|
|
},
|
|
},
|
|
}
|
|
}
|
|
|
|
t.Run("equal specs returns true", func(t *testing.T) {
|
|
assert.True(t, semanticScaledObjectIsEqual(base(), base()))
|
|
})
|
|
|
|
t.Run("different trigger returns false", func(t *testing.T) {
|
|
modified := base()
|
|
modified.Spec.Triggers[0].Metadata["query"] = "down"
|
|
assert.False(t, semanticScaledObjectIsEqual(base(), modified))
|
|
})
|
|
|
|
t.Run("different labels returns false", func(t *testing.T) {
|
|
modified := base()
|
|
modified.Labels = map[string]string{"app": "other"}
|
|
assert.False(t, semanticScaledObjectIsEqual(base(), modified))
|
|
})
|
|
|
|
t.Run("removed optional field in expected is detected", func(t *testing.T) {
|
|
expected := base()
|
|
expected.Spec.MaxReplicaCount = nil
|
|
assert.False(t, semanticScaledObjectIsEqual(expected, base()))
|
|
})
|
|
|
|
t.Run("extra label on curr is detected", func(t *testing.T) {
|
|
curr := base()
|
|
curr.Labels["extra"] = "value"
|
|
assert.False(t, semanticScaledObjectIsEqual(base(), curr))
|
|
})
|
|
}
|
|
|
|
func TestSemanticVAIsEqual(t *testing.T) {
|
|
base := func() *wvav1alpha1.VariantAutoscaling {
|
|
return &wvav1alpha1.VariantAutoscaling{
|
|
ObjectMeta: metav1.ObjectMeta{Labels: map[string]string{"app": "test"}},
|
|
Spec: wvav1alpha1.VariantAutoscalingSpec{
|
|
ModelID: "meta-llama/Llama-3.1-8B",
|
|
VariantAutoscalingConfigSpec: wvav1alpha1.VariantAutoscalingConfigSpec{
|
|
VariantCost: "10.0",
|
|
},
|
|
},
|
|
}
|
|
}
|
|
|
|
t.Run("equal specs returns true", func(t *testing.T) {
|
|
assert.True(t, semanticVAIsEqual(base(), base()))
|
|
})
|
|
|
|
t.Run("different modelID returns false", func(t *testing.T) {
|
|
modified := base()
|
|
modified.Spec.ModelID = "other-model"
|
|
assert.False(t, semanticVAIsEqual(base(), modified))
|
|
})
|
|
|
|
t.Run("different labels returns false", func(t *testing.T) {
|
|
modified := base()
|
|
modified.Labels = map[string]string{"app": "other"}
|
|
assert.False(t, semanticVAIsEqual(base(), modified))
|
|
})
|
|
|
|
t.Run("removed variantCost in expected is detected", func(t *testing.T) {
|
|
expected := base()
|
|
expected.Spec.VariantCost = ""
|
|
assert.False(t, semanticVAIsEqual(expected, base()))
|
|
})
|
|
|
|
t.Run("extra label on curr is detected", func(t *testing.T) {
|
|
curr := base()
|
|
curr.Labels["extra"] = "value"
|
|
assert.False(t, semanticVAIsEqual(base(), curr))
|
|
})
|
|
|
|
t.Run("removed annotation in expected is detected", func(t *testing.T) {
|
|
expected := base()
|
|
expected.Annotations = nil
|
|
curr := base()
|
|
curr.Annotations = map[string]string{"note": "old"}
|
|
assert.False(t, semanticVAIsEqual(expected, curr))
|
|
})
|
|
}
|
|
|
|
func TestPreserveKEDAManagedMetadata(t *testing.T) {
|
|
hook := PreserveKEDAManagedMetadata()
|
|
|
|
// Extract the AfterDryRunFunc from the UpdateOption by applying it to updateOptions.
|
|
opts := &updateOptions[*kedav1alpha1.ScaledObject]{}
|
|
hook(opts)
|
|
require.Len(t, opts.afterDryRunFns, 1)
|
|
fn := opts.afterDryRunFns[0]
|
|
|
|
t.Run("copies KEDA label from curr into expected", func(t *testing.T) {
|
|
expected := &kedav1alpha1.ScaledObject{
|
|
ObjectMeta: metav1.ObjectMeta{Labels: map[string]string{"app": "test"}},
|
|
}
|
|
curr := &kedav1alpha1.ScaledObject{
|
|
ObjectMeta: metav1.ObjectMeta{Labels: map[string]string{
|
|
"app": "test",
|
|
kedav1alpha1.ScaledObjectOwnerAnnotation: "my-so",
|
|
}},
|
|
}
|
|
fn(expected, expected.DeepCopy(), curr)
|
|
assert.Equal(t, "my-so", expected.Labels[kedav1alpha1.ScaledObjectOwnerAnnotation])
|
|
assert.Equal(t, "test", expected.Labels["app"])
|
|
})
|
|
|
|
t.Run("no-op when curr has no KEDA label", func(t *testing.T) {
|
|
expected := &kedav1alpha1.ScaledObject{
|
|
ObjectMeta: metav1.ObjectMeta{Labels: map[string]string{"app": "test"}},
|
|
}
|
|
curr := &kedav1alpha1.ScaledObject{
|
|
ObjectMeta: metav1.ObjectMeta{Labels: map[string]string{"app": "test"}},
|
|
}
|
|
fn(expected, expected.DeepCopy(), curr)
|
|
assert.Equal(t, map[string]string{"app": "test"}, expected.Labels)
|
|
})
|
|
|
|
t.Run("initializes nil labels map when KEDA label present on curr", func(t *testing.T) {
|
|
expected := &kedav1alpha1.ScaledObject{}
|
|
curr := &kedav1alpha1.ScaledObject{
|
|
ObjectMeta: metav1.ObjectMeta{Labels: map[string]string{
|
|
kedav1alpha1.ScaledObjectOwnerAnnotation: "my-so",
|
|
}},
|
|
}
|
|
fn(expected, expected.DeepCopy(), curr)
|
|
assert.Equal(t, "my-so", expected.Labels[kedav1alpha1.ScaledObjectOwnerAnnotation])
|
|
})
|
|
|
|
t.Run("copies finalizers from curr into expected", func(t *testing.T) {
|
|
expected := &kedav1alpha1.ScaledObject{}
|
|
curr := &kedav1alpha1.ScaledObject{
|
|
ObjectMeta: metav1.ObjectMeta{Finalizers: []string{"finalizer.keda.sh"}},
|
|
}
|
|
fn(expected, expected.DeepCopy(), curr)
|
|
assert.Equal(t, []string{"finalizer.keda.sh"}, expected.Finalizers)
|
|
})
|
|
|
|
t.Run("no-op when curr has no finalizers", func(t *testing.T) {
|
|
expected := &kedav1alpha1.ScaledObject{}
|
|
curr := &kedav1alpha1.ScaledObject{}
|
|
fn(expected, expected.DeepCopy(), curr)
|
|
assert.Empty(t, expected.Finalizers)
|
|
})
|
|
|
|
t.Run("preserves both KEDA label and finalizers together", func(t *testing.T) {
|
|
expected := &kedav1alpha1.ScaledObject{}
|
|
curr := &kedav1alpha1.ScaledObject{
|
|
ObjectMeta: metav1.ObjectMeta{
|
|
Labels: map[string]string{kedav1alpha1.ScaledObjectOwnerAnnotation: "my-so"},
|
|
Finalizers: []string{"finalizer.keda.sh"},
|
|
},
|
|
}
|
|
fn(expected, expected.DeepCopy(), curr)
|
|
assert.Equal(t, "my-so", expected.Labels[kedav1alpha1.ScaledObjectOwnerAnnotation])
|
|
assert.Equal(t, []string{"finalizer.keda.sh"}, expected.Finalizers)
|
|
})
|
|
}
|
|
|
|
func TestNamingHelpers(t *testing.T) {
|
|
svc := newTestLLMISVC("sim-llama", "llm-d-dev")
|
|
|
|
t.Run("main deployment name (standard)", func(t *testing.T) {
|
|
assert.Equal(t, "sim-llama-kserve", mainDeploymentName(svc))
|
|
})
|
|
|
|
t.Run("prefill deployment name", func(t *testing.T) {
|
|
assert.Equal(t, "sim-llama-kserve-prefill", prefillDeploymentName(svc))
|
|
})
|
|
|
|
t.Run("main HPA name", func(t *testing.T) {
|
|
assert.Equal(t, "sim-llama-kserve-hpa", mainHPAName(svc))
|
|
})
|
|
|
|
t.Run("prefill HPA name", func(t *testing.T) {
|
|
assert.Equal(t, "sim-llama-kserve-prefill-hpa", prefillHPAName(svc))
|
|
})
|
|
|
|
t.Run("main VA name", func(t *testing.T) {
|
|
assert.Equal(t, "sim-llama-kserve-va", mainVAName(svc))
|
|
})
|
|
|
|
t.Run("prefill VA name", func(t *testing.T) {
|
|
assert.Equal(t, "sim-llama-kserve-prefill-va", prefillVAName(svc))
|
|
})
|
|
|
|
t.Run("main ScaledObject name", func(t *testing.T) {
|
|
assert.Equal(t, "sim-llama-kserve-keda", mainScaledObjectName(svc))
|
|
})
|
|
|
|
t.Run("prefill ScaledObject name", func(t *testing.T) {
|
|
assert.Equal(t, "sim-llama-kserve-prefill-keda", prefillScaledObjectName(svc))
|
|
})
|
|
|
|
t.Run("main LWS name", func(t *testing.T) {
|
|
assert.Equal(t, "sim-llama-kserve-mn", mainLWSName(svc))
|
|
})
|
|
|
|
t.Run("prefill LWS name", func(t *testing.T) {
|
|
assert.Equal(t, "sim-llama-kserve-mn-prefill", prefillLWSName(svc))
|
|
})
|
|
}
|
|
|
|
func TestScaleTargetRefHelpers(t *testing.T) {
|
|
t.Run("mainScaleTargetRef returns Deployment when no worker", func(t *testing.T) {
|
|
svc := newTestLLMISVC("test-svc", "test-ns")
|
|
ref := mainScaleTargetRef(svc)
|
|
assert.Equal(t, "apps/v1", ref.APIVersion)
|
|
assert.Equal(t, "Deployment", ref.Kind)
|
|
assert.Equal(t, mainDeploymentName(svc), ref.Name)
|
|
})
|
|
|
|
t.Run("mainScaleTargetRef returns LWS when worker is set", func(t *testing.T) {
|
|
svc := newTestLLMISVC("test-svc", "test-ns")
|
|
svc.Spec.Worker = &corev1.PodSpec{}
|
|
ref := mainScaleTargetRef(svc)
|
|
assert.Equal(t, lwsapi.GroupVersion.String(), ref.APIVersion)
|
|
assert.Equal(t, "LeaderWorkerSet", ref.Kind)
|
|
assert.Equal(t, mainLWSName(svc), ref.Name)
|
|
})
|
|
|
|
t.Run("prefillScaleTargetRef returns Deployment when no prefill worker", func(t *testing.T) {
|
|
svc := newTestLLMISVC("test-svc", "test-ns")
|
|
ref := prefillScaleTargetRef(svc)
|
|
assert.Equal(t, "apps/v1", ref.APIVersion)
|
|
assert.Equal(t, "Deployment", ref.Kind)
|
|
assert.Equal(t, prefillDeploymentName(svc), ref.Name)
|
|
})
|
|
|
|
t.Run("prefillScaleTargetRef returns LWS when prefill worker is set", func(t *testing.T) {
|
|
svc := newTestLLMISVC("test-svc", "test-ns")
|
|
svc.Spec.Prefill = &v1alpha2.WorkloadSpec{
|
|
Worker: &corev1.PodSpec{},
|
|
}
|
|
ref := prefillScaleTargetRef(svc)
|
|
assert.Equal(t, lwsapi.GroupVersion.String(), ref.APIVersion)
|
|
assert.Equal(t, "LeaderWorkerSet", ref.Kind)
|
|
assert.Equal(t, prefillLWSName(svc), ref.Name)
|
|
})
|
|
|
|
t.Run("prefillScaleTargetRef returns Deployment when prefill has no worker", func(t *testing.T) {
|
|
svc := newTestLLMISVC("test-svc", "test-ns")
|
|
svc.Spec.Prefill = &v1alpha2.WorkloadSpec{}
|
|
ref := prefillScaleTargetRef(svc)
|
|
assert.Equal(t, "apps/v1", ref.APIVersion)
|
|
assert.Equal(t, "Deployment", ref.Kind)
|
|
assert.Equal(t, prefillDeploymentName(svc), ref.Name)
|
|
})
|
|
}
|
|
|
|
func newReconcilerWithHPA(hpa *autoscalingv2.HorizontalPodAutoscaler) *LLMISVCReconciler {
|
|
scheme := runtime.NewScheme()
|
|
_ = autoscalingv2.AddToScheme(scheme)
|
|
cb := fake.NewClientBuilder().WithScheme(scheme)
|
|
if hpa != nil {
|
|
cb = cb.WithObjects(hpa)
|
|
}
|
|
return &LLMISVCReconciler{
|
|
Client: cb.Build(),
|
|
EventRecorder: record.NewFakeRecorder(10),
|
|
}
|
|
}
|
|
|
|
func newReconcilerWithScaledObject(so *kedav1alpha1.ScaledObject) *LLMISVCReconciler {
|
|
scheme := runtime.NewScheme()
|
|
_ = kedav1alpha1.AddToScheme(scheme)
|
|
cb := fake.NewClientBuilder().WithScheme(scheme)
|
|
if so != nil {
|
|
cb = cb.WithObjects(so)
|
|
}
|
|
return &LLMISVCReconciler{
|
|
Client: cb.Build(),
|
|
EventRecorder: record.NewFakeRecorder(10),
|
|
}
|
|
}
|
|
|
|
func TestPropagateHPAStatus(t *testing.T) {
|
|
expectedHPAObj := &autoscalingv2.HorizontalPodAutoscaler{
|
|
ObjectMeta: metav1.ObjectMeta{Name: "test-hpa", Namespace: "test-ns"},
|
|
}
|
|
|
|
tests := []struct {
|
|
name string
|
|
hpa *autoscalingv2.HorizontalPodAutoscaler
|
|
wantReady bool
|
|
wantNotReady bool
|
|
wantReason string
|
|
wantErr bool
|
|
}{
|
|
{
|
|
name: "AbleToScale=True and ScalingActive=True -> ready",
|
|
hpa: &autoscalingv2.HorizontalPodAutoscaler{
|
|
ObjectMeta: metav1.ObjectMeta{Name: "test-hpa", Namespace: "test-ns"},
|
|
Status: autoscalingv2.HorizontalPodAutoscalerStatus{
|
|
Conditions: []autoscalingv2.HorizontalPodAutoscalerCondition{
|
|
{Type: autoscalingv2.AbleToScale, Status: corev1.ConditionTrue},
|
|
{Type: autoscalingv2.ScalingActive, Status: corev1.ConditionTrue},
|
|
},
|
|
},
|
|
},
|
|
wantReady: true,
|
|
},
|
|
{
|
|
name: "AbleToScale=False -> not ready with reason",
|
|
hpa: &autoscalingv2.HorizontalPodAutoscaler{
|
|
ObjectMeta: metav1.ObjectMeta{Name: "test-hpa", Namespace: "test-ns"},
|
|
Status: autoscalingv2.HorizontalPodAutoscalerStatus{
|
|
Conditions: []autoscalingv2.HorizontalPodAutoscalerCondition{
|
|
{Type: autoscalingv2.AbleToScale, Status: corev1.ConditionFalse, Reason: "FailedGetScale", Message: "cannot get scale"},
|
|
{Type: autoscalingv2.ScalingActive, Status: corev1.ConditionTrue},
|
|
},
|
|
},
|
|
},
|
|
wantNotReady: true,
|
|
wantReason: "FailedGetScale",
|
|
},
|
|
{
|
|
name: "ScalingActive=False -> not ready with reason",
|
|
hpa: &autoscalingv2.HorizontalPodAutoscaler{
|
|
ObjectMeta: metav1.ObjectMeta{Name: "test-hpa", Namespace: "test-ns"},
|
|
Status: autoscalingv2.HorizontalPodAutoscalerStatus{
|
|
Conditions: []autoscalingv2.HorizontalPodAutoscalerCondition{
|
|
{Type: autoscalingv2.AbleToScale, Status: corev1.ConditionTrue},
|
|
{Type: autoscalingv2.ScalingActive, Status: corev1.ConditionFalse, Reason: "FailedGetExternalMetric", Message: "metric not found"},
|
|
},
|
|
},
|
|
},
|
|
wantNotReady: true,
|
|
wantReason: "FailedGetExternalMetric",
|
|
},
|
|
{
|
|
name: "no conditions yet -> not ready HPAProgressing",
|
|
hpa: &autoscalingv2.HorizontalPodAutoscaler{
|
|
ObjectMeta: metav1.ObjectMeta{Name: "test-hpa", Namespace: "test-ns"},
|
|
},
|
|
wantNotReady: true,
|
|
wantReason: "HPAProgressing",
|
|
},
|
|
{
|
|
name: "ScalingLimited=True with healthy conditions -> ready",
|
|
hpa: &autoscalingv2.HorizontalPodAutoscaler{
|
|
ObjectMeta: metav1.ObjectMeta{Name: "test-hpa", Namespace: "test-ns"},
|
|
Status: autoscalingv2.HorizontalPodAutoscalerStatus{
|
|
Conditions: []autoscalingv2.HorizontalPodAutoscalerCondition{
|
|
{Type: autoscalingv2.AbleToScale, Status: corev1.ConditionTrue},
|
|
{Type: autoscalingv2.ScalingActive, Status: corev1.ConditionTrue},
|
|
{Type: autoscalingv2.ScalingLimited, Status: corev1.ConditionTrue, Message: "at max replicas"},
|
|
},
|
|
},
|
|
},
|
|
wantReady: true,
|
|
},
|
|
{
|
|
name: "HPA not found -> not ready HPAProgressing (cache lag)",
|
|
hpa: nil,
|
|
wantNotReady: true,
|
|
wantReason: "HPAProgressing",
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
r := newReconcilerWithHPA(tt.hpa)
|
|
var readyCalled, notReadyCalled bool
|
|
var notReadyReason string
|
|
|
|
err := r.propagateHPAStatus(context.Background(), expectedHPAObj,
|
|
func() { readyCalled = true },
|
|
func(reason, msg string, a ...interface{}) {
|
|
notReadyCalled = true
|
|
notReadyReason = reason
|
|
},
|
|
)
|
|
|
|
if tt.wantErr {
|
|
require.Error(t, err)
|
|
return
|
|
}
|
|
require.NoError(t, err)
|
|
assert.Equal(t, tt.wantReady, readyCalled, "ready callback")
|
|
assert.Equal(t, tt.wantNotReady, notReadyCalled, "notReady callback")
|
|
if tt.wantReason != "" {
|
|
assert.Equal(t, tt.wantReason, notReadyReason)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestPropagateScaledObjectStatus(t *testing.T) {
|
|
expectedSO := &kedav1alpha1.ScaledObject{
|
|
ObjectMeta: metav1.ObjectMeta{Name: "test-so", Namespace: "test-ns"},
|
|
}
|
|
|
|
tests := []struct {
|
|
name string
|
|
so *kedav1alpha1.ScaledObject
|
|
wantReady bool
|
|
wantNotReady bool
|
|
wantReason string
|
|
wantErr bool
|
|
}{
|
|
{
|
|
name: "Ready=True -> ready",
|
|
so: &kedav1alpha1.ScaledObject{
|
|
ObjectMeta: metav1.ObjectMeta{Name: "test-so", Namespace: "test-ns"},
|
|
Status: kedav1alpha1.ScaledObjectStatus{
|
|
Conditions: kedav1alpha1.Conditions{
|
|
{Type: kedav1alpha1.ConditionReady, Status: metav1.ConditionTrue},
|
|
{Type: kedav1alpha1.ConditionActive, Status: metav1.ConditionTrue},
|
|
{Type: kedav1alpha1.ConditionFallback, Status: metav1.ConditionFalse},
|
|
{Type: kedav1alpha1.ConditionPaused, Status: metav1.ConditionFalse},
|
|
},
|
|
},
|
|
},
|
|
wantReady: true,
|
|
},
|
|
{
|
|
name: "Ready=False -> not ready with reason",
|
|
so: &kedav1alpha1.ScaledObject{
|
|
ObjectMeta: metav1.ObjectMeta{Name: "test-so", Namespace: "test-ns"},
|
|
Status: kedav1alpha1.ScaledObjectStatus{
|
|
Conditions: kedav1alpha1.Conditions{
|
|
{Type: kedav1alpha1.ConditionReady, Status: metav1.ConditionFalse, Reason: "TriggerError", Message: "prometheus query failed"},
|
|
{Type: kedav1alpha1.ConditionActive, Status: metav1.ConditionFalse},
|
|
{Type: kedav1alpha1.ConditionFallback, Status: metav1.ConditionFalse},
|
|
{Type: kedav1alpha1.ConditionPaused, Status: metav1.ConditionFalse},
|
|
},
|
|
},
|
|
},
|
|
wantNotReady: true,
|
|
wantReason: "TriggerError",
|
|
},
|
|
{
|
|
name: "Ready=Unknown -> not ready ScaledObjectProgressing",
|
|
so: &kedav1alpha1.ScaledObject{
|
|
ObjectMeta: metav1.ObjectMeta{Name: "test-so", Namespace: "test-ns"},
|
|
Status: kedav1alpha1.ScaledObjectStatus{
|
|
Conditions: kedav1alpha1.Conditions{
|
|
{Type: kedav1alpha1.ConditionReady, Status: metav1.ConditionUnknown},
|
|
{Type: kedav1alpha1.ConditionActive, Status: metav1.ConditionUnknown},
|
|
{Type: kedav1alpha1.ConditionFallback, Status: metav1.ConditionUnknown},
|
|
{Type: kedav1alpha1.ConditionPaused, Status: metav1.ConditionUnknown},
|
|
},
|
|
},
|
|
},
|
|
wantNotReady: true,
|
|
wantReason: "ScaledObjectProgressing",
|
|
},
|
|
{
|
|
name: "Paused=True with Ready=True -> ready (pause not surfaced yet)",
|
|
so: &kedav1alpha1.ScaledObject{
|
|
ObjectMeta: metav1.ObjectMeta{Name: "test-so", Namespace: "test-ns"},
|
|
Status: kedav1alpha1.ScaledObjectStatus{
|
|
Conditions: kedav1alpha1.Conditions{
|
|
{Type: kedav1alpha1.ConditionReady, Status: metav1.ConditionTrue},
|
|
{Type: kedav1alpha1.ConditionActive, Status: metav1.ConditionTrue},
|
|
{Type: kedav1alpha1.ConditionFallback, Status: metav1.ConditionFalse},
|
|
{Type: kedav1alpha1.ConditionPaused, Status: metav1.ConditionTrue, Reason: "ScaledObjectPaused", Message: "ScaledObject is paused"},
|
|
},
|
|
},
|
|
},
|
|
wantReady: true,
|
|
},
|
|
{
|
|
name: "Fallback=True with Ready=True -> ready (soft warning)",
|
|
so: &kedav1alpha1.ScaledObject{
|
|
ObjectMeta: metav1.ObjectMeta{Name: "test-so", Namespace: "test-ns"},
|
|
Status: kedav1alpha1.ScaledObjectStatus{
|
|
Conditions: kedav1alpha1.Conditions{
|
|
{Type: kedav1alpha1.ConditionReady, Status: metav1.ConditionTrue},
|
|
{Type: kedav1alpha1.ConditionActive, Status: metav1.ConditionTrue},
|
|
{Type: kedav1alpha1.ConditionFallback, Status: metav1.ConditionTrue, Message: "using fallback replicas"},
|
|
{Type: kedav1alpha1.ConditionPaused, Status: metav1.ConditionFalse},
|
|
},
|
|
},
|
|
},
|
|
wantReady: true,
|
|
},
|
|
{
|
|
name: "no conditions (nil) -> not ready ScaledObjectProgressing",
|
|
so: &kedav1alpha1.ScaledObject{
|
|
ObjectMeta: metav1.ObjectMeta{Name: "test-so", Namespace: "test-ns"},
|
|
},
|
|
wantNotReady: true,
|
|
wantReason: "ScaledObjectProgressing",
|
|
},
|
|
{
|
|
name: "ScaledObject not found -> not ready ScaledObjectProgressing (cache lag)",
|
|
so: nil,
|
|
wantNotReady: true,
|
|
wantReason: "ScaledObjectProgressing",
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
r := newReconcilerWithScaledObject(tt.so)
|
|
var readyCalled, notReadyCalled bool
|
|
var notReadyReason string
|
|
|
|
err := r.propagateScaledObjectStatus(context.Background(), expectedSO,
|
|
func() { readyCalled = true },
|
|
func(reason, msg string, a ...interface{}) {
|
|
notReadyCalled = true
|
|
notReadyReason = reason
|
|
},
|
|
)
|
|
|
|
if tt.wantErr {
|
|
require.Error(t, err)
|
|
return
|
|
}
|
|
require.NoError(t, err)
|
|
assert.Equal(t, tt.wantReady, readyCalled, "ready callback")
|
|
assert.Equal(t, tt.wantNotReady, notReadyCalled, "notReady callback")
|
|
if tt.wantReason != "" {
|
|
assert.Equal(t, tt.wantReason, notReadyReason)
|
|
}
|
|
})
|
|
}
|
|
}
|