Files
2026-06-24 09:21:34 -07:00

2888 lines
81 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_test
import (
"strings"
"testing"
. "github.com/onsi/gomega"
"sigs.k8s.io/controller-runtime/pkg/log"
"sigs.k8s.io/yaml"
ktesting "github.com/kserve/kserve/pkg/testing"
"github.com/kserve/kserve/pkg/controller/v1alpha2/llmisvc"
"github.com/kserve/kserve/pkg/credentials"
"github.com/google/go-cmp/cmp"
corev1 "k8s.io/api/core/v1"
"k8s.io/apimachinery/pkg/api/resource"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/utils/ptr"
"knative.dev/pkg/apis"
duckv1 "knative.dev/pkg/apis/duck/v1"
igwapi "sigs.k8s.io/gateway-api-inference-extension/api/v1"
gwapiv1 "sigs.k8s.io/gateway-api/apis/v1"
"github.com/kserve/kserve/pkg/apis/serving/v1alpha2"
pkgtest "github.com/kserve/kserve/pkg/testing"
)
func TestMergeSpecs(t *testing.T) {
tests := []struct {
name string
cfgs []v1alpha2.LLMInferenceServiceSpec
want v1alpha2.LLMInferenceServiceSpec
wantErr bool
}{
{
name: "no configs",
cfgs: []v1alpha2.LLMInferenceServiceSpec{},
want: v1alpha2.LLMInferenceServiceSpec{},
wantErr: false,
},
{
name: "single config",
cfgs: []v1alpha2.LLMInferenceServiceSpec{
{Model: v1alpha2.LLMModelSpec{URI: apis.URL{Path: "model-a"}}},
},
want: v1alpha2.LLMInferenceServiceSpec{Model: v1alpha2.LLMModelSpec{URI: apis.URL{Path: "model-a"}}},
wantErr: false,
},
{
name: "two configs simple merge",
cfgs: []v1alpha2.LLMInferenceServiceSpec{
{Model: v1alpha2.LLMModelSpec{URI: apis.URL{Path: "model-a"}}},
},
want: v1alpha2.LLMInferenceServiceSpec{
Model: v1alpha2.LLMModelSpec{URI: apis.URL{Path: "model-a"}},
},
wantErr: false,
},
{
name: "two configs simple merge with sub-field override",
cfgs: []v1alpha2.LLMInferenceServiceSpec{
{
Model: v1alpha2.LLMModelSpec{URI: apis.URL{Path: "model-a"}},
Router: &v1alpha2.RouterSpec{
Route: &v1alpha2.GatewayRoutesSpec{},
Gateway: &v1alpha2.GatewaySpec{},
Scheduler: &v1alpha2.SchedulerSpec{},
},
},
{
Model: v1alpha2.LLMModelSpec{URI: apis.URL{Path: "model-a"}},
Router: &v1alpha2.RouterSpec{
Scheduler: &v1alpha2.SchedulerSpec{
Pool: &v1alpha2.InferencePoolSpec{
Spec: &igwapi.InferencePoolSpec{
TargetPorts: []igwapi.Port{{Number: 9999}},
},
},
},
},
},
},
want: v1alpha2.LLMInferenceServiceSpec{
Model: v1alpha2.LLMModelSpec{URI: apis.URL{Path: "model-a"}},
Router: &v1alpha2.RouterSpec{
Route: &v1alpha2.GatewayRoutesSpec{},
Gateway: &v1alpha2.GatewaySpec{},
Scheduler: &v1alpha2.SchedulerSpec{
Pool: &v1alpha2.InferencePoolSpec{
Spec: &igwapi.InferencePoolSpec{
TargetPorts: []igwapi.Port{{Number: 9999}},
},
},
},
},
},
wantErr: false,
},
{
name: "two configs with override",
cfgs: []v1alpha2.LLMInferenceServiceSpec{
{
Model: v1alpha2.LLMModelSpec{URI: apis.URL{Path: "model-a"}},
WorkloadSpec: v1alpha2.WorkloadSpec{
Replicas: ptr.To[int32](1),
},
},
{
Model: v1alpha2.LLMModelSpec{URI: apis.URL{Path: "model-b"}},
WorkloadSpec: v1alpha2.WorkloadSpec{
Replicas: ptr.To[int32](2),
},
},
},
want: v1alpha2.LLMInferenceServiceSpec{
Model: v1alpha2.LLMModelSpec{URI: apis.URL{Path: "model-b"}},
WorkloadSpec: v1alpha2.WorkloadSpec{
Replicas: ptr.To[int32](2),
},
},
wantErr: false,
},
{
name: "three configs chained merge",
cfgs: []v1alpha2.LLMInferenceServiceSpec{
{Model: v1alpha2.LLMModelSpec{URI: apis.URL{Path: "model-a"}}},
{
Model: v1alpha2.LLMModelSpec{URI: apis.URL{Path: "model-b"}},
},
},
want: v1alpha2.LLMInferenceServiceSpec{
Model: v1alpha2.LLMModelSpec{URI: apis.URL{Path: "model-b"}},
},
wantErr: false,
},
{
name: "deep merge with podspec template",
cfgs: []v1alpha2.LLMInferenceServiceSpec{
// Base configuration
{
WorkloadSpec: v1alpha2.WorkloadSpec{
Replicas: ptr.To[int32](1),
Template: &corev1.PodSpec{
InitContainers: []corev1.Container{
{
Name: "storage-initializer",
Image: "kserve/storage-initializer:latest",
Resources: corev1.ResourceRequirements{
Requests: corev1.ResourceList{
corev1.ResourceStorage: resource.MustParse("1Mi"),
},
},
},
},
Containers: []corev1.Container{
{
Name: "kserve-container",
Image: "base:0.1",
Resources: corev1.ResourceRequirements{
Requests: corev1.ResourceList{
corev1.ResourceCPU: resource.MustParse("1"),
},
},
},
},
Tolerations: []corev1.Toleration{
{Key: "team", Operator: corev1.TolerationOpEqual, Value: "a"},
},
},
},
},
// Override configuration
{
WorkloadSpec: v1alpha2.WorkloadSpec{
Replicas: ptr.To[int32](2),
Template: &corev1.PodSpec{
InitContainers: []corev1.Container{
{
Name: "storage-initializer",
Resources: corev1.ResourceRequirements{
Requests: corev1.ResourceList{
corev1.ResourceStorage: resource.MustParse("1Gi"),
},
},
},
},
Containers: []corev1.Container{
// This container should replace the base one due to the same name
{
Name: "kserve-container",
Image: "override:1.0",
Env: []corev1.EnvVar{
{Name: "FOO", Value: "bar"},
},
Resources: corev1.ResourceRequirements{
Requests: corev1.ResourceList{
corev1.ResourceCPU: resource.MustParse("2"), // Override CPU
},
},
},
// This is a new container that should be added
{
Name: "transformer",
Image: "transformer:latest",
},
},
// Tolerations should be REPLACED, not merged, as there is no patchMergeKey
Tolerations: []corev1.Toleration{
{Key: "gpu", Operator: corev1.TolerationOpExists},
},
PriorityClassName: "high-priority", // Add a new field
},
},
},
},
// Expected result of the merge
want: v1alpha2.LLMInferenceServiceSpec{
WorkloadSpec: v1alpha2.WorkloadSpec{
Replicas: ptr.To[int32](2),
Template: &corev1.PodSpec{
InitContainers: []corev1.Container{
{
Name: "storage-initializer",
Image: "kserve/storage-initializer:latest", // Image is preserved from base
Resources: corev1.ResourceRequirements{ // Resources are updated from override
Requests: corev1.ResourceList{
corev1.ResourceStorage: resource.MustParse("1Gi"),
},
},
},
},
Containers: []corev1.Container{
{
Name: "kserve-container",
Image: "override:1.0",
Env: []corev1.EnvVar{
{Name: "FOO", Value: "bar"},
},
Resources: corev1.ResourceRequirements{
Requests: corev1.ResourceList{
corev1.ResourceCPU: resource.MustParse("2"),
},
},
},
{
Name: "transformer",
Image: "transformer:latest",
},
},
// Tolerations slice is replaced by the override
Tolerations: []corev1.Toleration{
{Key: "gpu", Operator: corev1.TolerationOpExists},
},
PriorityClassName: "high-priority",
},
},
},
wantErr: false,
},
{
name: "merge with prefill spec",
cfgs: []v1alpha2.LLMInferenceServiceSpec{
// Base has only a decode workload
{
WorkloadSpec: v1alpha2.WorkloadSpec{
Replicas: ptr.To[int32](1),
Template: &corev1.PodSpec{Containers: []corev1.Container{{Name: "kserve-container", Image: "decode:0.1"}}},
},
},
// Override adds a prefill workload
{
Prefill: &v1alpha2.WorkloadSpec{
Replicas: ptr.To[int32](4),
Template: &corev1.PodSpec{Containers: []corev1.Container{{Name: "kserve-container", Image: "prefill:0.1"}}},
},
},
},
want: v1alpha2.LLMInferenceServiceSpec{
// Base workload spec is preserved
WorkloadSpec: v1alpha2.WorkloadSpec{
Replicas: ptr.To[int32](1),
Template: &corev1.PodSpec{Containers: []corev1.Container{{Name: "kserve-container", Image: "decode:0.1"}}},
},
// Prefill spec is added
Prefill: &v1alpha2.WorkloadSpec{
Replicas: ptr.To[int32](4),
Template: &corev1.PodSpec{Containers: []corev1.Container{{Name: "kserve-container", Image: "prefill:0.1"}}},
},
},
},
{
name: "merge with worker spec",
cfgs: []v1alpha2.LLMInferenceServiceSpec{
// Base has the main head/decode template
{
WorkloadSpec: v1alpha2.WorkloadSpec{
Template: &corev1.PodSpec{Containers: []corev1.Container{{Name: "kserve-container", Image: "head:0.1"}}},
},
},
// Override adds a worker spec
{
WorkloadSpec: v1alpha2.WorkloadSpec{
Worker: &corev1.PodSpec{Containers: []corev1.Container{{Name: "kserve-container", Image: "worker:0.1"}}},
},
},
},
want: v1alpha2.LLMInferenceServiceSpec{
WorkloadSpec: v1alpha2.WorkloadSpec{
// Head template is preserved
Template: &corev1.PodSpec{Containers: []corev1.Container{{Name: "kserve-container", Image: "head:0.1"}}},
// Worker spec is added
Worker: &corev1.PodSpec{Containers: []corev1.Container{{Name: "kserve-container", Image: "worker:0.1"}}},
},
},
},
{
name: "merge with parallelism spec",
cfgs: []v1alpha2.LLMInferenceServiceSpec{
// Base defines tensor parallelism
{
WorkloadSpec: v1alpha2.WorkloadSpec{
Parallelism: &v1alpha2.ParallelismSpec{
Tensor: ptr.To[int32](2),
},
},
},
// Override defines pipeline parallelism
{
WorkloadSpec: v1alpha2.WorkloadSpec{
Parallelism: &v1alpha2.ParallelismSpec{
Pipeline: ptr.To[int32](4),
},
},
},
},
want: v1alpha2.LLMInferenceServiceSpec{
WorkloadSpec: v1alpha2.WorkloadSpec{
// Both parallelism values should be present
Parallelism: &v1alpha2.ParallelismSpec{
Tensor: ptr.To[int32](2),
Pipeline: ptr.To[int32](4),
},
},
},
},
{
name: "deep merge of prefill spec",
cfgs: []v1alpha2.LLMInferenceServiceSpec{
// Base defines a prefill workload with replicas and a container with a resource request
{
Prefill: &v1alpha2.WorkloadSpec{
Replicas: ptr.To[int32](2),
Template: &corev1.PodSpec{
Containers: []corev1.Container{
{
Name: "prefill-container",
Image: "prefill:0.1",
Resources: corev1.ResourceRequirements{
Requests: corev1.ResourceList{corev1.ResourceCPU: resource.MustParse("1")},
},
},
},
},
},
},
// Override changes replica count and adds an environment variable to the container
{
Prefill: &v1alpha2.WorkloadSpec{
Replicas: ptr.To[int32](4),
Template: &corev1.PodSpec{
Containers: []corev1.Container{
{
Name: "prefill-container",
Env: []corev1.EnvVar{
{Name: "PREFILL_MODE", Value: "FAST"},
},
},
},
},
},
},
},
want: v1alpha2.LLMInferenceServiceSpec{
Prefill: &v1alpha2.WorkloadSpec{
Replicas: ptr.To[int32](4), // Replicas are overridden
Template: &corev1.PodSpec{
Containers: []corev1.Container{
{
Name: "prefill-container",
Image: "prefill:0.1", // Image is preserved from base
Env: []corev1.EnvVar{ // Env var is added from override
{Name: "PREFILL_MODE", Value: "FAST"},
},
Resources: corev1.ResourceRequirements{ // Resources are preserved from base
Requests: corev1.ResourceList{corev1.ResourceCPU: resource.MustParse("1")},
},
},
},
},
},
},
},
{
name: "4 chained merge router, epp, multi node",
cfgs: []v1alpha2.LLMInferenceServiceSpec{
{
Router: &v1alpha2.RouterSpec{
Route: &v1alpha2.GatewayRoutesSpec{},
Gateway: &v1alpha2.GatewaySpec{},
},
},
{
WorkloadSpec: v1alpha2.WorkloadSpec{
Parallelism: &v1alpha2.ParallelismSpec{
Tensor: ptr.To[int32](1),
Pipeline: ptr.To[int32](1),
},
Worker: &corev1.PodSpec{
Containers: []corev1.Container{
{
Name: "main",
Resources: corev1.ResourceRequirements{
Requests: corev1.ResourceList{
corev1.ResourceCPU: resource.MustParse("1"),
"nvidia.com/gpu": resource.MustParse("1"),
},
},
},
},
},
},
},
{
Router: &v1alpha2.RouterSpec{
Scheduler: &v1alpha2.SchedulerSpec{
Pool: &v1alpha2.InferencePoolSpec{
Spec: &igwapi.InferencePoolSpec{
TargetPorts: nil,
EndpointPickerRef: igwapi.EndpointPickerRef{
FailureMode: igwapi.EndpointPickerFailClose,
},
},
},
Template: &corev1.PodSpec{
Containers: []corev1.Container{
{
Name: "main",
},
},
},
},
},
},
{
WorkloadSpec: v1alpha2.WorkloadSpec{
Parallelism: &v1alpha2.ParallelismSpec{
Tensor: ptr.To[int32](4),
Pipeline: ptr.To[int32](2),
},
Worker: &corev1.PodSpec{
Containers: []corev1.Container{
{
Name: "main",
Resources: corev1.ResourceRequirements{
Requests: corev1.ResourceList{
corev1.ResourceCPU: resource.MustParse("1"),
"nvidia.com/gpu": resource.MustParse("4"),
},
},
},
},
},
},
},
},
want: v1alpha2.LLMInferenceServiceSpec{
Router: &v1alpha2.RouterSpec{
Route: &v1alpha2.GatewayRoutesSpec{},
Gateway: &v1alpha2.GatewaySpec{},
Scheduler: &v1alpha2.SchedulerSpec{
Pool: &v1alpha2.InferencePoolSpec{
Spec: &igwapi.InferencePoolSpec{
TargetPorts: nil,
EndpointPickerRef: igwapi.EndpointPickerRef{
FailureMode: igwapi.EndpointPickerFailClose,
},
},
},
Template: &corev1.PodSpec{
Containers: []corev1.Container{
{
Name: "main",
},
},
},
},
},
WorkloadSpec: v1alpha2.WorkloadSpec{
Parallelism: &v1alpha2.ParallelismSpec{
Tensor: ptr.To[int32](4),
Pipeline: ptr.To[int32](2),
},
Worker: &corev1.PodSpec{
Containers: []corev1.Container{
{
Name: "main",
Resources: corev1.ResourceRequirements{
Requests: corev1.ResourceList{
corev1.ResourceCPU: resource.MustParse("1"),
"nvidia.com/gpu": resource.MustParse("4"),
},
},
},
},
},
},
},
},
{
name: "4 chained merge router with scheduler, http route and gateway ref, multi node",
cfgs: []v1alpha2.LLMInferenceServiceSpec{
{
Router: &v1alpha2.RouterSpec{
Route: &v1alpha2.GatewayRoutesSpec{
HTTP: &v1alpha2.HTTPRouteSpec{
Spec: &gwapiv1.HTTPRouteSpec{
CommonRouteSpec: gwapiv1.CommonRouteSpec{
ParentRefs: []gwapiv1.ParentReference{
{
Name: "my-parent",
},
},
},
Hostnames: nil,
Rules: nil,
},
Refs: []corev1.LocalObjectReference{{Name: "my-route"}},
},
},
Gateway: &v1alpha2.GatewaySpec{
Refs: []v1alpha2.GatewayObjectReference{{UntypedObjectReference: v1alpha2.UntypedObjectReference{Name: "my-gateway"}}},
},
},
},
{
Router: &v1alpha2.RouterSpec{
Route: &v1alpha2.GatewayRoutesSpec{
HTTP: &v1alpha2.HTTPRouteSpec{
Refs: []corev1.LocalObjectReference{{Name: "my-second-route"}},
},
},
Gateway: &v1alpha2.GatewaySpec{
Refs: []v1alpha2.GatewayObjectReference{{UntypedObjectReference: v1alpha2.UntypedObjectReference{Name: "my-second-gateway"}}},
},
},
},
{
WorkloadSpec: v1alpha2.WorkloadSpec{
Parallelism: &v1alpha2.ParallelismSpec{
Tensor: ptr.To[int32](1),
Pipeline: ptr.To[int32](1),
},
Worker: &corev1.PodSpec{
Containers: []corev1.Container{
{
Name: "main",
Resources: corev1.ResourceRequirements{
Requests: corev1.ResourceList{
corev1.ResourceCPU: resource.MustParse("1"),
"nvidia.com/gpu": resource.MustParse("1"),
},
},
},
},
},
},
},
{
Router: &v1alpha2.RouterSpec{
Scheduler: &v1alpha2.SchedulerSpec{
Pool: &v1alpha2.InferencePoolSpec{
Ref: &corev1.LocalObjectReference{
Name: "my-pool",
},
},
},
},
},
{
WorkloadSpec: v1alpha2.WorkloadSpec{
Parallelism: &v1alpha2.ParallelismSpec{
Tensor: ptr.To[int32](4),
Pipeline: ptr.To[int32](2),
},
Worker: &corev1.PodSpec{
Containers: []corev1.Container{
{
Name: "main",
Resources: corev1.ResourceRequirements{
Requests: corev1.ResourceList{
corev1.ResourceCPU: resource.MustParse("1"),
"nvidia.com/gpu": resource.MustParse("4"),
},
},
},
},
},
},
},
},
want: v1alpha2.LLMInferenceServiceSpec{
Router: &v1alpha2.RouterSpec{
Route: &v1alpha2.GatewayRoutesSpec{
HTTP: &v1alpha2.HTTPRouteSpec{
Spec: &gwapiv1.HTTPRouteSpec{
CommonRouteSpec: gwapiv1.CommonRouteSpec{
ParentRefs: []gwapiv1.ParentReference{
{
Name: "my-parent",
},
},
},
Hostnames: nil,
Rules: nil,
},
Refs: []corev1.LocalObjectReference{{Name: "my-second-route"}},
},
},
Gateway: &v1alpha2.GatewaySpec{
Refs: []v1alpha2.GatewayObjectReference{{UntypedObjectReference: v1alpha2.UntypedObjectReference{Name: "my-second-gateway"}}},
},
Scheduler: &v1alpha2.SchedulerSpec{
Pool: &v1alpha2.InferencePoolSpec{
Ref: &corev1.LocalObjectReference{
Name: "my-pool",
},
},
},
},
WorkloadSpec: v1alpha2.WorkloadSpec{
Parallelism: &v1alpha2.ParallelismSpec{
Tensor: ptr.To[int32](4),
Pipeline: ptr.To[int32](2),
},
Worker: &corev1.PodSpec{
Containers: []corev1.Container{
{
Name: "main",
Resources: corev1.ResourceRequirements{
Requests: corev1.ResourceList{
corev1.ResourceCPU: resource.MustParse("1"),
"nvidia.com/gpu": resource.MustParse("4"),
},
},
},
},
},
},
},
},
{
name: "merge requests and limits",
cfgs: []v1alpha2.LLMInferenceServiceSpec{
{
Router: &v1alpha2.RouterSpec{
Route: &v1alpha2.GatewayRoutesSpec{},
Gateway: &v1alpha2.GatewaySpec{},
},
},
{
WorkloadSpec: v1alpha2.WorkloadSpec{
Parallelism: &v1alpha2.ParallelismSpec{
Tensor: ptr.To[int32](1),
Pipeline: ptr.To[int32](1),
},
Worker: &corev1.PodSpec{
Containers: []corev1.Container{
{
Name: "main",
Resources: corev1.ResourceRequirements{
Requests: corev1.ResourceList{
corev1.ResourceCPU: resource.MustParse("1"),
"nvidia.com/gpu": resource.MustParse("1"),
},
Limits: corev1.ResourceList{
"nvidia.com/gpu": resource.MustParse("1"),
},
},
Env: []corev1.EnvVar{
{Name: "a", Value: "1"},
{Name: "z", Value: "42"},
},
Args: []string{
"a", "b",
},
},
},
},
},
},
{
WorkloadSpec: v1alpha2.WorkloadSpec{
Worker: &corev1.PodSpec{
Containers: []corev1.Container{
{
Name: "main",
Resources: corev1.ResourceRequirements{
Requests: corev1.ResourceList{
corev1.ResourceMemory: resource.MustParse("1Gi"),
},
Limits: corev1.ResourceList{
corev1.ResourceCPU: resource.MustParse("2"),
},
},
Env: []corev1.EnvVar{
{Name: "b", Value: "2"},
{Name: "z", Value: ""},
},
Args: []string{
"x", "y",
},
},
},
},
},
},
},
want: v1alpha2.LLMInferenceServiceSpec{
Router: &v1alpha2.RouterSpec{
Route: &v1alpha2.GatewayRoutesSpec{},
Gateway: &v1alpha2.GatewaySpec{},
},
WorkloadSpec: v1alpha2.WorkloadSpec{
Parallelism: &v1alpha2.ParallelismSpec{
Tensor: ptr.To[int32](1),
Pipeline: ptr.To[int32](1),
},
Worker: &corev1.PodSpec{
Containers: []corev1.Container{
{
Name: "main",
Resources: corev1.ResourceRequirements{
Requests: corev1.ResourceList{
corev1.ResourceMemory: resource.MustParse("1Gi"),
corev1.ResourceCPU: resource.MustParse("1"),
"nvidia.com/gpu": resource.MustParse("1"),
},
Limits: corev1.ResourceList{
corev1.ResourceCPU: resource.MustParse("2"),
"nvidia.com/gpu": resource.MustParse("1"),
},
},
Env: []corev1.EnvVar{
{Name: "b", Value: "2"},
{Name: "a", Value: "1"},
{Name: "z", Value: "42"},
},
Args: []string{
"x", "y",
},
},
},
},
},
},
},
{
name: "merge LoRA adapters",
cfgs: []v1alpha2.LLMInferenceServiceSpec{
{
Model: v1alpha2.LLMModelSpec{
URI: apis.URL{Path: "base-model"},
LoRA: &v1alpha2.LoRASpec{
Adapters: []v1alpha2.LLMModelSpec{
{URI: apis.URL{Path: "lora-model"}},
},
},
},
},
{
Model: v1alpha2.LLMModelSpec{
LoRA: &v1alpha2.LoRASpec{
Adapters: []v1alpha2.LLMModelSpec{
{URI: apis.URL{Path: "lora-model2"}},
},
},
},
},
},
want: v1alpha2.LLMInferenceServiceSpec{
Model: v1alpha2.LLMModelSpec{
URI: apis.URL{Path: "base-model"},
LoRA: &v1alpha2.LoRASpec{
Adapters: []v1alpha2.LLMModelSpec{
{URI: apis.URL{Path: "lora-model2"}},
},
},
},
},
},
{
name: "merge model URI",
cfgs: []v1alpha2.LLMInferenceServiceSpec{
{
Model: v1alpha2.LLMModelSpec{
URI: apis.URL{Scheme: "hf", Host: "hub.com", Path: "/model-a"},
},
},
{
Model: v1alpha2.LLMModelSpec{
URI: apis.URL{Scheme: "s3", Host: "bucket.com", Path: "/model-b"},
},
},
},
want: v1alpha2.LLMInferenceServiceSpec{
Model: v1alpha2.LLMModelSpec{
URI: apis.URL{Scheme: "s3", Host: "bucket.com", Path: "/model-b"},
},
},
},
{
name: "merge baseRefs",
cfgs: []v1alpha2.LLMInferenceServiceSpec{
{
BaseRefs: []corev1.LocalObjectReference{
{Name: "base-config-1"},
{Name: "base-config-2"},
},
},
{
BaseRefs: []corev1.LocalObjectReference{
{Name: "override-config-1"},
},
},
},
want: v1alpha2.LLMInferenceServiceSpec{
BaseRefs: []corev1.LocalObjectReference{
{Name: "override-config-1"},
},
},
},
{
name: "merge ingress spec",
cfgs: []v1alpha2.LLMInferenceServiceSpec{
{
Router: &v1alpha2.RouterSpec{
Ingress: &v1alpha2.IngressSpec{
Refs: []v1alpha2.UntypedObjectReference{
{Name: "base-ingress", Namespace: "base-ns"},
},
},
},
},
{
Router: &v1alpha2.RouterSpec{
Ingress: &v1alpha2.IngressSpec{
Refs: []v1alpha2.UntypedObjectReference{
{Name: "override-ingress", Namespace: "override-ns"},
},
},
},
},
},
want: v1alpha2.LLMInferenceServiceSpec{
Router: &v1alpha2.RouterSpec{
Ingress: &v1alpha2.IngressSpec{
Refs: []v1alpha2.UntypedObjectReference{
{Name: "override-ingress", Namespace: "override-ns"},
},
},
},
},
},
{
name: "merge with nil pointer handling",
cfgs: []v1alpha2.LLMInferenceServiceSpec{
{
Model: v1alpha2.LLMModelSpec{
URI: apis.URL{Path: "model-uri"},
Name: ptr.To("base-name"),
},
WorkloadSpec: v1alpha2.WorkloadSpec{
Replicas: ptr.To[int32](1),
},
},
{
Model: v1alpha2.LLMModelSpec{
Name: nil, // nil pointer should not override non-nil base
},
WorkloadSpec: v1alpha2.WorkloadSpec{
Replicas: ptr.To[int32](3),
},
},
},
want: v1alpha2.LLMInferenceServiceSpec{
Model: v1alpha2.LLMModelSpec{
URI: apis.URL{Path: "model-uri"},
Name: ptr.To("base-name"), // Base value should be preserved
},
WorkloadSpec: v1alpha2.WorkloadSpec{
Replicas: ptr.To[int32](3),
},
},
},
{
name: "merge complex nested structures",
cfgs: []v1alpha2.LLMInferenceServiceSpec{
{
Model: v1alpha2.LLMModelSpec{
URI: apis.URL{Path: "base-model"},
Name: ptr.To("base-name"),
// Criticality: ptr.To(igwapi.Sheddable),
LoRA: &v1alpha2.LoRASpec{
Adapters: []v1alpha2.LLMModelSpec{
{URI: apis.URL{Path: "lora-model"}},
},
},
},
WorkloadSpec: v1alpha2.WorkloadSpec{
Replicas: ptr.To[int32](1),
Parallelism: &v1alpha2.ParallelismSpec{
Tensor: ptr.To[int32](2),
},
},
Router: &v1alpha2.RouterSpec{
Gateway: &v1alpha2.GatewaySpec{
Refs: []v1alpha2.GatewayObjectReference{{UntypedObjectReference: v1alpha2.UntypedObjectReference{Name: "base-gw"}}},
},
},
},
{
Model: v1alpha2.LLMModelSpec{
Name: ptr.To("override-name"),
// Criticality: ptr.To(igwapi.Critical),
LoRA: &v1alpha2.LoRASpec{
Adapters: []v1alpha2.LLMModelSpec{
{URI: apis.URL{Path: "lora-model2"}},
},
},
},
WorkloadSpec: v1alpha2.WorkloadSpec{
Replicas: ptr.To[int32](5),
Parallelism: &v1alpha2.ParallelismSpec{
Pipeline: ptr.To[int32](4),
},
},
Router: &v1alpha2.RouterSpec{
Route: &v1alpha2.GatewayRoutesSpec{
HTTP: &v1alpha2.HTTPRouteSpec{
Refs: []corev1.LocalObjectReference{{Name: "override-route"}},
},
},
},
},
},
want: v1alpha2.LLMInferenceServiceSpec{
Model: v1alpha2.LLMModelSpec{
URI: apis.URL{Path: "base-model"}, // Base URI preserved
Name: ptr.To("override-name"), // Override name
// Criticality: ptr.To(igwapi.Critical),
LoRA: &v1alpha2.LoRASpec{
Adapters: []v1alpha2.LLMModelSpec{
{URI: apis.URL{Path: "lora-model2"}},
},
},
},
WorkloadSpec: v1alpha2.WorkloadSpec{
Replicas: ptr.To[int32](5),
Parallelism: &v1alpha2.ParallelismSpec{
Tensor: ptr.To[int32](2), // Base tensor preserved
Pipeline: ptr.To[int32](4), // Override pipeline
},
},
Router: &v1alpha2.RouterSpec{
Gateway: &v1alpha2.GatewaySpec{
Refs: []v1alpha2.GatewayObjectReference{{UntypedObjectReference: v1alpha2.UntypedObjectReference{Name: "base-gw"}}},
},
Route: &v1alpha2.GatewayRoutesSpec{
HTTP: &v1alpha2.HTTPRouteSpec{
Refs: []corev1.LocalObjectReference{{Name: "override-route"}},
},
},
},
},
},
{
name: "merge empty structures",
cfgs: []v1alpha2.LLMInferenceServiceSpec{
{
Model: v1alpha2.LLMModelSpec{},
},
{
Model: v1alpha2.LLMModelSpec{
URI: apis.URL{Path: "populated-model"},
},
},
},
want: v1alpha2.LLMInferenceServiceSpec{
Model: v1alpha2.LLMModelSpec{
URI: apis.URL{Path: "populated-model"},
},
},
},
{
name: "merge with zero values vs nil pointers",
cfgs: []v1alpha2.LLMInferenceServiceSpec{
{
WorkloadSpec: v1alpha2.WorkloadSpec{
Replicas: ptr.To[int32](0), // Zero value, but non-nil pointer
},
},
{
WorkloadSpec: v1alpha2.WorkloadSpec{
Replicas: nil, // Nil pointer should not override zero value
},
},
},
want: v1alpha2.LLMInferenceServiceSpec{
WorkloadSpec: v1alpha2.WorkloadSpec{
Replicas: ptr.To[int32](0), // Zero value should be preserved
},
},
},
{
name: "merge pod spec with nil containers",
cfgs: []v1alpha2.LLMInferenceServiceSpec{
{
WorkloadSpec: v1alpha2.WorkloadSpec{
Template: &corev1.PodSpec{
Containers: []corev1.Container{
{
Image: "busybox",
Name: "busybox",
},
},
InitContainers: []corev1.Container{
{
Image: "busybox-init",
Name: "busybox-init",
},
},
Volumes: []corev1.Volume{
{
Name: "vol1",
},
},
},
Replicas: ptr.To[int32](1),
},
},
{
WorkloadSpec: v1alpha2.WorkloadSpec{
Template: &corev1.PodSpec{},
Replicas: nil,
},
},
},
want: v1alpha2.LLMInferenceServiceSpec{
WorkloadSpec: v1alpha2.WorkloadSpec{
Template: &corev1.PodSpec{
Containers: []corev1.Container{
{
Image: "busybox",
Name: "busybox",
},
},
InitContainers: []corev1.Container{
{
Image: "busybox-init",
Name: "busybox-init",
},
},
Volumes: []corev1.Volume{
{
Name: "vol1",
},
},
},
Replicas: ptr.To[int32](1),
},
},
},
{
name: "merge pod spec with empty containers",
cfgs: []v1alpha2.LLMInferenceServiceSpec{
{
WorkloadSpec: v1alpha2.WorkloadSpec{
Template: &corev1.PodSpec{
Containers: []corev1.Container{
{
Image: "busybox",
Name: "busybox",
},
},
InitContainers: []corev1.Container{
{
Image: "busybox-init",
Name: "busybox-init",
},
},
Volumes: []corev1.Volume{
{
Name: "vol1",
},
},
},
Replicas: ptr.To[int32](1),
},
},
{
WorkloadSpec: v1alpha2.WorkloadSpec{
Template: &corev1.PodSpec{
Containers: []corev1.Container{},
},
Replicas: nil,
},
},
},
want: v1alpha2.LLMInferenceServiceSpec{
WorkloadSpec: v1alpha2.WorkloadSpec{
Template: &corev1.PodSpec{
Containers: []corev1.Container{
{
Image: "busybox",
Name: "busybox",
},
},
InitContainers: []corev1.Container{
{
Image: "busybox-init",
Name: "busybox-init",
},
},
Volumes: []corev1.Volume{
{
Name: "vol1",
},
},
},
Replicas: ptr.To[int32](1),
},
},
},
{
name: "merge pod spec, add container",
cfgs: []v1alpha2.LLMInferenceServiceSpec{
{
WorkloadSpec: v1alpha2.WorkloadSpec{
Template: &corev1.PodSpec{
Containers: []corev1.Container{
{
Image: "busybox",
Name: "busybox",
},
},
InitContainers: []corev1.Container{
{
Image: "busybox-sidecar",
Name: "busybox-sidecar",
},
},
},
Replicas: ptr.To[int32](2),
},
},
{
WorkloadSpec: v1alpha2.WorkloadSpec{
Template: &corev1.PodSpec{
Containers: []corev1.Container{
{
Image: "busybox-2",
Name: "busybox-2",
},
},
InitContainers: []corev1.Container{},
},
Replicas: nil,
},
},
},
want: v1alpha2.LLMInferenceServiceSpec{
WorkloadSpec: v1alpha2.WorkloadSpec{
Template: &corev1.PodSpec{
Containers: []corev1.Container{
{
Image: "busybox-2",
Name: "busybox-2",
},
{
Image: "busybox",
Name: "busybox",
},
},
InitContainers: []corev1.Container{
{
Image: "busybox-sidecar",
Name: "busybox-sidecar",
},
},
},
Replicas: ptr.To[int32](2),
},
},
},
{
name: "merge pod spec, add container",
cfgs: []v1alpha2.LLMInferenceServiceSpec{
{
WorkloadSpec: v1alpha2.WorkloadSpec{
Template: &corev1.PodSpec{
Containers: []corev1.Container{
{
Image: "busybox",
Name: "busybox",
},
},
InitContainers: []corev1.Container{
{
Image: "busybox-sidecar",
Name: "busybox-sidecar",
},
},
},
Replicas: ptr.To[int32](2),
},
},
{
WorkloadSpec: v1alpha2.WorkloadSpec{
Template: &corev1.PodSpec{
Containers: []corev1.Container{
{
Image: "busybox-2",
Name: "busybox-2",
},
},
InitContainers: []corev1.Container{},
},
Replicas: nil,
},
},
{},
},
want: v1alpha2.LLMInferenceServiceSpec{
WorkloadSpec: v1alpha2.WorkloadSpec{
Template: &corev1.PodSpec{
Containers: []corev1.Container{
{
Image: "busybox-2",
Name: "busybox-2",
},
{
Image: "busybox",
Name: "busybox",
},
},
InitContainers: []corev1.Container{
{
Image: "busybox-sidecar",
Name: "busybox-sidecar",
},
},
},
Replicas: ptr.To[int32](2),
},
},
},
{
name: "custom pool selector overrides base empty selector",
cfgs: []v1alpha2.LLMInferenceServiceSpec{
// Base config with pool spec but no selector
{
Router: &v1alpha2.RouterSpec{
Route: &v1alpha2.GatewayRoutesSpec{},
Gateway: &v1alpha2.GatewaySpec{},
Scheduler: &v1alpha2.SchedulerSpec{
Pool: &v1alpha2.InferencePoolSpec{
Spec: &igwapi.InferencePoolSpec{
TargetPorts: []igwapi.Port{{Number: 8000}},
},
},
Template: &corev1.PodSpec{
Containers: []corev1.Container{{Name: "main"}},
},
},
},
},
// Override with custom selector for multi-GPU-vendor pooling
{
Router: &v1alpha2.RouterSpec{
Scheduler: &v1alpha2.SchedulerSpec{
Pool: &v1alpha2.InferencePoolSpec{
Spec: &igwapi.InferencePoolSpec{
Selector: igwapi.LabelSelector{
MatchLabels: map[igwapi.LabelKey]igwapi.LabelValue{
"llm-pool": "qwen2-7b",
"kserve.io/component": "workload",
},
},
TargetPorts: []igwapi.Port{{Number: 8000}},
},
},
},
},
},
},
want: v1alpha2.LLMInferenceServiceSpec{
Router: &v1alpha2.RouterSpec{
Route: &v1alpha2.GatewayRoutesSpec{},
Gateway: &v1alpha2.GatewaySpec{},
Scheduler: &v1alpha2.SchedulerSpec{
Pool: &v1alpha2.InferencePoolSpec{
Spec: &igwapi.InferencePoolSpec{
Selector: igwapi.LabelSelector{
MatchLabels: map[igwapi.LabelKey]igwapi.LabelValue{
"llm-pool": "qwen2-7b",
"kserve.io/component": "workload",
},
},
TargetPorts: []igwapi.Port{{Number: 8000}},
},
},
Template: &corev1.PodSpec{
Containers: []corev1.Container{{Name: "main"}},
},
},
},
},
},
{
name: "custom pool selector merge base selector",
cfgs: []v1alpha2.LLMInferenceServiceSpec{
// Base config with pool spec but no selector
{
Router: &v1alpha2.RouterSpec{
Route: &v1alpha2.GatewayRoutesSpec{},
Gateway: &v1alpha2.GatewaySpec{},
Scheduler: &v1alpha2.SchedulerSpec{
Pool: &v1alpha2.InferencePoolSpec{
Spec: &igwapi.InferencePoolSpec{
Selector: igwapi.LabelSelector{
MatchLabels: map[igwapi.LabelKey]igwapi.LabelValue{
"x": "x",
},
},
TargetPorts: []igwapi.Port{{Number: 8000}},
},
},
Template: &corev1.PodSpec{
Containers: []corev1.Container{{Name: "main"}},
},
},
},
},
// Override with custom selector for multi-GPU-vendor pooling
{
Router: &v1alpha2.RouterSpec{
Scheduler: &v1alpha2.SchedulerSpec{
Pool: &v1alpha2.InferencePoolSpec{
Spec: &igwapi.InferencePoolSpec{
Selector: igwapi.LabelSelector{
MatchLabels: map[igwapi.LabelKey]igwapi.LabelValue{
"llm-pool": "qwen2-7b",
"kserve.io/component": "workload",
},
},
TargetPorts: []igwapi.Port{{Number: 8000}},
},
},
},
},
},
},
want: v1alpha2.LLMInferenceServiceSpec{
Router: &v1alpha2.RouterSpec{
Route: &v1alpha2.GatewayRoutesSpec{},
Gateway: &v1alpha2.GatewaySpec{},
Scheduler: &v1alpha2.SchedulerSpec{
Pool: &v1alpha2.InferencePoolSpec{
Spec: &igwapi.InferencePoolSpec{
Selector: igwapi.LabelSelector{
MatchLabels: map[igwapi.LabelKey]igwapi.LabelValue{
"llm-pool": "qwen2-7b",
"kserve.io/component": "workload",
"x": "x",
},
},
TargetPorts: []igwapi.Port{{Number: 8000}},
},
},
Template: &corev1.PodSpec{
Containers: []corev1.Container{{Name: "main"}},
},
},
},
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
ctx := t.Context()
ctx = log.IntoContext(ctx, pkgtest.NewTestLogger(t))
got, err := llmisvc.MergeSpecs(ctx, tt.cfgs...)
if (err != nil) != tt.wantErr {
t.Errorf("MergeSpecs() error = %v, wantErr %v", err, tt.wantErr)
return
}
if diff := cmp.Diff(tt.want, got); diff != "" {
t.Errorf("MergeSpecs() got = \n%#v\n, want \n%#v\nDiff (-want, +got):\n%s", got, tt.want, diff)
}
})
}
}
func TestGetWorkloadLabelSelectorUsedAsDefaultPoolSelector(t *testing.T) {
// Verify that GetWorkloadLabelSelector returns the labels used as default
// InferencePool selector in combineBaseRefsConfig (config_merge.go:232-244).
// When a user provides custom MatchLabels (e.g., for multi-GPU-vendor pooling),
// the default selector is not applied because MatchLabels is non-empty.
meta := metav1.ObjectMeta{Name: "test-svc", Namespace: "default"}
selector := llmisvc.GetWorkloadLabelSelector(meta, nil)
g := NewWithT(t)
g.Expect(selector).To(HaveKeyWithValue("app.kubernetes.io/name", "test-svc"))
g.Expect(selector).To(HaveKeyWithValue("app.kubernetes.io/part-of", "llminferenceservice"))
g.Expect(selector).To(HaveKeyWithValue("kserve.io/component", "workload"))
g.Expect(selector).To(HaveLen(3))
}
func TestReplaceVariables(t *testing.T) {
tests := []struct {
name string
llmSvc *v1alpha2.LLMInferenceService
cfg *v1alpha2.LLMInferenceServiceConfig
extra *llmisvc.Config
want *v1alpha2.LLMInferenceServiceConfig
wantErr bool
}{
{
name: "Replace model name",
cfg: &v1alpha2.LLMInferenceServiceConfig{
Spec: v1alpha2.LLMInferenceServiceSpec{
Model: v1alpha2.LLMModelSpec{
Name: ptr.To("{{ .Spec.Model.Name }}"),
},
WorkloadSpec: v1alpha2.WorkloadSpec{
Template: &corev1.PodSpec{
Containers: []corev1.Container{
{Args: []string{
"--served-model-name",
"{{ .Spec.Model.Name }}",
}},
},
},
},
},
},
llmSvc: &v1alpha2.LLMInferenceService{
Spec: v1alpha2.LLMInferenceServiceSpec{
Model: v1alpha2.LLMModelSpec{
Name: ptr.To("meta-llama/Llama-3.2-3B-Instruct"),
},
},
},
want: &v1alpha2.LLMInferenceServiceConfig{
Spec: v1alpha2.LLMInferenceServiceSpec{
Model: v1alpha2.LLMModelSpec{
Name: ptr.To("meta-llama/Llama-3.2-3B-Instruct"),
},
WorkloadSpec: v1alpha2.WorkloadSpec{
Template: &corev1.PodSpec{
Containers: []corev1.Container{
{Args: []string{
"--served-model-name",
"meta-llama/Llama-3.2-3B-Instruct",
}},
},
},
},
},
},
},
{
name: "template with ChildName function",
cfg: &v1alpha2.LLMInferenceServiceConfig{
Spec: v1alpha2.LLMInferenceServiceSpec{
WorkloadSpec: v1alpha2.WorkloadSpec{
Template: &corev1.PodSpec{
ServiceAccountName: "{{ ChildName .Name `-sa` }}",
Containers: []corev1.Container{
{
Name: "main",
Env: []corev1.EnvVar{
{Name: "DEPLOYMENT_NAME", Value: "{{ ChildName .Name `-deployment` }}"},
},
},
},
},
},
},
},
llmSvc: &v1alpha2.LLMInferenceService{
ObjectMeta: metav1.ObjectMeta{
Name: "test-llm",
Namespace: "test-ns",
},
},
want: &v1alpha2.LLMInferenceServiceConfig{
Spec: v1alpha2.LLMInferenceServiceSpec{
WorkloadSpec: v1alpha2.WorkloadSpec{
Template: &corev1.PodSpec{
ServiceAccountName: "test-llm-sa",
Containers: []corev1.Container{
{
Name: "main",
Env: []corev1.EnvVar{
{Name: "DEPLOYMENT_NAME", Value: "test-llm-deployment"},
},
},
},
},
},
},
},
},
{
name: "template in arrays",
cfg: &v1alpha2.LLMInferenceServiceConfig{
Spec: v1alpha2.LLMInferenceServiceSpec{
WorkloadSpec: v1alpha2.WorkloadSpec{
Template: &corev1.PodSpec{
Containers: []corev1.Container{
{
Name: "main",
Args: []string{
"--model-name={{ .Name }}",
"--namespace={{ .Namespace }}",
"--config-path=/config/{{ .Name }}.yaml",
},
Env: []corev1.EnvVar{
{Name: "MODEL_NAME", Value: "{{ .Name }}"},
{Name: "NAMESPACE", Value: "{{ .Namespace }}"},
},
},
},
},
},
},
},
llmSvc: &v1alpha2.LLMInferenceService{
ObjectMeta: metav1.ObjectMeta{
Name: "gpt-model",
Namespace: "ml-team",
},
},
want: &v1alpha2.LLMInferenceServiceConfig{
Spec: v1alpha2.LLMInferenceServiceSpec{
WorkloadSpec: v1alpha2.WorkloadSpec{
Template: &corev1.PodSpec{
Containers: []corev1.Container{
{
Name: "main",
Args: []string{
"--model-name=gpt-model",
"--namespace=ml-team",
"--config-path=/config/gpt-model.yaml",
},
Env: []corev1.EnvVar{
{Name: "MODEL_NAME", Value: "gpt-model"},
{Name: "NAMESPACE", Value: "ml-team"},
},
},
},
},
},
},
},
},
{
name: "template with complex nested model spec",
cfg: &v1alpha2.LLMInferenceServiceConfig{
Spec: v1alpha2.LLMInferenceServiceSpec{
Model: v1alpha2.LLMModelSpec{
Name: ptr.To("{{ .Spec.Model.Name }}"),
},
WorkloadSpec: v1alpha2.WorkloadSpec{
Template: &corev1.PodSpec{
Containers: []corev1.Container{
{
Name: "main",
Args: []string{
"--served-model-name={{ .Spec.Model.Name }}",
"--model-path={{ .Spec.Model.URI.Host }}{{ .Spec.Model.URI.Path }}",
},
},
},
},
},
},
},
llmSvc: &v1alpha2.LLMInferenceService{
Spec: v1alpha2.LLMInferenceServiceSpec{
Model: v1alpha2.LLMModelSpec{
Name: ptr.To("meta-llama/Llama-3.2-3B-Instruct"),
URI: mustParseURL("hf://meta-llama/Llama-3.2-3B-Instruct"),
},
},
},
want: &v1alpha2.LLMInferenceServiceConfig{
Spec: v1alpha2.LLMInferenceServiceSpec{
Model: v1alpha2.LLMModelSpec{
Name: ptr.To("meta-llama/Llama-3.2-3B-Instruct"),
},
WorkloadSpec: v1alpha2.WorkloadSpec{
Template: &corev1.PodSpec{
Containers: []corev1.Container{
{
Name: "main",
Args: []string{
"--served-model-name=meta-llama/Llama-3.2-3B-Instruct",
"--model-path=meta-llama/Llama-3.2-3B-Instruct",
},
},
},
},
},
},
},
},
{
name: "template with nil pointer access should not error if default value is provided",
cfg: &v1alpha2.LLMInferenceServiceConfig{
Spec: v1alpha2.LLMInferenceServiceSpec{
Model: v1alpha2.LLMModelSpec{
Name: ptr.To(`{{ if .Spec.Model.Name }}{{ .Spec.Model.Name }}{{ else }}default-model{{ end }}`),
},
},
},
llmSvc: &v1alpha2.LLMInferenceService{
Spec: v1alpha2.LLMInferenceServiceSpec{
Model: v1alpha2.LLMModelSpec{
Name: nil, // Nil pointer
},
},
},
want: &v1alpha2.LLMInferenceServiceConfig{
Spec: v1alpha2.LLMInferenceServiceSpec{
Model: v1alpha2.LLMModelSpec{
Name: ptr.To("default-model"),
},
},
},
},
{
name: "template with router configurations",
cfg: &v1alpha2.LLMInferenceServiceConfig{
Spec: v1alpha2.LLMInferenceServiceSpec{
Router: &v1alpha2.RouterSpec{
Route: &v1alpha2.GatewayRoutesSpec{
HTTP: &v1alpha2.HTTPRouteSpec{
Refs: []corev1.LocalObjectReference{
{Name: "{{ .Name }}-route"},
},
},
},
Gateway: &v1alpha2.GatewaySpec{
Refs: []v1alpha2.GatewayObjectReference{
{UntypedObjectReference: v1alpha2.UntypedObjectReference{Name: "{{ .Name }}-gateway", Namespace: "{{ .Namespace }}"}},
},
},
},
},
},
llmSvc: &v1alpha2.LLMInferenceService{
ObjectMeta: metav1.ObjectMeta{
Name: "router-test",
Namespace: "routing-ns",
},
},
want: &v1alpha2.LLMInferenceServiceConfig{
Spec: v1alpha2.LLMInferenceServiceSpec{
Router: &v1alpha2.RouterSpec{
Route: &v1alpha2.GatewayRoutesSpec{
HTTP: &v1alpha2.HTTPRouteSpec{
Refs: []corev1.LocalObjectReference{
{Name: "router-test-route"},
},
},
},
Gateway: &v1alpha2.GatewaySpec{
Refs: []v1alpha2.GatewayObjectReference{
{UntypedObjectReference: v1alpha2.UntypedObjectReference{Name: "router-test-gateway", Namespace: "routing-ns"}},
},
},
},
},
},
},
{
name: "template with multiple variables in single string",
cfg: &v1alpha2.LLMInferenceServiceConfig{
Spec: v1alpha2.LLMInferenceServiceSpec{
WorkloadSpec: v1alpha2.WorkloadSpec{
Template: &corev1.PodSpec{
Containers: []corev1.Container{
{
Name: "main",
Env: []corev1.EnvVar{
{Name: "FULL_NAME", Value: "{{ .Namespace }}/{{ .Name }}"},
{Name: "CONFIG_PATH", Value: "/config/{{ .Namespace }}-{{ .Name }}.yaml"},
},
},
},
},
},
},
},
llmSvc: &v1alpha2.LLMInferenceService{
ObjectMeta: metav1.ObjectMeta{
Name: "multi-var",
Namespace: "test-ns",
},
},
want: &v1alpha2.LLMInferenceServiceConfig{
Spec: v1alpha2.LLMInferenceServiceSpec{
WorkloadSpec: v1alpha2.WorkloadSpec{
Template: &corev1.PodSpec{
Containers: []corev1.Container{
{
Name: "main",
Env: []corev1.EnvVar{
{Name: "FULL_NAME", Value: "test-ns/multi-var"},
{Name: "CONFIG_PATH", Value: "/config/test-ns-multi-var.yaml"},
},
},
},
},
},
},
},
},
{
name: "template with invalid syntax should error",
cfg: &v1alpha2.LLMInferenceServiceConfig{
Spec: v1alpha2.LLMInferenceServiceSpec{
Model: v1alpha2.LLMModelSpec{
Name: ptr.To("{{ .Name"), // Invalid template syntax - missing closing brace
},
},
},
llmSvc: &v1alpha2.LLMInferenceService{
ObjectMeta: metav1.ObjectMeta{Name: "test"},
},
wantErr: true,
},
{
name: "template with non-existent field should error",
cfg: &v1alpha2.LLMInferenceServiceConfig{
Spec: v1alpha2.LLMInferenceServiceSpec{
Model: v1alpha2.LLMModelSpec{
Name: ptr.To("{{ .NonExistentField }}"),
},
},
},
llmSvc: &v1alpha2.LLMInferenceService{
ObjectMeta: metav1.ObjectMeta{Name: "test"},
},
wantErr: true,
},
{
name: "template in baseRefs",
cfg: &v1alpha2.LLMInferenceServiceConfig{
Spec: v1alpha2.LLMInferenceServiceSpec{
BaseRefs: []corev1.LocalObjectReference{
{Name: "{{ .Name }}-base-config"},
{Name: "{{ .Namespace }}-shared-config"},
},
},
},
llmSvc: &v1alpha2.LLMInferenceService{
ObjectMeta: metav1.ObjectMeta{
Name: "base-ref-test",
Namespace: "template-ns",
},
},
want: &v1alpha2.LLMInferenceServiceConfig{
Spec: v1alpha2.LLMInferenceServiceSpec{
BaseRefs: []corev1.LocalObjectReference{
{Name: "base-ref-test-base-config"},
{Name: "template-ns-shared-config"},
},
},
},
},
{
name: "shutdownTimeout uses default when spec.template is nil",
cfg: &v1alpha2.LLMInferenceServiceConfig{
Spec: v1alpha2.LLMInferenceServiceSpec{
WorkloadSpec: v1alpha2.WorkloadSpec{
Template: &corev1.PodSpec{
Containers: []corev1.Container{
{Args: []string{"--shutdown-timeout", "{{ shutdownTimeout .Spec.Template 15 }}"}},
},
},
},
},
},
llmSvc: &v1alpha2.LLMInferenceService{
// spec.template not set — shutdownTimeout falls back to default tgps=60: 60-15-min(5,60)=40
},
want: &v1alpha2.LLMInferenceServiceConfig{
Spec: v1alpha2.LLMInferenceServiceSpec{
WorkloadSpec: v1alpha2.WorkloadSpec{
Template: &corev1.PodSpec{
Containers: []corev1.Container{
{Args: []string{"--shutdown-timeout", "40"}},
},
},
},
},
},
},
{
name: "shutdownTimeout uses spec.template.terminationGracePeriodSeconds when set",
cfg: &v1alpha2.LLMInferenceServiceConfig{
Spec: v1alpha2.LLMInferenceServiceSpec{
WorkloadSpec: v1alpha2.WorkloadSpec{
Template: &corev1.PodSpec{
Containers: []corev1.Container{
{Args: []string{"--shutdown-timeout", "{{ shutdownTimeout .Spec.Template 15 }}"}},
},
},
},
},
},
llmSvc: &v1alpha2.LLMInferenceService{
Spec: v1alpha2.LLMInferenceServiceSpec{
WorkloadSpec: v1alpha2.WorkloadSpec{
Template: &corev1.PodSpec{
TerminationGracePeriodSeconds: ptr.To(int64(120)),
},
},
},
},
want: &v1alpha2.LLMInferenceServiceConfig{
Spec: v1alpha2.LLMInferenceServiceSpec{
WorkloadSpec: v1alpha2.WorkloadSpec{
Template: &corev1.PodSpec{
Containers: []corev1.Container{
{Args: []string{"--shutdown-timeout", "100"}},
},
},
},
},
},
},
{
name: "shutdownTimeout uses default when spec.worker is nil",
cfg: &v1alpha2.LLMInferenceServiceConfig{
Spec: v1alpha2.LLMInferenceServiceSpec{
WorkloadSpec: v1alpha2.WorkloadSpec{
Template: &corev1.PodSpec{
Containers: []corev1.Container{
{Args: []string{"--shutdown-timeout", "{{ shutdownTimeout .Spec.Worker 15 }}"}},
},
},
},
},
},
llmSvc: &v1alpha2.LLMInferenceService{
// spec.worker not set — shutdownTimeout falls back to default tgps=60: 60-15-min(5,60)=40
},
want: &v1alpha2.LLMInferenceServiceConfig{
Spec: v1alpha2.LLMInferenceServiceSpec{
WorkloadSpec: v1alpha2.WorkloadSpec{
Template: &corev1.PodSpec{
Containers: []corev1.Container{
{Args: []string{"--shutdown-timeout", "40"}},
},
},
},
},
},
},
{
name: "shutdownTimeout uses spec.worker.terminationGracePeriodSeconds when set",
cfg: &v1alpha2.LLMInferenceServiceConfig{
Spec: v1alpha2.LLMInferenceServiceSpec{
WorkloadSpec: v1alpha2.WorkloadSpec{
Template: &corev1.PodSpec{
Containers: []corev1.Container{
{Args: []string{"--shutdown-timeout", "{{ shutdownTimeout .Spec.Worker 15 }}"}},
},
},
},
},
},
llmSvc: &v1alpha2.LLMInferenceService{
Spec: v1alpha2.LLMInferenceServiceSpec{
WorkloadSpec: v1alpha2.WorkloadSpec{
Worker: &corev1.PodSpec{
TerminationGracePeriodSeconds: ptr.To(int64(120)),
},
},
},
},
want: &v1alpha2.LLMInferenceServiceConfig{
Spec: v1alpha2.LLMInferenceServiceSpec{
WorkloadSpec: v1alpha2.WorkloadSpec{
Template: &corev1.PodSpec{
Containers: []corev1.Container{
{Args: []string{"--shutdown-timeout", "100"}},
},
},
},
},
},
},
{
name: "shutdownTimeout uses default when no spec provided (nil arg)",
cfg: &v1alpha2.LLMInferenceServiceConfig{
Spec: v1alpha2.LLMInferenceServiceSpec{
WorkloadSpec: v1alpha2.WorkloadSpec{
Template: &corev1.PodSpec{
Containers: []corev1.Container{
{Args: []string{"--shutdown-timeout", "{{ shutdownTimeout nil 15 }}"}},
},
},
},
},
},
llmSvc: &v1alpha2.LLMInferenceService{},
want: &v1alpha2.LLMInferenceServiceConfig{
Spec: v1alpha2.LLMInferenceServiceSpec{
WorkloadSpec: v1alpha2.WorkloadSpec{
Template: &corev1.PodSpec{
Containers: []corev1.Container{
{Args: []string{"--shutdown-timeout", "40"}},
},
},
},
},
},
},
{
name: "shutdownTimeout uses spec.prefill.template.terminationGracePeriodSeconds when set",
cfg: &v1alpha2.LLMInferenceServiceConfig{
Spec: v1alpha2.LLMInferenceServiceSpec{
WorkloadSpec: v1alpha2.WorkloadSpec{
Template: &corev1.PodSpec{
Containers: []corev1.Container{
{Args: []string{"--shutdown-timeout", "{{ if .Spec.Prefill }}{{ shutdownTimeout .Spec.Prefill.Template 15 }}{{ else }}{{ shutdownTimeout nil 15 }}{{ end }}"}},
},
},
},
},
},
llmSvc: &v1alpha2.LLMInferenceService{
Spec: v1alpha2.LLMInferenceServiceSpec{
Prefill: &v1alpha2.WorkloadSpec{
Template: &corev1.PodSpec{
TerminationGracePeriodSeconds: ptr.To(int64(120)),
},
},
},
},
want: &v1alpha2.LLMInferenceServiceConfig{
Spec: v1alpha2.LLMInferenceServiceSpec{
WorkloadSpec: v1alpha2.WorkloadSpec{
Template: &corev1.PodSpec{
Containers: []corev1.Container{
{Args: []string{"--shutdown-timeout", "100"}},
},
},
},
},
},
},
{
name: "shutdownTimeout uses spec.prefill.worker.terminationGracePeriodSeconds when set",
cfg: &v1alpha2.LLMInferenceServiceConfig{
Spec: v1alpha2.LLMInferenceServiceSpec{
WorkloadSpec: v1alpha2.WorkloadSpec{
Template: &corev1.PodSpec{
Containers: []corev1.Container{
{Args: []string{"--shutdown-timeout", "{{ if .Spec.Prefill }}{{ shutdownTimeout .Spec.Prefill.Worker 15 }}{{ else }}{{ shutdownTimeout nil 15 }}{{ end }}"}},
},
},
},
},
},
llmSvc: &v1alpha2.LLMInferenceService{
Spec: v1alpha2.LLMInferenceServiceSpec{
Prefill: &v1alpha2.WorkloadSpec{
Worker: &corev1.PodSpec{
TerminationGracePeriodSeconds: ptr.To(int64(120)),
},
},
},
},
want: &v1alpha2.LLMInferenceServiceConfig{
Spec: v1alpha2.LLMInferenceServiceSpec{
WorkloadSpec: v1alpha2.WorkloadSpec{
Template: &corev1.PodSpec{
Containers: []corev1.Container{
{Args: []string{"--shutdown-timeout", "100"}},
},
},
},
},
},
},
{
name: "shutdownTimeout uses default when spec.prefill is nil (prefill via base ref only)",
cfg: &v1alpha2.LLMInferenceServiceConfig{
Spec: v1alpha2.LLMInferenceServiceSpec{
WorkloadSpec: v1alpha2.WorkloadSpec{
Template: &corev1.PodSpec{
Containers: []corev1.Container{
{Args: []string{"--shutdown-timeout", "{{ if .Spec.Prefill }}{{ shutdownTimeout .Spec.Prefill.Template 15 }}{{ else }}{{ shutdownTimeout nil 15 }}{{ end }}"}},
},
},
},
},
},
llmSvc: &v1alpha2.LLMInferenceService{
Spec: v1alpha2.LLMInferenceServiceSpec{},
},
want: &v1alpha2.LLMInferenceServiceConfig{
Spec: v1alpha2.LLMInferenceServiceSpec{
WorkloadSpec: v1alpha2.WorkloadSpec{
Template: &corev1.PodSpec{
Containers: []corev1.Container{
{Args: []string{"--shutdown-timeout", "40"}},
},
},
},
},
},
},
{
name: "kvTransferConfig returns empty string when kvCacheOffloading is nil",
cfg: &v1alpha2.LLMInferenceServiceConfig{
Spec: v1alpha2.LLMInferenceServiceSpec{
WorkloadSpec: v1alpha2.WorkloadSpec{
Template: &corev1.PodSpec{
Containers: []corev1.Container{
{Args: []string{"{{ kvTransferConfig .Spec.KVCacheOffloading }}"}},
},
},
},
},
},
llmSvc: &v1alpha2.LLMInferenceService{},
want: &v1alpha2.LLMInferenceServiceConfig{
Spec: v1alpha2.LLMInferenceServiceSpec{
WorkloadSpec: v1alpha2.WorkloadSpec{
Template: &corev1.PodSpec{
Containers: []corev1.Container{
{Args: []string{""}},
},
},
},
},
},
},
{
name: "kvTransferConfig renders --kv-transfer-config from kvCacheOffloading",
cfg: &v1alpha2.LLMInferenceServiceConfig{
Spec: v1alpha2.LLMInferenceServiceSpec{
WorkloadSpec: v1alpha2.WorkloadSpec{
Template: &corev1.PodSpec{
Containers: []corev1.Container{
{Args: []string{"{{ kvTransferConfig .Spec.KVCacheOffloading }}"}},
},
},
},
},
},
llmSvc: &v1alpha2.LLMInferenceService{
Spec: v1alpha2.LLMInferenceServiceSpec{
WorkloadSpec: v1alpha2.WorkloadSpec{
KVCacheOffloading: &v1alpha2.KVCacheOffloadingSpec{
CPU: resource.MustParse("10Gi"),
},
},
},
},
want: &v1alpha2.LLMInferenceServiceConfig{
Spec: v1alpha2.LLMInferenceServiceSpec{
WorkloadSpec: v1alpha2.WorkloadSpec{
Template: &corev1.PodSpec{
Containers: []corev1.Container{
// json.Marshal sorts map keys alphabetically: kv_connector < kv_connector_extra_config < kv_role
// Internally " is escaped to \" for JSON-safe template rendering; JSON unmarshal restores plain ".
{Args: []string{`--kv-transfer-config '{"kv_connector":"OffloadingConnector","kv_connector_extra_config":{"cpu_bytes_to_use":10737418240,"spec_name":"TieringOffloadingSpec"},"kv_role":"kv_both"}'`}},
},
},
},
},
},
},
{
name: "kvTransferConfig includes eviction policy when set",
cfg: &v1alpha2.LLMInferenceServiceConfig{
Spec: v1alpha2.LLMInferenceServiceSpec{
WorkloadSpec: v1alpha2.WorkloadSpec{
Template: &corev1.PodSpec{
Containers: []corev1.Container{
{Args: []string{"{{ kvTransferConfig .Spec.KVCacheOffloading }}"}},
},
},
},
},
},
llmSvc: &v1alpha2.LLMInferenceService{
Spec: v1alpha2.LLMInferenceServiceSpec{
WorkloadSpec: v1alpha2.WorkloadSpec{
KVCacheOffloading: &v1alpha2.KVCacheOffloadingSpec{
CPU: resource.MustParse("5Gi"),
EvictionPolicy: "arc",
},
},
},
},
want: &v1alpha2.LLMInferenceServiceConfig{
Spec: v1alpha2.LLMInferenceServiceSpec{
WorkloadSpec: v1alpha2.WorkloadSpec{
Template: &corev1.PodSpec{
Containers: []corev1.Container{
// json.Marshal sorts map keys alphabetically: cpu_bytes_to_use < eviction_policy < spec_name
{Args: []string{`--kv-transfer-config '{"kv_connector":"OffloadingConnector","kv_connector_extra_config":{"cpu_bytes_to_use":5368709120,"eviction_policy":"arc","spec_name":"TieringOffloadingSpec"},"kv_role":"kv_both"}'`}},
},
},
},
},
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := llmisvc.ReplaceVariables(tt.llmSvc, tt.cfg, tt.extra)
if (err != nil) != tt.wantErr {
t.Errorf("ReplaceVariables() error = %v, wantErr %v", err, tt.wantErr)
return
}
if !tt.wantErr {
if diff := cmp.Diff(tt.want, got); diff != "" {
t.Errorf("ReplaceVariables() got = %#v, want %#v\nDiff:\n%s", got, tt.want, diff)
}
}
})
}
}
func mustParseURL(s string) apis.URL {
u, err := apis.ParseURL(s)
if err != nil {
panic(err)
}
return *u
}
func TestAdditionalData(t *testing.T) {
tests := []struct {
name string
config string
reconcilerConfig llmisvc.Config
wantErr bool
want func(llmSvc *v1alpha2.LLMInferenceServiceConfig, g *GomegaWithT)
}{
{
name: "additional structs replacements",
config: `apiVersion: serving.kserve.io/v1alpha1
kind: LLMInferenceServiceConfig
metadata:
name: test-config
namespace: "{{ .GlobalConfig.SystemNamespace }}"
spec:
router:
route:
http:
spec:
parentRefs:
- group: gateway.networking.k8s.io
kind: Gateway
name: "{{ .GlobalConfig.IngressGatewayName }}"
namespace: "{{ .GlobalConfig.IngressGatewayNamespace }}"`,
reconcilerConfig: llmisvc.Config{
SystemNamespace: "my-kserve",
IngressGatewayName: "my-gateway",
IngressGatewayNamespace: "my-ns",
},
want: func(llmSvc *v1alpha2.LLMInferenceServiceConfig, g *GomegaWithT) {
httpRouteSpec := llmSvc.Spec.Router.Route.HTTP.Spec
expectedGatewayRef := gwapiv1.ParentReference{
Name: "my-gateway",
Namespace: ptr.To(gwapiv1.Namespace("my-ns")),
}
g.Expect(httpRouteSpec).To(ktesting.HaveGatewayRefs(expectedGatewayRef))
g.Expect(llmSvc.Namespace).To(Equal("my-kserve"))
},
},
{
name: "template with non-existing key should error",
config: `apiVersion: serving.kserve.io/v1alpha1
kind: LLMInferenceServiceConfig
metadata:
name: "{{ .GlobalConfig.NonExistentConfig.SomeField }}"
spec:
model:
name: "static-model"`,
wantErr: true,
want: func(llmSvc *v1alpha2.LLMInferenceServiceConfig, g *GomegaWithT) {},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
preset := &v1alpha2.LLMInferenceServiceConfig{}
if err := yaml.Unmarshal([]byte(tt.config), preset); err != nil {
t.Errorf("Failed to unmarshal YAML: %v", err)
return
}
llmSvc := &v1alpha2.LLMInferenceService{}
got, err := llmisvc.ReplaceVariables(llmSvc, preset, &tt.reconcilerConfig)
if (err != nil) != tt.wantErr {
t.Errorf("ReplaceVariables() error = %v, wantErr %v", err, tt.wantErr)
return
}
tt.want(got, NewGomegaWithT(t))
})
}
}
func TestToParentRefs(t *testing.T) {
tests := []struct {
name string
refs []v1alpha2.GatewayObjectReference
want []gwapiv1.ParentReference
}{
{
name: "single gateway ref",
refs: []v1alpha2.GatewayObjectReference{
{UntypedObjectReference: v1alpha2.UntypedObjectReference{Name: "my-gateway", Namespace: "my-ns"}},
},
want: []gwapiv1.ParentReference{
{
Name: "my-gateway",
Namespace: ptr.To(gwapiv1.Namespace("my-ns")),
Group: ptr.To(gwapiv1.Group("gateway.networking.k8s.io")),
Kind: ptr.To(gwapiv1.Kind("Gateway")),
},
},
},
{
name: "multiple gateway refs",
refs: []v1alpha2.GatewayObjectReference{
{UntypedObjectReference: v1alpha2.UntypedObjectReference{Name: "gw-1", Namespace: "ns-1"}},
{UntypedObjectReference: v1alpha2.UntypedObjectReference{Name: "gw-2", Namespace: "ns-2"}},
},
want: []gwapiv1.ParentReference{
{
Name: "gw-1",
Namespace: ptr.To(gwapiv1.Namespace("ns-1")),
Group: ptr.To(gwapiv1.Group("gateway.networking.k8s.io")),
Kind: ptr.To(gwapiv1.Kind("Gateway")),
},
{
Name: "gw-2",
Namespace: ptr.To(gwapiv1.Namespace("ns-2")),
Group: ptr.To(gwapiv1.Group("gateway.networking.k8s.io")),
Kind: ptr.To(gwapiv1.Kind("Gateway")),
},
},
},
{
name: "gateway ref with empty namespace",
refs: []v1alpha2.GatewayObjectReference{
{UntypedObjectReference: v1alpha2.UntypedObjectReference{Name: "my-gateway", Namespace: ""}},
},
want: []gwapiv1.ParentReference{
{
Name: "my-gateway",
Namespace: nil,
Group: ptr.To(gwapiv1.Group("gateway.networking.k8s.io")),
Kind: ptr.To(gwapiv1.Kind("Gateway")),
},
},
},
{
name: "empty refs returns empty slice",
refs: []v1alpha2.GatewayObjectReference{},
want: []gwapiv1.ParentReference{},
},
{
name: "nil refs returns empty slice",
refs: nil,
want: []gwapiv1.ParentReference{},
},
{
name: "gateway ref with sectionName targets specific listener",
refs: []v1alpha2.GatewayObjectReference{
{
UntypedObjectReference: v1alpha2.UntypedObjectReference{Name: "shared-gateway", Namespace: "gw-ns"},
SectionName: ptr.To(gwapiv1.SectionName("https")),
},
},
want: []gwapiv1.ParentReference{
{
Name: "shared-gateway",
Namespace: ptr.To(gwapiv1.Namespace("gw-ns")),
Group: ptr.To(gwapiv1.Group("gateway.networking.k8s.io")),
Kind: ptr.To(gwapiv1.Kind("Gateway")),
SectionName: ptr.To(gwapiv1.SectionName("https")),
},
},
},
{
name: "mixed refs - with and without sectionName",
refs: []v1alpha2.GatewayObjectReference{
{UntypedObjectReference: v1alpha2.UntypedObjectReference{Name: "gw-all", Namespace: "ns-1"}},
{
UntypedObjectReference: v1alpha2.UntypedObjectReference{Name: "gw-specific", Namespace: "ns-2"},
SectionName: ptr.To(gwapiv1.SectionName("http")),
},
},
want: []gwapiv1.ParentReference{
{
Name: "gw-all",
Namespace: ptr.To(gwapiv1.Namespace("ns-1")),
Group: ptr.To(gwapiv1.Group("gateway.networking.k8s.io")),
Kind: ptr.To(gwapiv1.Kind("Gateway")),
SectionName: nil,
},
{
Name: "gw-specific",
Namespace: ptr.To(gwapiv1.Namespace("ns-2")),
Group: ptr.To(gwapiv1.Group("gateway.networking.k8s.io")),
Kind: ptr.To(gwapiv1.Kind("Gateway")),
SectionName: ptr.To(gwapiv1.SectionName("http")),
},
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := llmisvc.ToParentRefs(tt.refs)
if diff := cmp.Diff(tt.want, got); diff != "" {
t.Errorf("ToParentRefs() mismatch (-want +got):\n%s", diff)
}
})
}
}
func TestParentRefRewritingGuard(t *testing.T) {
tests := []struct {
name string
gatewayHasRefs bool
httpRouteHasSpec bool
wantRewrite bool
}{
{
name: "gateway refs + route spec -> rewrite",
gatewayHasRefs: true,
httpRouteHasSpec: true,
wantRewrite: true,
},
{
name: "gateway refs + route refs (no spec) -> skip rewrite",
gatewayHasRefs: true,
httpRouteHasSpec: false,
wantRewrite: false,
},
{
name: "no gateway refs + route spec -> skip rewrite",
gatewayHasRefs: false,
httpRouteHasSpec: true,
wantRewrite: false,
},
{
name: "no gateway refs + no route spec -> skip rewrite",
gatewayHasRefs: false,
httpRouteHasSpec: false,
wantRewrite: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
gateway := &v1alpha2.GatewaySpec{}
if tt.gatewayHasRefs {
gateway.Refs = []v1alpha2.GatewayObjectReference{
{UntypedObjectReference: v1alpha2.UntypedObjectReference{Name: "my-gw", Namespace: "my-ns"}},
}
}
httpRoute := &v1alpha2.HTTPRouteSpec{}
if tt.httpRouteHasSpec {
httpRoute.Spec = &gwapiv1.HTTPRouteSpec{
CommonRouteSpec: gwapiv1.CommonRouteSpec{
ParentRefs: []gwapiv1.ParentReference{
{Name: "old-default-gateway"},
},
},
}
} else {
httpRoute.Refs = []corev1.LocalObjectReference{{Name: "my-route"}}
}
shouldRewrite := httpRoute.HasSpec() && gateway.HasRefs()
if shouldRewrite != tt.wantRewrite {
t.Errorf("expected rewrite=%v, got %v", tt.wantRewrite, shouldRewrite)
}
if shouldRewrite {
parentRefs := llmisvc.ToParentRefs(gateway.Refs)
if len(parentRefs) != len(gateway.Refs) {
t.Errorf("expected %d parent refs, got %d", len(gateway.Refs), len(parentRefs))
}
}
})
}
}
func TestReplaceVariables_TemplateInjection(t *testing.T) {
tests := []struct {
name string
config string
}{
{
name: "access CredentialConfig via template injection",
config: `apiVersion: serving.kserve.io/v1alpha1
kind: LLMInferenceServiceConfig
metadata:
name: "{{ .GlobalConfig.CredentialConfig.StorageSpecSecretName }}"
spec:
model:
name: "safe-model"`,
},
{
name: "access StorageConfig via template injection",
config: `apiVersion: serving.kserve.io/v1alpha1
kind: LLMInferenceServiceConfig
metadata:
name: injected
spec:
model:
name: "{{ .GlobalConfig.StorageConfig }}"`,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
preset := &v1alpha2.LLMInferenceServiceConfig{}
if err := yaml.Unmarshal([]byte(tt.config), preset); err != nil {
t.Fatalf("Failed to unmarshal YAML: %v", err)
}
reconcilerConfig := &llmisvc.Config{
SystemNamespace: "kserve",
IngressGatewayName: "kserve-gateway",
IngressGatewayNamespace: "kserve",
CredentialConfig: &credentials.CredentialConfig{StorageSpecSecretName: "super-secret"},
}
llmSvc := &v1alpha2.LLMInferenceService{}
_, err := llmisvc.ReplaceVariables(llmSvc, preset, reconcilerConfig)
if err == nil {
t.Errorf("ReplaceVariables() should reject template accessing sensitive GlobalConfig fields, but succeeded")
}
})
}
}
func TestWellKnownConfigResolver_Attach(t *testing.T) {
tests := []struct {
name string
disableVersioning bool
llmSvc *v1alpha2.LLMInferenceService
wantAnnotations func(t *testing.T, annotations map[string]string)
}{
{
name: "versioning disabled is a no-op",
disableVersioning: true,
llmSvc: &v1alpha2.LLMInferenceService{
Status: v1alpha2.LLMInferenceServiceStatus{},
},
wantAnnotations: func(t *testing.T, annotations map[string]string) {
if annotations != nil {
t.Errorf("expected nil annotations when versioning disabled, got %v", annotations)
}
},
},
{
name: "pins all well-known configs on first call",
llmSvc: &v1alpha2.LLMInferenceService{
Status: v1alpha2.LLMInferenceServiceStatus{},
},
wantAnnotations: func(t *testing.T, annotations map[string]string) {
if annotations == nil {
t.Fatal("expected annotations to be set")
}
for _, name := range llmisvc.WellKnownDefaultConfigs.UnsortedList() {
suffix := strings.TrimPrefix(name, "kserve-")
key := llmisvc.StaticWellKnownConfigResolverPrefix + suffix
if got, ok := annotations[key]; !ok {
t.Errorf("missing annotation key %q", key)
} else if got != name {
t.Errorf("annotation %q = %q, want %q", key, got, name)
}
}
},
},
{
name: "idempotent: calling Attach twice does not change annotations",
llmSvc: &v1alpha2.LLMInferenceService{
Status: v1alpha2.LLMInferenceServiceStatus{},
},
wantAnnotations: func(t *testing.T, annotations map[string]string) {
// This test calls Attach twice (handled in the test body below);
// the check ensures values didn't change.
if annotations == nil {
t.Fatal("expected annotations to be set")
}
for _, name := range llmisvc.WellKnownDefaultConfigs.UnsortedList() {
suffix := strings.TrimPrefix(name, "kserve-")
key := llmisvc.StaticWellKnownConfigResolverPrefix + suffix
if got := annotations[key]; got != name {
t.Errorf("annotation %q = %q, want %q after second Attach", key, got, name)
}
}
},
},
{
name: "preserves pre-populated annotations",
llmSvc: &v1alpha2.LLMInferenceService{
Status: v1alpha2.LLMInferenceServiceStatus{
Status: duckv1.Status{
Annotations: map[string]string{
llmisvc.StaticWellKnownConfigResolverPrefix + "config-llm-template": "custom-prefix-config-llm-template",
"unrelated-key": "unrelated-value",
},
},
},
},
wantAnnotations: func(t *testing.T, annotations map[string]string) {
if annotations == nil {
t.Fatal("expected annotations to be set")
}
// The pre-populated annotation should be preserved, not overwritten.
if got := annotations[llmisvc.StaticWellKnownConfigResolverPrefix+"config-llm-template"]; got != "custom-prefix-config-llm-template" {
t.Errorf("pre-populated annotation was overwritten: got %q, want %q", got, "custom-prefix-config-llm-template")
}
// Unrelated annotations should be preserved.
if got := annotations["unrelated-key"]; got != "unrelated-value" {
t.Errorf("unrelated annotation was lost: got %q, want %q", got, "unrelated-value")
}
// Other well-known configs should still be populated.
suffix := "config-llm-scheduler"
key := llmisvc.StaticWellKnownConfigResolverPrefix + suffix
if got, ok := annotations[key]; !ok {
t.Errorf("missing annotation key %q", key)
} else if got != "kserve-"+suffix {
t.Errorf("annotation %q = %q, want %q", key, got, "kserve-"+suffix)
}
},
},
{
name: "annotations pinned with old prefix survive new Attach",
llmSvc: &v1alpha2.LLMInferenceService{
Status: v1alpha2.LLMInferenceServiceStatus{
Status: duckv1.Status{
Annotations: func() map[string]string {
// Simulate annotations pinned with an old prefix (e.g., "old-prefix-").
m := map[string]string{}
for _, name := range llmisvc.WellKnownDefaultConfigs.UnsortedList() {
suffix := strings.TrimPrefix(name, "kserve-")
key := llmisvc.StaticWellKnownConfigResolverPrefix + suffix
m[key] = "old-prefix-" + suffix
}
return m
}(),
},
},
},
wantAnnotations: func(t *testing.T, annotations map[string]string) {
if annotations == nil {
t.Fatal("expected annotations to be set")
}
// All annotations should retain the old prefix values.
for _, name := range llmisvc.WellKnownDefaultConfigs.UnsortedList() {
suffix := strings.TrimPrefix(name, "kserve-")
key := llmisvc.StaticWellKnownConfigResolverPrefix + suffix
want := "old-prefix-" + suffix
if got := annotations[key]; got != want {
t.Errorf("annotation %q = %q, want %q (old-prefix value should survive)", key, got, want)
}
}
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if tt.disableVersioning {
cleanup := llmisvc.SetUseVersionedConfigForTest(false)
defer cleanup()
}
wr := &llmisvc.WellKnownConfigResolver{}
wr.Attach(tt.llmSvc)
// For the idempotency test, call Attach a second time.
if tt.name == "idempotent: calling Attach twice does not change annotations" {
// Snapshot after first call.
firstCallAnnotations := make(map[string]string, len(tt.llmSvc.Status.Annotations))
for k, v := range tt.llmSvc.Status.Annotations {
firstCallAnnotations[k] = v
}
wr.Attach(tt.llmSvc)
// Verify annotations didn't change.
if diff := cmp.Diff(firstCallAnnotations, tt.llmSvc.Status.Annotations); diff != "" {
t.Errorf("Attach() second call changed annotations (-first +second):\n%s", diff)
}
}
tt.wantAnnotations(t, tt.llmSvc.Status.Annotations)
})
}
}
func TestWellKnownConfigResolver_Resolve(t *testing.T) {
tests := []struct {
name string
disableVersioning bool
llmSvc *v1alpha2.LLMInferenceService
inputName string
want string
}{
{
name: "versioning disabled returns name as-is",
disableVersioning: true,
llmSvc: &v1alpha2.LLMInferenceService{
Status: v1alpha2.LLMInferenceServiceStatus{
Status: duckv1.Status{
Annotations: map[string]string{
llmisvc.StaticWellKnownConfigResolverPrefix + "config-llm-template": "kserve-config-llm-template",
},
},
},
},
inputName: "kserve-config-llm-template",
want: "kserve-config-llm-template",
},
{
name: "nil annotations returns name as-is",
llmSvc: &v1alpha2.LLMInferenceService{
Status: v1alpha2.LLMInferenceServiceStatus{},
},
inputName: "kserve-config-llm-template",
want: "kserve-config-llm-template",
},
{
name: "returns pinned name from annotations",
llmSvc: &v1alpha2.LLMInferenceService{
Status: v1alpha2.LLMInferenceServiceStatus{
Status: duckv1.Status{
Annotations: map[string]string{
llmisvc.StaticWellKnownConfigResolverPrefix + "config-llm-template": "kserve-config-llm-template",
},
},
},
},
inputName: "kserve-config-llm-template",
want: "kserve-config-llm-template",
},
{
name: "returns pinned name even when current prefix differs",
llmSvc: &v1alpha2.LLMInferenceService{
Status: v1alpha2.LLMInferenceServiceStatus{
Status: duckv1.Status{
Annotations: map[string]string{
llmisvc.StaticWellKnownConfigResolverPrefix + "config-llm-template": "old-prefix-config-llm-template",
},
},
},
},
inputName: "kserve-config-llm-template",
want: "old-prefix-config-llm-template",
},
{
name: "suffix not found in annotations returns name as-is",
llmSvc: &v1alpha2.LLMInferenceService{
Status: v1alpha2.LLMInferenceServiceStatus{
Status: duckv1.Status{
Annotations: map[string]string{
llmisvc.StaticWellKnownConfigResolverPrefix + "config-llm-scheduler": "kserve-config-llm-scheduler",
},
},
},
},
inputName: "kserve-config-llm-template",
want: "kserve-config-llm-template",
},
{
name: "name without known prefix strips nothing and looks up full suffix",
llmSvc: &v1alpha2.LLMInferenceService{
Status: v1alpha2.LLMInferenceServiceStatus{
Status: duckv1.Status{
Annotations: map[string]string{
llmisvc.StaticWellKnownConfigResolverPrefix + "custom-config": "pinned-custom-config",
},
},
},
},
inputName: "custom-config",
want: "pinned-custom-config",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if tt.disableVersioning {
cleanup := llmisvc.SetUseVersionedConfigForTest(false)
defer cleanup()
}
wr := &llmisvc.WellKnownConfigResolver{}
got := wr.Resolve(tt.llmSvc, tt.inputName)
if got != tt.want {
t.Errorf("Resolve(%q) = %q, want %q", tt.inputName, got, tt.want)
}
})
}
}
const tlsTemplateFixture = `apiVersion: serving.kserve.io/v1alpha1
kind: LLMInferenceServiceConfig
metadata:
name: test
spec:
router:
scheduler:
template:
containers:
- name: main
args:
- '{{ if .GlobalConfig.EnableTLS }}--enable-cert-reload=true{{- end }}'
- '{{ if .GlobalConfig.EnableTLS }}--secure-serving=true{{- end }}'
- '{{ if .GlobalConfig.EnableTLS }}--model-server-metrics-scheme=https{{- end }}'
- '{{ if .GlobalConfig.EnableTLS }}--cert-path=/var/run/kserve/tls{{- end }}'
`
func TestReplaceVariables_TLSConditional(t *testing.T) {
tests := []struct {
name string
enableTLS bool
wantArgs []string
}{
{
name: "TLS on: all flags render with TLS values",
enableTLS: true,
wantArgs: []string{
"--enable-cert-reload=true",
"--secure-serving=true",
"--model-server-metrics-scheme=https",
"--cert-path=/var/run/kserve/tls",
},
},
{
name: "TLS off: all flags render empty",
enableTLS: false,
wantArgs: []string{
"",
"",
"",
"",
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
preset := &v1alpha2.LLMInferenceServiceConfig{}
if err := yaml.Unmarshal([]byte(tlsTemplateFixture), preset); err != nil {
t.Fatalf("failed to unmarshal fixture: %v", err)
}
llmSvc := &v1alpha2.LLMInferenceService{}
cfg := &llmisvc.Config{EnableTLS: tt.enableTLS}
got, err := llmisvc.ReplaceVariables(llmSvc, preset, cfg)
if err != nil {
t.Fatalf("ReplaceVariables() error = %v", err)
}
containers := got.Spec.Router.Scheduler.Template.Containers
if len(containers) == 0 {
t.Fatal("expected at least one container in rendered config")
}
args := containers[0].Args
if len(args) != len(tt.wantArgs) {
t.Fatalf("got %d args, want %d: %q", len(args), len(tt.wantArgs), args)
}
for i, want := range tt.wantArgs {
if args[i] != want {
t.Errorf("arg[%d] = %q, want %q", i, args[i], want)
}
}
})
}
}
func TestReplaceVariables_PDSidecarTLSConditional(t *testing.T) {
const pdSidecarFixture = `apiVersion: serving.kserve.io/v1alpha1
kind: LLMInferenceServiceConfig
metadata:
name: test
spec:
router:
scheduler:
template:
containers:
- name: sidecar
args:
- '{{ if .GlobalConfig.EnableTLS }}--decoder-use-tls=true{{- end }}'
- '{{ if .GlobalConfig.EnableTLS }}--prefiller-use-tls=true{{- end }}'
`
tests := []struct {
name string
enableTLS bool
wantArgs []string
}{
{
name: "TLS on",
enableTLS: true,
wantArgs: []string{"--decoder-use-tls=true", "--prefiller-use-tls=true"},
},
{
name: "TLS off",
enableTLS: false,
wantArgs: []string{"", ""},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
preset := &v1alpha2.LLMInferenceServiceConfig{}
if err := yaml.Unmarshal([]byte(pdSidecarFixture), preset); err != nil {
t.Fatalf("unmarshal: %v", err)
}
got, err := llmisvc.ReplaceVariables(&v1alpha2.LLMInferenceService{}, preset, &llmisvc.Config{EnableTLS: tt.enableTLS})
if err != nil {
t.Fatalf("ReplaceVariables: %v", err)
}
args := got.Spec.Router.Scheduler.Template.Containers[0].Args
if len(args) != len(tt.wantArgs) {
t.Fatalf("got %d args, want %d: %q", len(args), len(tt.wantArgs), args)
}
for i, want := range tt.wantArgs {
if args[i] != want {
t.Errorf("arg[%d] = %q, want %q", i, args[i], want)
}
}
})
}
}