Files
SivananthamandJooho Lee 8744d9e42c feat(isvc): add dual-protocol (REST/gRPC) routing for Standard mode (#5451)
Signed-off-by: Sivanantham Chinnaiyan <[email protected]>
Co-authored-by: Copilot <[email protected]>
(cherry picked from commit 3f4cbbb5c9)
Signed-off-by: Jooho Lee <[email protected]>
2026-04-28 22:11:08 -04:00

3465 lines
106 KiB
Go

/*
Copyright 2024 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 ingress
import (
"context"
"testing"
"github.com/google/go-cmp/cmp/cmpopts"
. "github.com/onsi/gomega"
"github.com/onsi/gomega/format"
corev1 "k8s.io/api/core/v1"
apierr "k8s.io/apimachinery/pkg/api/errors"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/runtime/schema"
"k8s.io/apimachinery/pkg/types"
"k8s.io/client-go/kubernetes/scheme"
"k8s.io/utils/ptr"
"knative.dev/pkg/apis"
duckv1 "knative.dev/pkg/apis/duck/v1"
ctrl "sigs.k8s.io/controller-runtime"
"sigs.k8s.io/controller-runtime/pkg/client"
"sigs.k8s.io/controller-runtime/pkg/client/fake"
"sigs.k8s.io/controller-runtime/pkg/controller/controllerutil"
gwapiv1 "sigs.k8s.io/gateway-api/apis/v1"
"github.com/kserve/kserve/pkg/apis/serving/v1beta1"
"github.com/kserve/kserve/pkg/constants"
)
// httpRouteClientInterceptor wraps a client.Client to simulate errors for testing
type httpRouteClientInterceptor struct {
client.Client
blockHTTPRouteCreation bool
}
func (c *httpRouteClientInterceptor) Create(ctx context.Context, obj client.Object, opts ...client.CreateOption) error {
// Allow HTTPRoute creation to proceed normally
return c.Client.Create(ctx, obj, opts...)
}
// Get intercepts Get calls to return NotFound for HTTPRoutes when blocked
func (c *httpRouteClientInterceptor) Get(ctx context.Context, key client.ObjectKey, obj client.Object, opts ...client.GetOption) error {
// Return NotFound for HTTPRoute Get calls when blocked
// This simulates the case where HTTPRoute was created but then deleted or not found during status checks
if c.blockHTTPRouteCreation {
if _, ok := obj.(*gwapiv1.HTTPRoute); ok {
return apierr.NewNotFound(schema.GroupResource{
Group: gwapiv1.GroupVersion.Group,
Resource: "httproutes",
}, key.Name)
}
}
return c.Client.Get(ctx, key, obj, opts...)
}
func TestCreateRawURL(t *testing.T) {
g := NewGomegaWithT(t)
testCases := map[string]struct {
isvc *v1beta1.InferenceService
ingressConfig *v1beta1.IngressConfig
expectedURL string
isErrorExpected bool
}{
"basic case": {
isvc: &v1beta1.InferenceService{
ObjectMeta: metav1.ObjectMeta{
Name: "test-isvc",
Namespace: "default",
},
},
ingressConfig: &v1beta1.IngressConfig{
IngressDomain: "example.com",
UrlScheme: "http",
DomainTemplate: "{{.Name}}-{{.Namespace}}.{{.IngressDomain}}",
},
isErrorExpected: false,
expectedURL: "http://test-isvc-default.example.com",
},
"basic case with empty domain template": {
isvc: &v1beta1.InferenceService{
ObjectMeta: metav1.ObjectMeta{
Name: "test-isvc",
Namespace: "default",
},
},
ingressConfig: &v1beta1.IngressConfig{
IngressDomain: "example.com",
UrlScheme: "http",
DomainTemplate: "",
},
expectedURL: "",
isErrorExpected: true,
},
}
for name, tc := range testCases {
t.Run(name, func(t *testing.T) {
url, err := createRawURL(tc.isvc, tc.ingressConfig)
if tc.isErrorExpected {
g.Expect(err).To(HaveOccurred())
} else {
g.Expect(err).ToNot(HaveOccurred())
}
g.Expect(tc.expectedURL).To(BeComparableTo(url.String()))
})
}
}
func TestGetRawServiceHost(t *testing.T) {
g := NewGomegaWithT(t)
testCases := map[string]struct {
isvc *v1beta1.InferenceService
expectedHost string
}{
"basic case": {
isvc: &v1beta1.InferenceService{
ObjectMeta: metav1.ObjectMeta{
Name: "test-isvc",
Namespace: "default",
},
Spec: v1beta1.InferenceServiceSpec{
Predictor: v1beta1.PredictorSpec{},
},
},
expectedHost: "test-isvc-predictor.default.svc.cluster.local",
},
"basic case with transformer": {
isvc: &v1beta1.InferenceService{
ObjectMeta: metav1.ObjectMeta{
Name: "test-isvc",
Namespace: "default",
},
Spec: v1beta1.InferenceServiceSpec{
Predictor: v1beta1.PredictorSpec{},
Transformer: &v1beta1.TransformerSpec{},
},
},
expectedHost: "test-isvc-transformer.default.svc.cluster.local",
},
}
for name, tc := range testCases {
t.Run(name, func(t *testing.T) {
host := getRawServiceHost(tc.isvc)
g.Expect(tc.expectedHost).To(BeComparableTo(host))
})
}
}
func TestCreateHTTPRouteMatch(t *testing.T) {
g := NewGomegaWithT(t)
testCases := map[string]struct {
prefix string
expectedHTTPRoutes gwapiv1.HTTPRouteMatch
}{
"basic case": {
prefix: "^.*$",
expectedHTTPRoutes: gwapiv1.HTTPRouteMatch{
Path: &gwapiv1.HTTPPathMatch{
Type: ptr.To(gwapiv1.PathMatchRegularExpression),
Value: ptr.To("^.*$"),
},
},
},
}
for name, tc := range testCases {
t.Run(name, func(t *testing.T) {
matches := createHTTPRouteMatch(tc.prefix)
g.Expect(matches).To(BeComparableTo(tc.expectedHTTPRoutes))
})
}
}
func TestAddIsvcHeaders(t *testing.T) {
g := NewGomegaWithT(t)
testCases := map[string]struct {
isvcName string
isvcNamespace string
}{
"basic case": {
isvcName: "test-isvc",
isvcNamespace: "default",
},
}
for name, tc := range testCases {
t.Run(name, func(t *testing.T) {
headers := addIsvcHeaders(tc.isvcName, tc.isvcNamespace)
g.Expect(headers.Type).To(BeComparableTo(gwapiv1.HTTPRouteFilterRequestHeaderModifier))
g.Expect(headers.RequestHeaderModifier.Set).To(HaveLen(2))
g.Expect(string(headers.RequestHeaderModifier.Set[0].Name)).To(BeComparableTo(constants.IsvcNameHeader))
g.Expect(headers.RequestHeaderModifier.Set[0].Value).To(BeComparableTo(tc.isvcName))
g.Expect(string(headers.RequestHeaderModifier.Set[1].Name)).To(BeComparableTo(constants.IsvcNamespaceHeader))
g.Expect(headers.RequestHeaderModifier.Set[1].Value).To(BeComparableTo(tc.isvcNamespace))
})
}
}
func TestCreateHTTPRouteRule(t *testing.T) {
g := NewGomegaWithT(t)
testCases := map[string]struct {
matches []gwapiv1.HTTPRouteMatch
filters []gwapiv1.HTTPRouteFilter
serviceName string
servicePort int32
expectedRules int
}{
"basic case": {
matches: []gwapiv1.HTTPRouteMatch{
{
Path: &gwapiv1.HTTPPathMatch{
Type: ptr.To(gwapiv1.PathMatchRegularExpression),
Value: ptr.To("/predict"),
},
},
},
filters: []gwapiv1.HTTPRouteFilter{
addIsvcHeaders("test-isvc", "default"),
},
serviceName: "test-service",
servicePort: 80,
expectedRules: 1,
},
}
for name, tc := range testCases {
t.Run(name, func(t *testing.T) {
rule := createHTTPRouteRule(tc.matches, tc.filters, tc.serviceName, "default", tc.servicePort, DefaultTimeout)
g.Expect(rule.Matches).To(HaveLen(tc.expectedRules))
g.Expect(rule.Filters).To(HaveLen(tc.expectedRules))
g.Expect(rule.BackendRefs).To(HaveLen(tc.expectedRules))
g.Expect(string(rule.BackendRefs[0].Name)).To(BeComparableTo(tc.serviceName))
})
}
}
func TestSemanticHttpRouteEquals(t *testing.T) {
g := NewGomegaWithT(t)
testCases := map[string]struct {
desired *gwapiv1.HTTPRoute
existing *gwapiv1.HTTPRoute
expectedEqual bool
}{
"equal routes": {
desired: &gwapiv1.HTTPRoute{
Spec: gwapiv1.HTTPRouteSpec{
Hostnames: []gwapiv1.Hostname{"example.com"},
},
},
existing: &gwapiv1.HTTPRoute{
Spec: gwapiv1.HTTPRouteSpec{
Hostnames: []gwapiv1.Hostname{"example.com"},
},
},
expectedEqual: true,
},
"different routes": {
desired: &gwapiv1.HTTPRoute{
Spec: gwapiv1.HTTPRouteSpec{
Hostnames: []gwapiv1.Hostname{"example.com"},
},
},
existing: &gwapiv1.HTTPRoute{
Spec: gwapiv1.HTTPRouteSpec{
Hostnames: []gwapiv1.Hostname{"different.com"},
},
},
expectedEqual: false,
},
}
for name, tc := range testCases {
t.Run(name, func(t *testing.T) {
g.Expect(semanticHttpRouteEquals(tc.desired, tc.existing)).To(BeComparableTo(tc.expectedEqual))
})
}
}
func TestIsHTTPRouteReady(t *testing.T) {
g := NewGomegaWithT(t)
testCases := map[string]struct {
httpRouteStatus gwapiv1.HTTPRouteStatus
expectedReady bool
expectedReason *string
expectedMessage *string
}{
"route accepted": {
httpRouteStatus: gwapiv1.HTTPRouteStatus{
RouteStatus: gwapiv1.RouteStatus{
Parents: []gwapiv1.RouteParentStatus{
{
Conditions: []metav1.Condition{
{
Type: string(gwapiv1.RouteConditionAccepted),
Status: metav1.ConditionTrue,
},
{
Type: string(gwapiv1.RouteConditionResolvedRefs),
Status: metav1.ConditionTrue,
},
},
},
},
},
},
expectedReady: true,
expectedReason: nil,
expectedMessage: nil,
},
"route not accepted": {
httpRouteStatus: gwapiv1.HTTPRouteStatus{
RouteStatus: gwapiv1.RouteStatus{
Parents: []gwapiv1.RouteParentStatus{
{
Conditions: []metav1.Condition{
{
Type: string(gwapiv1.RouteConditionAccepted),
Status: metav1.ConditionFalse,
Reason: "Route not accepted",
Message: "Route not accepted",
},
{
Type: string(gwapiv1.RouteConditionResolvedRefs),
Status: metav1.ConditionTrue,
},
},
},
},
},
},
expectedReady: false,
expectedReason: ptr.To("Route not accepted"),
expectedMessage: ptr.To("Route not accepted"),
},
"no parent status": {
httpRouteStatus: gwapiv1.HTTPRouteStatus{
RouteStatus: gwapiv1.RouteStatus{
Parents: []gwapiv1.RouteParentStatus{},
},
},
expectedReady: false,
expectedReason: ptr.To(HTTPRouteParentStatusNotAvailable),
expectedMessage: ptr.To(HTTPRouteNotReady),
},
"resolved refs not ready": {
httpRouteStatus: gwapiv1.HTTPRouteStatus{
RouteStatus: gwapiv1.RouteStatus{
Parents: []gwapiv1.RouteParentStatus{
{
Conditions: []metav1.Condition{
{
Type: string(gwapiv1.RouteConditionAccepted),
Status: metav1.ConditionTrue,
},
{
Type: string(gwapiv1.RouteConditionResolvedRefs),
Status: metav1.ConditionFalse,
Reason: "BackendNotFound",
Message: "Backend service not found",
},
},
},
},
},
},
expectedReady: false,
expectedReason: ptr.To("BackendNotFound"),
expectedMessage: ptr.To("Backend service not found"),
},
"multiple parents with one not ready": {
httpRouteStatus: gwapiv1.HTTPRouteStatus{
RouteStatus: gwapiv1.RouteStatus{
Parents: []gwapiv1.RouteParentStatus{
{
Conditions: []metav1.Condition{
{
Type: string(gwapiv1.RouteConditionAccepted),
Status: metav1.ConditionTrue,
},
{
Type: string(gwapiv1.RouteConditionResolvedRefs),
Status: metav1.ConditionTrue,
},
},
},
{
Conditions: []metav1.Condition{
{
Type: string(gwapiv1.RouteConditionAccepted),
Status: metav1.ConditionFalse,
Reason: "GatewayNotFound",
Message: "Gateway not found",
},
},
},
},
},
},
expectedReady: false,
expectedReason: ptr.To("GatewayNotFound"),
expectedMessage: ptr.To("Gateway not found"),
},
"both accepted and resolved refs false": {
httpRouteStatus: gwapiv1.HTTPRouteStatus{
RouteStatus: gwapiv1.RouteStatus{
Parents: []gwapiv1.RouteParentStatus{
{
Conditions: []metav1.Condition{
{
Type: string(gwapiv1.RouteConditionAccepted),
Status: metav1.ConditionFalse,
Reason: "NotAccepted",
Message: "Route not accepted",
},
{
Type: string(gwapiv1.RouteConditionResolvedRefs),
Status: metav1.ConditionFalse,
Reason: "BackendNotFound",
Message: "Backend not found",
},
},
},
},
},
},
expectedReady: false,
expectedReason: ptr.To("NotAccepted"),
expectedMessage: ptr.To("Route not accepted"),
},
}
for name, tc := range testCases {
t.Run(name, func(t *testing.T) {
ready, reason, message := isHTTPRouteReady(tc.httpRouteStatus)
g.Expect(ready).To(BeComparableTo(tc.expectedReady))
g.Expect(reason).To(BeComparableTo(tc.expectedReason))
g.Expect(message).To(BeComparableTo(tc.expectedMessage))
})
}
}
func TestCreateRawTopLevelHTTPRoute(t *testing.T) {
format.MaxLength = 0
g := NewGomegaWithT(t)
testCases := map[string]struct {
isvc *v1beta1.InferenceService
ingressConfig *v1beta1.IngressConfig
expected *gwapiv1.HTTPRoute
}{
"Predictor ready": {
isvc: &v1beta1.InferenceService{
ObjectMeta: metav1.ObjectMeta{
Name: "test-isvc",
Namespace: "default",
},
Spec: v1beta1.InferenceServiceSpec{
Predictor: v1beta1.PredictorSpec{},
},
Status: v1beta1.InferenceServiceStatus{
Status: duckv1.Status{
Conditions: []apis.Condition{
{
Type: v1beta1.PredictorReady,
Status: corev1.ConditionTrue,
},
},
},
},
},
ingressConfig: &v1beta1.IngressConfig{
IngressDomain: "example.com",
UrlScheme: "http",
DomainTemplate: "{{.Name}}-{{.Namespace}}.{{.IngressDomain}}",
KserveIngressGateway: "kserve/kserve-gateway",
AdditionalIngressDomains: &[]string{"additional.example.com"},
EnableGatewayAPI: true,
},
expected: &gwapiv1.HTTPRoute{
ObjectMeta: metav1.ObjectMeta{
Name: "test-isvc",
Namespace: "default",
Annotations: map[string]string{},
Labels: map[string]string{},
},
Spec: gwapiv1.HTTPRouteSpec{
Hostnames: []gwapiv1.Hostname{"test-isvc-default.example.com", "test-isvc-default.additional.example.com"},
Rules: []gwapiv1.HTTPRouteRule{
{
Matches: []gwapiv1.HTTPRouteMatch{
{
Path: &gwapiv1.HTTPPathMatch{
Type: ptr.To(gwapiv1.PathMatchRegularExpression),
Value: ptr.To("^/.*$"),
},
},
},
Filters: []gwapiv1.HTTPRouteFilter{
{
Type: gwapiv1.HTTPRouteFilterRequestHeaderModifier,
RequestHeaderModifier: &gwapiv1.HTTPHeaderFilter{
Set: []gwapiv1.HTTPHeader{
{
Name: constants.IsvcNameHeader,
Value: "test-isvc",
},
{
Name: constants.IsvcNamespaceHeader,
Value: "default",
},
},
},
},
},
BackendRefs: []gwapiv1.HTTPBackendRef{
{
BackendRef: gwapiv1.BackendRef{
BackendObjectReference: gwapiv1.BackendObjectReference{
Kind: ptr.To(gwapiv1.Kind(constants.ServiceKind)),
Name: "test-isvc-predictor",
Namespace: (*gwapiv1.Namespace)(ptr.To("default")),
Port: ptr.To(int32(constants.CommonDefaultHttpPort)),
},
},
},
},
Timeouts: &gwapiv1.HTTPRouteTimeouts{
Request: ptr.To(gwapiv1.Duration("60s")),
},
},
},
CommonRouteSpec: gwapiv1.CommonRouteSpec{
ParentRefs: []gwapiv1.ParentReference{
{
Name: "kserve-gateway",
Kind: ptr.To(gwapiv1.Kind(constants.GatewayKind)),
Group: (*gwapiv1.Group)(&gwapiv1.GroupVersion.Group),
Namespace: ptr.To(gwapiv1.Namespace("kserve")),
},
},
},
},
},
},
"When predictor not ready, httproute should not be created": {
isvc: &v1beta1.InferenceService{
ObjectMeta: metav1.ObjectMeta{
Name: "test-isvc",
Namespace: "default",
},
Spec: v1beta1.InferenceServiceSpec{
Predictor: v1beta1.PredictorSpec{},
},
Status: v1beta1.InferenceServiceStatus{
Status: duckv1.Status{
Conditions: []apis.Condition{
{
Type: v1beta1.PredictorReady,
Status: corev1.ConditionFalse,
},
},
},
},
},
ingressConfig: &v1beta1.IngressConfig{
IngressDomain: "example.com",
UrlScheme: "http",
DomainTemplate: "{{.Name}}-{{.Namespace}}.{{.IngressDomain}}",
KserveIngressGateway: "kserve/kserve-gateway",
AdditionalIngressDomains: &[]string{"additional.example.com"},
EnableGatewayAPI: true,
},
expected: nil,
},
"With transformer ready": {
isvc: &v1beta1.InferenceService{
ObjectMeta: metav1.ObjectMeta{
Name: "test-isvc",
Namespace: "default",
},
Spec: v1beta1.InferenceServiceSpec{
Predictor: v1beta1.PredictorSpec{},
Transformer: &v1beta1.TransformerSpec{},
},
Status: v1beta1.InferenceServiceStatus{
Status: duckv1.Status{
Conditions: []apis.Condition{
{
Type: v1beta1.TransformerReady,
Status: corev1.ConditionTrue,
},
{
Type: v1beta1.PredictorReady,
Status: corev1.ConditionTrue,
},
},
},
},
},
ingressConfig: &v1beta1.IngressConfig{
IngressDomain: "example.com",
UrlScheme: "http",
DomainTemplate: "{{.Name}}-{{.Namespace}}.{{.IngressDomain}}",
KserveIngressGateway: "kserve/kserve-gateway",
AdditionalIngressDomains: &[]string{"additional.example.com"},
EnableGatewayAPI: true,
},
expected: &gwapiv1.HTTPRoute{
ObjectMeta: metav1.ObjectMeta{
Name: "test-isvc",
Namespace: "default",
Annotations: map[string]string{},
Labels: map[string]string{},
},
Spec: gwapiv1.HTTPRouteSpec{
Hostnames: []gwapiv1.Hostname{"test-isvc-default.example.com", "test-isvc-default.additional.example.com"},
Rules: []gwapiv1.HTTPRouteRule{
{
Matches: []gwapiv1.HTTPRouteMatch{
{
Path: &gwapiv1.HTTPPathMatch{
Type: ptr.To(gwapiv1.PathMatchRegularExpression),
Value: ptr.To("^/.*$"),
},
},
},
Filters: []gwapiv1.HTTPRouteFilter{
{
Type: gwapiv1.HTTPRouteFilterRequestHeaderModifier,
RequestHeaderModifier: &gwapiv1.HTTPHeaderFilter{
Set: []gwapiv1.HTTPHeader{
{
Name: constants.IsvcNameHeader,
Value: "test-isvc",
},
{
Name: constants.IsvcNamespaceHeader,
Value: "default",
},
},
},
},
},
BackendRefs: []gwapiv1.HTTPBackendRef{
{
BackendRef: gwapiv1.BackendRef{
BackendObjectReference: gwapiv1.BackendObjectReference{
Kind: ptr.To(gwapiv1.Kind(constants.ServiceKind)),
Name: "test-isvc-transformer",
Namespace: (*gwapiv1.Namespace)(ptr.To("default")),
Port: ptr.To(int32(constants.CommonDefaultHttpPort)),
},
},
},
},
Timeouts: &gwapiv1.HTTPRouteTimeouts{
Request: ptr.To(gwapiv1.Duration("60s")),
},
},
},
CommonRouteSpec: gwapiv1.CommonRouteSpec{
ParentRefs: []gwapiv1.ParentReference{
{
Name: "kserve-gateway",
Kind: ptr.To(gwapiv1.Kind(constants.GatewayKind)),
Group: (*gwapiv1.Group)(&gwapiv1.GroupVersion.Group),
Namespace: ptr.To(gwapiv1.Namespace("kserve")),
},
},
},
},
},
},
"When transformer not ready, httproute should not be created": {
isvc: &v1beta1.InferenceService{
ObjectMeta: metav1.ObjectMeta{
Name: "test-isvc",
Namespace: "default",
},
Spec: v1beta1.InferenceServiceSpec{
Predictor: v1beta1.PredictorSpec{},
Transformer: &v1beta1.TransformerSpec{},
},
Status: v1beta1.InferenceServiceStatus{
Status: duckv1.Status{
Conditions: []apis.Condition{
{
Type: v1beta1.TransformerReady,
Status: corev1.ConditionFalse,
},
{
Type: v1beta1.PredictorReady,
Status: corev1.ConditionTrue,
},
},
},
},
},
ingressConfig: &v1beta1.IngressConfig{
IngressDomain: "example.com",
UrlScheme: "http",
DomainTemplate: "{{.Name}}-{{.Namespace}}.{{.IngressDomain}}",
KserveIngressGateway: "kserve/kserve-gateway",
AdditionalIngressDomains: &[]string{"additional.example.com"},
EnableGatewayAPI: true,
},
expected: nil,
},
"With explainer ready": {
isvc: &v1beta1.InferenceService{
ObjectMeta: metav1.ObjectMeta{
Name: "test-isvc",
Namespace: "default",
},
Spec: v1beta1.InferenceServiceSpec{
Predictor: v1beta1.PredictorSpec{},
Explainer: &v1beta1.ExplainerSpec{},
},
Status: v1beta1.InferenceServiceStatus{
Status: duckv1.Status{
Conditions: []apis.Condition{
{
Type: v1beta1.ExplainerReady,
Status: corev1.ConditionTrue,
},
{
Type: v1beta1.PredictorReady,
Status: corev1.ConditionTrue,
},
},
},
},
},
ingressConfig: &v1beta1.IngressConfig{
IngressDomain: "example.com",
UrlScheme: "http",
DomainTemplate: "{{.Name}}-{{.Namespace}}.{{.IngressDomain}}",
KserveIngressGateway: "kserve/kserve-gateway",
AdditionalIngressDomains: &[]string{"additional.example.com"},
EnableGatewayAPI: true,
},
expected: &gwapiv1.HTTPRoute{
ObjectMeta: metav1.ObjectMeta{
Name: "test-isvc",
Namespace: "default",
Annotations: map[string]string{},
Labels: map[string]string{},
},
Spec: gwapiv1.HTTPRouteSpec{
Hostnames: []gwapiv1.Hostname{"test-isvc-default.example.com", "test-isvc-default.additional.example.com"},
Rules: []gwapiv1.HTTPRouteRule{
{
Matches: []gwapiv1.HTTPRouteMatch{
{
Path: &gwapiv1.HTTPPathMatch{
Type: ptr.To(gwapiv1.PathMatchRegularExpression),
Value: ptr.To(constants.ExplainPrefix()),
},
},
},
Filters: []gwapiv1.HTTPRouteFilter{
{
Type: gwapiv1.HTTPRouteFilterRequestHeaderModifier,
RequestHeaderModifier: &gwapiv1.HTTPHeaderFilter{
Set: []gwapiv1.HTTPHeader{
{
Name: constants.IsvcNameHeader,
Value: "test-isvc",
},
{
Name: constants.IsvcNamespaceHeader,
Value: "default",
},
},
},
},
},
BackendRefs: []gwapiv1.HTTPBackendRef{
{
BackendRef: gwapiv1.BackendRef{
BackendObjectReference: gwapiv1.BackendObjectReference{
Kind: ptr.To(gwapiv1.Kind(constants.ServiceKind)),
Name: "test-isvc-explainer",
Namespace: (*gwapiv1.Namespace)(ptr.To("default")),
Port: ptr.To(int32(constants.CommonDefaultHttpPort)),
},
},
},
},
Timeouts: &gwapiv1.HTTPRouteTimeouts{
Request: ptr.To(gwapiv1.Duration("60s")),
},
},
{
Matches: []gwapiv1.HTTPRouteMatch{
{
Path: &gwapiv1.HTTPPathMatch{
Type: ptr.To(gwapiv1.PathMatchRegularExpression),
Value: ptr.To("^/.*$"),
},
},
},
Filters: []gwapiv1.HTTPRouteFilter{
{
Type: gwapiv1.HTTPRouteFilterRequestHeaderModifier,
RequestHeaderModifier: &gwapiv1.HTTPHeaderFilter{
Set: []gwapiv1.HTTPHeader{
{
Name: constants.IsvcNameHeader,
Value: "test-isvc",
},
{
Name: constants.IsvcNamespaceHeader,
Value: "default",
},
},
},
},
},
BackendRefs: []gwapiv1.HTTPBackendRef{
{
BackendRef: gwapiv1.BackendRef{
BackendObjectReference: gwapiv1.BackendObjectReference{
Kind: ptr.To(gwapiv1.Kind(constants.ServiceKind)),
Name: "test-isvc-predictor",
Namespace: (*gwapiv1.Namespace)(ptr.To("default")),
Port: ptr.To(int32(constants.CommonDefaultHttpPort)),
},
},
},
},
Timeouts: &gwapiv1.HTTPRouteTimeouts{
Request: ptr.To(gwapiv1.Duration("60s")),
},
},
},
CommonRouteSpec: gwapiv1.CommonRouteSpec{
ParentRefs: []gwapiv1.ParentReference{
{
Name: "kserve-gateway",
Kind: ptr.To(gwapiv1.Kind(constants.GatewayKind)),
Group: (*gwapiv1.Group)(&gwapiv1.GroupVersion.Group),
Namespace: ptr.To(gwapiv1.Namespace("kserve")),
},
},
},
},
},
},
"When explainer not ready, httproute should not be created": {
isvc: &v1beta1.InferenceService{
ObjectMeta: metav1.ObjectMeta{
Name: "test-isvc",
Namespace: "default",
},
Spec: v1beta1.InferenceServiceSpec{
Predictor: v1beta1.PredictorSpec{},
Explainer: &v1beta1.ExplainerSpec{},
},
Status: v1beta1.InferenceServiceStatus{
Status: duckv1.Status{
Conditions: []apis.Condition{
{
Type: v1beta1.ExplainerReady,
Status: corev1.ConditionFalse,
},
{
Type: v1beta1.PredictorReady,
Status: corev1.ConditionTrue,
},
},
},
},
},
ingressConfig: &v1beta1.IngressConfig{
IngressDomain: "example.com",
UrlScheme: "http",
DomainTemplate: "{{.Name}}-{{.Namespace}}.{{.IngressDomain}}",
KserveIngressGateway: "kserve/kserve-gateway",
AdditionalIngressDomains: &[]string{"additional.example.com"},
EnableGatewayAPI: true,
},
expected: nil,
},
"Path based routing with explainer": {
isvc: &v1beta1.InferenceService{
ObjectMeta: metav1.ObjectMeta{
Name: "test-isvc",
Namespace: "default",
},
Spec: v1beta1.InferenceServiceSpec{
Predictor: v1beta1.PredictorSpec{},
Explainer: &v1beta1.ExplainerSpec{},
},
Status: v1beta1.InferenceServiceStatus{
Status: duckv1.Status{
Conditions: []apis.Condition{
{
Type: v1beta1.ExplainerReady,
Status: corev1.ConditionTrue,
},
{
Type: v1beta1.PredictorReady,
Status: corev1.ConditionTrue,
},
},
},
},
},
ingressConfig: &v1beta1.IngressConfig{
IngressDomain: "example.com",
UrlScheme: "http",
DomainTemplate: "{{.Name}}-{{.Namespace}}.{{.IngressDomain}}",
KserveIngressGateway: "kserve/kserve-gateway",
AdditionalIngressDomains: &[]string{"additional.example.com"},
PathTemplate: "/serving/{{ .Namespace }}/{{ .Name }}",
EnableGatewayAPI: true,
},
expected: &gwapiv1.HTTPRoute{
ObjectMeta: metav1.ObjectMeta{
Name: "test-isvc",
Namespace: "default",
Annotations: map[string]string{},
Labels: map[string]string{},
},
Spec: gwapiv1.HTTPRouteSpec{
Hostnames: []gwapiv1.Hostname{"test-isvc-default.example.com", "test-isvc-default.additional.example.com", "example.com"},
Rules: []gwapiv1.HTTPRouteRule{
{
Matches: []gwapiv1.HTTPRouteMatch{
{
Path: &gwapiv1.HTTPPathMatch{
Type: ptr.To(gwapiv1.PathMatchRegularExpression),
Value: ptr.To(constants.ExplainPrefix()),
},
},
},
Filters: []gwapiv1.HTTPRouteFilter{
{
Type: gwapiv1.HTTPRouteFilterRequestHeaderModifier,
RequestHeaderModifier: &gwapiv1.HTTPHeaderFilter{
Set: []gwapiv1.HTTPHeader{
{
Name: constants.IsvcNameHeader,
Value: "test-isvc",
},
{
Name: constants.IsvcNamespaceHeader,
Value: "default",
},
},
},
},
},
BackendRefs: []gwapiv1.HTTPBackendRef{
{
BackendRef: gwapiv1.BackendRef{
BackendObjectReference: gwapiv1.BackendObjectReference{
Kind: ptr.To(gwapiv1.Kind(constants.ServiceKind)),
Name: "test-isvc-explainer",
Namespace: (*gwapiv1.Namespace)(ptr.To("default")),
Port: ptr.To(int32(constants.CommonDefaultHttpPort)),
},
},
},
},
Timeouts: &gwapiv1.HTTPRouteTimeouts{
Request: ptr.To(gwapiv1.Duration("60s")),
},
},
{
Matches: []gwapiv1.HTTPRouteMatch{
{
Path: &gwapiv1.HTTPPathMatch{
Type: ptr.To(gwapiv1.PathMatchRegularExpression),
Value: ptr.To("^/.*$"),
},
},
},
Filters: []gwapiv1.HTTPRouteFilter{
{
Type: gwapiv1.HTTPRouteFilterRequestHeaderModifier,
RequestHeaderModifier: &gwapiv1.HTTPHeaderFilter{
Set: []gwapiv1.HTTPHeader{
{
Name: constants.IsvcNameHeader,
Value: "test-isvc",
},
{
Name: constants.IsvcNamespaceHeader,
Value: "default",
},
},
},
},
},
BackendRefs: []gwapiv1.HTTPBackendRef{
{
BackendRef: gwapiv1.BackendRef{
BackendObjectReference: gwapiv1.BackendObjectReference{
Kind: ptr.To(gwapiv1.Kind(constants.ServiceKind)),
Name: "test-isvc-predictor",
Namespace: (*gwapiv1.Namespace)(ptr.To("default")),
Port: ptr.To(int32(constants.CommonDefaultHttpPort)),
},
},
},
},
Timeouts: &gwapiv1.HTTPRouteTimeouts{
Request: ptr.To(gwapiv1.Duration("60s")),
},
},
{
Matches: []gwapiv1.HTTPRouteMatch{
{
Path: &gwapiv1.HTTPPathMatch{
Type: ptr.To(gwapiv1.PathMatchRegularExpression),
Value: ptr.To("/serving/default/test-isvc" + constants.PathBasedExplainPrefix()),
},
},
},
Filters: []gwapiv1.HTTPRouteFilter{
{
Type: gwapiv1.HTTPRouteFilterRequestHeaderModifier,
RequestHeaderModifier: &gwapiv1.HTTPHeaderFilter{
Set: []gwapiv1.HTTPHeader{
{
Name: constants.IsvcNameHeader,
Value: "test-isvc",
},
{
Name: constants.IsvcNamespaceHeader,
Value: "default",
},
},
},
},
},
BackendRefs: []gwapiv1.HTTPBackendRef{
{
BackendRef: gwapiv1.BackendRef{
BackendObjectReference: gwapiv1.BackendObjectReference{
Kind: ptr.To(gwapiv1.Kind(constants.ServiceKind)),
Name: "test-isvc-explainer",
Namespace: (*gwapiv1.Namespace)(ptr.To("default")),
Port: ptr.To(int32(constants.CommonDefaultHttpPort)),
},
},
},
},
Timeouts: &gwapiv1.HTTPRouteTimeouts{
Request: ptr.To(gwapiv1.Duration("60s")),
},
},
{
Matches: []gwapiv1.HTTPRouteMatch{
{
Path: &gwapiv1.HTTPPathMatch{
Type: ptr.To(gwapiv1.PathMatchRegularExpression),
Value: ptr.To("/serving/default/test-isvc/"),
},
},
},
Filters: []gwapiv1.HTTPRouteFilter{
{
Type: gwapiv1.HTTPRouteFilterRequestHeaderModifier,
RequestHeaderModifier: &gwapiv1.HTTPHeaderFilter{
Set: []gwapiv1.HTTPHeader{
{
Name: constants.IsvcNameHeader,
Value: "test-isvc",
},
{
Name: constants.IsvcNamespaceHeader,
Value: "default",
},
},
},
},
},
BackendRefs: []gwapiv1.HTTPBackendRef{
{
BackendRef: gwapiv1.BackendRef{
BackendObjectReference: gwapiv1.BackendObjectReference{
Kind: ptr.To(gwapiv1.Kind(constants.ServiceKind)),
Name: "test-isvc-predictor",
Namespace: (*gwapiv1.Namespace)(ptr.To("default")),
Port: ptr.To(int32(constants.CommonDefaultHttpPort)),
},
},
},
},
Timeouts: &gwapiv1.HTTPRouteTimeouts{
Request: ptr.To(gwapiv1.Duration("60s")),
},
},
},
CommonRouteSpec: gwapiv1.CommonRouteSpec{
ParentRefs: []gwapiv1.ParentReference{
{
Name: "kserve-gateway",
Kind: ptr.To(gwapiv1.Kind(constants.GatewayKind)),
Group: (*gwapiv1.Group)(&gwapiv1.GroupVersion.Group),
Namespace: ptr.To(gwapiv1.Namespace("kserve")),
},
},
},
},
},
},
"Path based routing with transformer": {
isvc: &v1beta1.InferenceService{
ObjectMeta: metav1.ObjectMeta{
Name: "test-isvc",
Namespace: "default",
},
Spec: v1beta1.InferenceServiceSpec{
Predictor: v1beta1.PredictorSpec{},
Transformer: &v1beta1.TransformerSpec{},
},
Status: v1beta1.InferenceServiceStatus{
Status: duckv1.Status{
Conditions: []apis.Condition{
{
Type: v1beta1.TransformerReady,
Status: corev1.ConditionTrue,
},
{
Type: v1beta1.PredictorReady,
Status: corev1.ConditionTrue,
},
},
},
},
},
ingressConfig: &v1beta1.IngressConfig{
IngressDomain: "example.com",
UrlScheme: "http",
DomainTemplate: "{{.Name}}-{{.Namespace}}.{{.IngressDomain}}",
KserveIngressGateway: "kserve/kserve-gateway",
AdditionalIngressDomains: &[]string{"additional.example.com"},
PathTemplate: "/serving/{{ .Namespace }}/{{ .Name }}",
EnableGatewayAPI: true,
},
expected: &gwapiv1.HTTPRoute{
ObjectMeta: metav1.ObjectMeta{
Name: "test-isvc",
Namespace: "default",
Annotations: map[string]string{},
Labels: map[string]string{},
},
Spec: gwapiv1.HTTPRouteSpec{
Hostnames: []gwapiv1.Hostname{"test-isvc-default.example.com", "test-isvc-default.additional.example.com", "example.com"},
Rules: []gwapiv1.HTTPRouteRule{
{
Matches: []gwapiv1.HTTPRouteMatch{
{
Path: &gwapiv1.HTTPPathMatch{
Type: ptr.To(gwapiv1.PathMatchRegularExpression),
Value: ptr.To("^/.*$"),
},
},
},
Filters: []gwapiv1.HTTPRouteFilter{
{
Type: gwapiv1.HTTPRouteFilterRequestHeaderModifier,
RequestHeaderModifier: &gwapiv1.HTTPHeaderFilter{
Set: []gwapiv1.HTTPHeader{
{
Name: constants.IsvcNameHeader,
Value: "test-isvc",
},
{
Name: constants.IsvcNamespaceHeader,
Value: "default",
},
},
},
},
},
BackendRefs: []gwapiv1.HTTPBackendRef{
{
BackendRef: gwapiv1.BackendRef{
BackendObjectReference: gwapiv1.BackendObjectReference{
Kind: ptr.To(gwapiv1.Kind(constants.ServiceKind)),
Name: "test-isvc-transformer",
Namespace: (*gwapiv1.Namespace)(ptr.To("default")),
Port: ptr.To(int32(constants.CommonDefaultHttpPort)),
},
},
},
},
Timeouts: &gwapiv1.HTTPRouteTimeouts{
Request: ptr.To(gwapiv1.Duration("60s")),
},
},
{
Matches: []gwapiv1.HTTPRouteMatch{
{
Path: &gwapiv1.HTTPPathMatch{
Type: ptr.To(gwapiv1.PathMatchRegularExpression),
Value: ptr.To("/serving/default/test-isvc/"),
},
},
},
Filters: []gwapiv1.HTTPRouteFilter{
{
Type: gwapiv1.HTTPRouteFilterRequestHeaderModifier,
RequestHeaderModifier: &gwapiv1.HTTPHeaderFilter{
Set: []gwapiv1.HTTPHeader{
{
Name: constants.IsvcNameHeader,
Value: "test-isvc",
},
{
Name: constants.IsvcNamespaceHeader,
Value: "default",
},
},
},
},
},
BackendRefs: []gwapiv1.HTTPBackendRef{
{
BackendRef: gwapiv1.BackendRef{
BackendObjectReference: gwapiv1.BackendObjectReference{
Kind: ptr.To(gwapiv1.Kind(constants.ServiceKind)),
Name: "test-isvc-transformer",
Namespace: (*gwapiv1.Namespace)(ptr.To("default")),
Port: ptr.To(int32(constants.CommonDefaultHttpPort)),
},
},
},
},
Timeouts: &gwapiv1.HTTPRouteTimeouts{
Request: ptr.To(gwapiv1.Duration("60s")),
},
},
},
CommonRouteSpec: gwapiv1.CommonRouteSpec{
ParentRefs: []gwapiv1.ParentReference{
{
Name: "kserve-gateway",
Kind: ptr.To(gwapiv1.Kind(constants.GatewayKind)),
Group: (*gwapiv1.Group)(&gwapiv1.GroupVersion.Group),
Namespace: ptr.To(gwapiv1.Namespace("kserve")),
},
},
},
},
},
},
}
for name, tc := range testCases {
t.Run(name, func(t *testing.T) {
s := scheme.Scheme
s.AddKnownTypes(v1beta1.SchemeGroupVersion, &v1beta1.InferenceService{})
s.AddKnownTypes(schema.GroupVersion{Group: gwapiv1.GroupVersion.Group, Version: gwapiv1.GroupVersion.Version},
&gwapiv1.HTTPRoute{})
client := fake.NewClientBuilder().WithScheme(s).Build()
// Create a dummy service to test default suffix case
_ = client.Create(t.Context(), &corev1.Service{
ObjectMeta: metav1.ObjectMeta{
Name: "test-isvc-default-predictor-default",
Namespace: "default",
},
Spec: corev1.ServiceSpec{},
})
isvcConfig := &v1beta1.InferenceServicesConfig{
ServiceAnnotationDisallowedList: []string{},
ServiceLabelDisallowedList: []string{},
}
ctx := context.TODO()
fakeClient := fake.NewClientBuilder().Build()
httpRoute, err := createRawTopLevelHTTPRoute(ctx, fakeClient, tc.isvc, tc.ingressConfig, isvcConfig)
g.Expect(err).ToNot(HaveOccurred())
if tc.expected != nil {
g.Expect(httpRoute.Spec).To(BeComparableTo(tc.expected.Spec))
g.Expect(httpRoute.ObjectMeta).To(BeComparableTo(tc.expected.ObjectMeta, cmpopts.IgnoreFields(httpRoute.ObjectMeta, "CreationTimestamp")))
} else {
g.Expect(httpRoute).To(BeNil())
}
})
}
}
func TestCreateRawPredictorHTTPRoute(t *testing.T) {
format.MaxLength = 0
g := NewGomegaWithT(t)
testCases := map[string]struct {
isvc *v1beta1.InferenceService
ingressConfig *v1beta1.IngressConfig
expected *gwapiv1.HTTPRoute
}{
"Predictor ready": {
isvc: &v1beta1.InferenceService{
ObjectMeta: metav1.ObjectMeta{
Name: "test-isvc",
Namespace: "default",
},
Spec: v1beta1.InferenceServiceSpec{
Predictor: v1beta1.PredictorSpec{},
},
Status: v1beta1.InferenceServiceStatus{
Status: duckv1.Status{
Conditions: []apis.Condition{
{
Type: v1beta1.PredictorReady,
Status: corev1.ConditionTrue,
},
},
},
},
},
ingressConfig: &v1beta1.IngressConfig{
IngressDomain: "example.com",
UrlScheme: "http",
DomainTemplate: "{{.Name}}-{{.Namespace}}.{{.IngressDomain}}",
KserveIngressGateway: "kserve/kserve-gateway",
EnableGatewayAPI: true,
},
expected: &gwapiv1.HTTPRoute{
ObjectMeta: metav1.ObjectMeta{
Name: "test-isvc-predictor",
Namespace: "default",
Annotations: map[string]string{},
Labels: map[string]string{},
},
Spec: gwapiv1.HTTPRouteSpec{
Hostnames: []gwapiv1.Hostname{"test-isvc-predictor-default.example.com"},
Rules: []gwapiv1.HTTPRouteRule{
{
Matches: []gwapiv1.HTTPRouteMatch{
{
Path: &gwapiv1.HTTPPathMatch{
Type: ptr.To(gwapiv1.PathMatchRegularExpression),
Value: ptr.To("^/.*$"),
},
},
},
Filters: []gwapiv1.HTTPRouteFilter{
{
Type: gwapiv1.HTTPRouteFilterRequestHeaderModifier,
RequestHeaderModifier: &gwapiv1.HTTPHeaderFilter{
Set: []gwapiv1.HTTPHeader{
{
Name: constants.IsvcNameHeader,
Value: "test-isvc",
},
{
Name: constants.IsvcNamespaceHeader,
Value: "default",
},
},
},
},
},
BackendRefs: []gwapiv1.HTTPBackendRef{
{
BackendRef: gwapiv1.BackendRef{
BackendObjectReference: gwapiv1.BackendObjectReference{
Kind: ptr.To(gwapiv1.Kind(constants.ServiceKind)),
Name: "test-isvc-predictor",
Namespace: (*gwapiv1.Namespace)(ptr.To("default")),
Port: ptr.To(int32(constants.CommonDefaultHttpPort)),
},
},
},
},
Timeouts: &gwapiv1.HTTPRouteTimeouts{
Request: ptr.To(gwapiv1.Duration("60s")),
},
},
},
CommonRouteSpec: gwapiv1.CommonRouteSpec{
ParentRefs: []gwapiv1.ParentReference{
{
Group: (*gwapiv1.Group)(&gwapiv1.GroupVersion.Group),
Kind: (*gwapiv1.Kind)(ptr.To(constants.GatewayKind)),
Namespace: (*gwapiv1.Namespace)(ptr.To("kserve")),
Name: gwapiv1.ObjectName("kserve-gateway"),
},
},
},
},
},
},
"Predictor not ready": {
isvc: &v1beta1.InferenceService{
ObjectMeta: metav1.ObjectMeta{
Name: "test-isvc",
Namespace: "default",
},
Spec: v1beta1.InferenceServiceSpec{
Predictor: v1beta1.PredictorSpec{},
},
Status: v1beta1.InferenceServiceStatus{
Status: duckv1.Status{
Conditions: []apis.Condition{
{
Type: v1beta1.PredictorReady,
Status: corev1.ConditionFalse,
},
},
},
},
},
ingressConfig: &v1beta1.IngressConfig{
IngressDomain: "example.com",
UrlScheme: "http",
DomainTemplate: "{{.Name}}-{{.Namespace}}.{{.IngressDomain}}",
KserveIngressGateway: "kserve/kserve-gateway",
EnableGatewayAPI: true,
},
expected: nil,
},
}
for name, tc := range testCases {
t.Run(name, func(t *testing.T) {
s := scheme.Scheme
s.AddKnownTypes(v1beta1.SchemeGroupVersion, &v1beta1.InferenceService{})
s.AddKnownTypes(schema.GroupVersion{Group: gwapiv1.GroupVersion.Group, Version: gwapiv1.GroupVersion.Version},
&gwapiv1.HTTPRoute{})
client := fake.NewClientBuilder().WithScheme(s).Build()
// Create a dummy service to test default suffix case
_ = client.Create(t.Context(), &corev1.Service{
ObjectMeta: metav1.ObjectMeta{
Name: "test-isvc-default-predictor-default",
Namespace: "default",
},
Spec: corev1.ServiceSpec{},
})
isvcConfig := &v1beta1.InferenceServicesConfig{
ServiceAnnotationDisallowedList: []string{},
ServiceLabelDisallowedList: []string{},
}
ctx := context.TODO()
fakeClient := fake.NewClientBuilder().Build()
httpRoute, err := createRawPredictorHTTPRoute(ctx, fakeClient, tc.isvc, tc.ingressConfig, isvcConfig)
g.Expect(err).ToNot(HaveOccurred())
if tc.expected != nil {
g.Expect(httpRoute.Spec).To(BeComparableTo(tc.expected.Spec))
g.Expect(httpRoute.ObjectMeta).To(BeComparableTo(tc.expected.ObjectMeta, cmpopts.IgnoreFields(httpRoute.ObjectMeta, "CreationTimestamp")))
} else {
g.Expect(httpRoute).To(BeNil())
}
})
}
}
func TestCreateRawTransformerHTTPRoute(t *testing.T) {
format.MaxLength = 0
g := NewGomegaWithT(t)
testCases := map[string]struct {
isvc *v1beta1.InferenceService
ingressConfig *v1beta1.IngressConfig
expected *gwapiv1.HTTPRoute
}{
"Transformer ready": {
isvc: &v1beta1.InferenceService{
ObjectMeta: metav1.ObjectMeta{
Name: "test-isvc",
Namespace: "default",
},
Spec: v1beta1.InferenceServiceSpec{
Predictor: v1beta1.PredictorSpec{},
Transformer: &v1beta1.TransformerSpec{},
},
Status: v1beta1.InferenceServiceStatus{
Status: duckv1.Status{
Conditions: []apis.Condition{
{
Type: v1beta1.TransformerReady,
Status: corev1.ConditionTrue,
},
},
},
},
},
ingressConfig: &v1beta1.IngressConfig{
IngressDomain: "example.com",
UrlScheme: "http",
DomainTemplate: "{{.Name}}-{{.Namespace}}.{{.IngressDomain}}",
KserveIngressGateway: "kserve/kserve-gateway",
EnableGatewayAPI: true,
},
expected: &gwapiv1.HTTPRoute{
ObjectMeta: metav1.ObjectMeta{
Name: "test-isvc-transformer",
Namespace: "default",
Annotations: map[string]string{},
Labels: map[string]string{},
},
Spec: gwapiv1.HTTPRouteSpec{
Hostnames: []gwapiv1.Hostname{"test-isvc-transformer-default.example.com"},
Rules: []gwapiv1.HTTPRouteRule{
{
Matches: []gwapiv1.HTTPRouteMatch{
{
Path: &gwapiv1.HTTPPathMatch{
Type: ptr.To(gwapiv1.PathMatchRegularExpression),
Value: ptr.To("^/.*$"),
},
},
},
Filters: []gwapiv1.HTTPRouteFilter{
{
Type: gwapiv1.HTTPRouteFilterRequestHeaderModifier,
RequestHeaderModifier: &gwapiv1.HTTPHeaderFilter{
Set: []gwapiv1.HTTPHeader{
{
Name: constants.IsvcNameHeader,
Value: "test-isvc",
},
{
Name: constants.IsvcNamespaceHeader,
Value: "default",
},
},
},
},
},
BackendRefs: []gwapiv1.HTTPBackendRef{
{
BackendRef: gwapiv1.BackendRef{
BackendObjectReference: gwapiv1.BackendObjectReference{
Kind: ptr.To(gwapiv1.Kind(constants.ServiceKind)),
Name: "test-isvc-transformer",
Namespace: (*gwapiv1.Namespace)(ptr.To("default")),
Port: ptr.To(int32(constants.CommonDefaultHttpPort)),
},
},
},
},
Timeouts: &gwapiv1.HTTPRouteTimeouts{
Request: ptr.To(gwapiv1.Duration("60s")),
},
},
},
CommonRouteSpec: gwapiv1.CommonRouteSpec{
ParentRefs: []gwapiv1.ParentReference{
{
Group: (*gwapiv1.Group)(&gwapiv1.GroupVersion.Group),
Kind: (*gwapiv1.Kind)(ptr.To(constants.GatewayKind)),
Namespace: (*gwapiv1.Namespace)(ptr.To("kserve")),
Name: gwapiv1.ObjectName("kserve-gateway"),
},
},
},
},
},
},
"Transformer not ready": {
isvc: &v1beta1.InferenceService{
ObjectMeta: metav1.ObjectMeta{
Name: "test-isvc",
Namespace: "default",
},
Spec: v1beta1.InferenceServiceSpec{
Predictor: v1beta1.PredictorSpec{},
Transformer: &v1beta1.TransformerSpec{},
},
Status: v1beta1.InferenceServiceStatus{
Status: duckv1.Status{
Conditions: []apis.Condition{
{
Type: v1beta1.TransformerReady,
Status: corev1.ConditionFalse,
},
},
},
},
},
ingressConfig: &v1beta1.IngressConfig{
IngressDomain: "example.com",
UrlScheme: "http",
DomainTemplate: "{{.Name}}-{{.Namespace}}.{{.IngressDomain}}",
KserveIngressGateway: "kserve/kserve-gateway",
EnableGatewayAPI: true,
},
expected: nil,
},
}
for name, tc := range testCases {
t.Run(name, func(t *testing.T) {
s := scheme.Scheme
s.AddKnownTypes(v1beta1.SchemeGroupVersion, &v1beta1.InferenceService{})
s.AddKnownTypes(schema.GroupVersion{Group: gwapiv1.GroupVersion.Group, Version: gwapiv1.GroupVersion.Version},
&gwapiv1.HTTPRoute{})
client := fake.NewClientBuilder().WithScheme(s).Build()
// Create a dummy service to test default suffix case
_ = client.Create(t.Context(), &corev1.Service{
ObjectMeta: metav1.ObjectMeta{
Name: "test-isvc-default-transformer-default",
Namespace: "default",
},
Spec: corev1.ServiceSpec{},
})
isvcConfig := &v1beta1.InferenceServicesConfig{
ServiceAnnotationDisallowedList: []string{},
ServiceLabelDisallowedList: []string{},
}
ctx := context.TODO()
fakeClient := fake.NewClientBuilder().Build()
httpRoute, err := createRawTransformerHTTPRoute(ctx, fakeClient, tc.isvc, tc.ingressConfig, isvcConfig)
g.Expect(err).ToNot(HaveOccurred())
if tc.expected != nil {
g.Expect(httpRoute.Spec).To(BeComparableTo(tc.expected.Spec))
g.Expect(httpRoute.ObjectMeta).To(BeComparableTo(tc.expected.ObjectMeta, cmpopts.IgnoreFields(httpRoute.ObjectMeta, "CreationTimestamp")))
} else {
g.Expect(httpRoute).To(BeNil())
}
})
}
}
func TestCreateRawExplainerHTTPRoute(t *testing.T) {
format.MaxLength = 0
g := NewGomegaWithT(t)
testCases := map[string]struct {
isvc *v1beta1.InferenceService
ingressConfig *v1beta1.IngressConfig
expected *gwapiv1.HTTPRoute
}{
"Explainer ready": {
isvc: &v1beta1.InferenceService{
ObjectMeta: metav1.ObjectMeta{
Name: "test-isvc",
Namespace: "default",
},
Spec: v1beta1.InferenceServiceSpec{
Predictor: v1beta1.PredictorSpec{},
Explainer: &v1beta1.ExplainerSpec{},
},
Status: v1beta1.InferenceServiceStatus{
Status: duckv1.Status{
Conditions: []apis.Condition{
{
Type: v1beta1.ExplainerReady,
Status: corev1.ConditionTrue,
},
},
},
},
},
ingressConfig: &v1beta1.IngressConfig{
IngressDomain: "example.com",
UrlScheme: "http",
DomainTemplate: "{{.Name}}-{{.Namespace}}.{{.IngressDomain}}",
KserveIngressGateway: "kserve/kserve-gateway",
EnableGatewayAPI: true,
},
expected: &gwapiv1.HTTPRoute{
ObjectMeta: metav1.ObjectMeta{
Name: "test-isvc-explainer",
Namespace: "default",
Annotations: map[string]string{},
Labels: map[string]string{},
},
Spec: gwapiv1.HTTPRouteSpec{
Hostnames: []gwapiv1.Hostname{"test-isvc-explainer-default.example.com"},
Rules: []gwapiv1.HTTPRouteRule{
{
Matches: []gwapiv1.HTTPRouteMatch{
{
Path: &gwapiv1.HTTPPathMatch{
Type: ptr.To(gwapiv1.PathMatchRegularExpression),
Value: ptr.To("^/.*$"),
},
},
},
Filters: []gwapiv1.HTTPRouteFilter{
{
Type: gwapiv1.HTTPRouteFilterRequestHeaderModifier,
RequestHeaderModifier: &gwapiv1.HTTPHeaderFilter{
Set: []gwapiv1.HTTPHeader{
{
Name: constants.IsvcNameHeader,
Value: "test-isvc",
},
{
Name: constants.IsvcNamespaceHeader,
Value: "default",
},
},
},
},
},
BackendRefs: []gwapiv1.HTTPBackendRef{
{
BackendRef: gwapiv1.BackendRef{
BackendObjectReference: gwapiv1.BackendObjectReference{
Kind: ptr.To(gwapiv1.Kind(constants.ServiceKind)),
Name: "test-isvc-explainer",
Namespace: (*gwapiv1.Namespace)(ptr.To("default")),
Port: ptr.To(int32(constants.CommonDefaultHttpPort)),
},
},
},
},
Timeouts: &gwapiv1.HTTPRouteTimeouts{
Request: ptr.To(gwapiv1.Duration("60s")),
},
},
},
CommonRouteSpec: gwapiv1.CommonRouteSpec{
ParentRefs: []gwapiv1.ParentReference{
{
Group: (*gwapiv1.Group)(&gwapiv1.GroupVersion.Group),
Kind: (*gwapiv1.Kind)(ptr.To(constants.GatewayKind)),
Namespace: (*gwapiv1.Namespace)(ptr.To("kserve")),
Name: gwapiv1.ObjectName("kserve-gateway"),
},
},
},
},
},
},
"Explainer not ready": {
isvc: &v1beta1.InferenceService{
ObjectMeta: metav1.ObjectMeta{
Name: "test-isvc",
Namespace: "default",
},
Spec: v1beta1.InferenceServiceSpec{
Predictor: v1beta1.PredictorSpec{},
Explainer: &v1beta1.ExplainerSpec{},
},
Status: v1beta1.InferenceServiceStatus{
Status: duckv1.Status{
Conditions: []apis.Condition{
{
Type: v1beta1.ExplainerReady,
Status: corev1.ConditionFalse,
},
},
},
},
},
ingressConfig: &v1beta1.IngressConfig{
IngressDomain: "example.com",
UrlScheme: "http",
DomainTemplate: "{{.Name}}-{{.Namespace}}.{{.IngressDomain}}",
KserveIngressGateway: "kserve/kserve-gateway",
EnableGatewayAPI: true,
},
expected: nil,
},
}
for name, tc := range testCases {
t.Run(name, func(t *testing.T) {
s := scheme.Scheme
s.AddKnownTypes(v1beta1.SchemeGroupVersion, &v1beta1.InferenceService{})
s.AddKnownTypes(schema.GroupVersion{Group: gwapiv1.GroupVersion.Group, Version: gwapiv1.GroupVersion.Version},
&gwapiv1.HTTPRoute{})
client := fake.NewClientBuilder().WithScheme(s).Build()
// Create a dummy service to test default suffix case
_ = client.Create(t.Context(), &corev1.Service{
ObjectMeta: metav1.ObjectMeta{
Name: "test-isvc-default-explainer-default",
Namespace: "default",
},
Spec: corev1.ServiceSpec{},
})
isvcConfig := &v1beta1.InferenceServicesConfig{
ServiceAnnotationDisallowedList: []string{},
ServiceLabelDisallowedList: []string{},
}
ctx := context.TODO()
fakeClient := fake.NewClientBuilder().Build()
httpRoute, err := createRawExplainerHTTPRoute(ctx, fakeClient, tc.isvc, tc.ingressConfig, isvcConfig)
g.Expect(err).ToNot(HaveOccurred())
if tc.expected != nil {
g.Expect(httpRoute.Spec).To(BeComparableTo(tc.expected.Spec))
g.Expect(httpRoute.ObjectMeta).To(BeComparableTo(tc.expected.ObjectMeta, cmpopts.IgnoreFields(httpRoute.ObjectMeta, "CreationTimestamp")))
} else {
g.Expect(httpRoute).To(BeNil())
}
})
}
}
func TestRawHTTPRouteReconciler_reconcilePredictorHTTPRoute(t *testing.T) {
g := NewGomegaWithT(t)
s := scheme.Scheme
_ = v1beta1.AddToScheme(s)
_ = gwapiv1.Install(s)
_ = corev1.AddToScheme(s)
ingressConfig := &v1beta1.IngressConfig{
IngressDomain: "example.com",
UrlScheme: "http",
DomainTemplate: "{{.Name}}-{{.Namespace}}.{{.IngressDomain}}",
KserveIngressGateway: "kserve/knative-gateway",
}
isvcConfig := &v1beta1.InferenceServicesConfig{}
t.Run("creates predictor HTTPRoute if not exists", func(t *testing.T) {
client := fake.NewClientBuilder().WithScheme(s).Build()
isvc := &v1beta1.InferenceService{
ObjectMeta: metav1.ObjectMeta{
Name: "foo",
Namespace: "default",
},
Spec: v1beta1.InferenceServiceSpec{
Predictor: v1beta1.PredictorSpec{},
},
Status: v1beta1.InferenceServiceStatus{},
}
isvc.Status.SetCondition(v1beta1.PredictorReady, &apis.Condition{
Type: v1beta1.PredictorReady,
Status: corev1.ConditionTrue,
})
reconciler := &RawHTTPRouteReconciler{
client: client,
scheme: s,
ingressConfig: ingressConfig,
isvcConfig: isvcConfig,
}
err := reconciler.reconcilePredictorHTTPRoute(t.Context(), isvc)
g.Expect(err).ToNot(HaveOccurred())
route := &gwapiv1.HTTPRoute{}
err = client.Get(t.Context(), types.NamespacedName{
Name: "foo-predictor",
Namespace: "default",
}, route)
g.Expect(err).ToNot(HaveOccurred())
g.Expect(route.Spec.Hostnames).To(ContainElement(gwapiv1.Hostname("foo-predictor-default.example.com")))
})
t.Run("does nothing if desired is nil (PredictorReady false)", func(t *testing.T) {
client := fake.NewClientBuilder().WithScheme(s).Build()
isvc := &v1beta1.InferenceService{
ObjectMeta: metav1.ObjectMeta{
Name: "foo",
Namespace: "default",
},
Spec: v1beta1.InferenceServiceSpec{
Predictor: v1beta1.PredictorSpec{},
},
Status: v1beta1.InferenceServiceStatus{},
}
// PredictorReady is not set or false
reconciler := &RawHTTPRouteReconciler{
client: client,
scheme: s,
ingressConfig: ingressConfig,
isvcConfig: isvcConfig,
}
err := reconciler.reconcilePredictorHTTPRoute(t.Context(), isvc)
g.Expect(err).ToNot(HaveOccurred())
route := &gwapiv1.HTTPRoute{}
err = client.Get(t.Context(), types.NamespacedName{
Name: "foo-predictor",
Namespace: "default",
}, route)
g.Expect(apierr.IsNotFound(err)).To(BeTrue())
})
t.Run("updates HTTPRoute if spec changes", func(t *testing.T) {
client := fake.NewClientBuilder().WithScheme(s).Build()
isvc := &v1beta1.InferenceService{
ObjectMeta: metav1.ObjectMeta{
Name: "foo",
Namespace: "default",
},
Spec: v1beta1.InferenceServiceSpec{
Predictor: v1beta1.PredictorSpec{},
},
Status: v1beta1.InferenceServiceStatus{},
}
isvc.Status.SetCondition(v1beta1.PredictorReady, &apis.Condition{
Type: v1beta1.PredictorReady,
Status: corev1.ConditionTrue,
})
// Create an existing HTTPRoute with a different hostname
existing := &gwapiv1.HTTPRoute{
ObjectMeta: metav1.ObjectMeta{
Name: "foo-predictor",
Namespace: "default",
},
Spec: gwapiv1.HTTPRouteSpec{
Hostnames: []gwapiv1.Hostname{"old-host.example.com"},
},
}
_ = client.Create(t.Context(), existing)
reconciler := &RawHTTPRouteReconciler{
client: client,
scheme: s,
ingressConfig: ingressConfig,
isvcConfig: isvcConfig,
}
err := reconciler.reconcilePredictorHTTPRoute(t.Context(), isvc)
g.Expect(err).ToNot(HaveOccurred())
route := &gwapiv1.HTTPRoute{}
err = client.Get(t.Context(), types.NamespacedName{
Name: "foo-predictor",
Namespace: "default",
}, route)
g.Expect(err).ToNot(HaveOccurred())
g.Expect(route.Spec.Hostnames).To(ContainElement(gwapiv1.Hostname("foo-predictor-default.example.com")))
})
}
func TestRawHTTPRouteReconciler_reconcileTransformerHTTPRoute(t *testing.T) {
g := NewGomegaWithT(t)
s := scheme.Scheme
s.AddKnownTypes(v1beta1.SchemeGroupVersion, &v1beta1.InferenceService{})
s.AddKnownTypes(schema.GroupVersion{Group: gwapiv1.GroupVersion.Group, Version: gwapiv1.GroupVersion.Version}, &gwapiv1.HTTPRoute{})
s.AddKnownTypes(corev1.SchemeGroupVersion, &corev1.Service{})
ingressConfig := &v1beta1.IngressConfig{
IngressDomain: "example.com",
UrlScheme: "http",
DomainTemplate: "{{.Name}}-{{.Namespace}}.{{.IngressDomain}}",
KserveIngressGateway: "kserve/knative-gateway",
}
isvcConfig := &v1beta1.InferenceServicesConfig{}
t.Run("creates transformer HTTPRoute if not exists", func(t *testing.T) {
isvc := &v1beta1.InferenceService{
ObjectMeta: metav1.ObjectMeta{
Name: "test-isvc",
Namespace: "default",
},
Spec: v1beta1.InferenceServiceSpec{
Predictor: v1beta1.PredictorSpec{},
Transformer: &v1beta1.TransformerSpec{},
},
Status: v1beta1.InferenceServiceStatus{
Status: duckv1.Status{
Conditions: duckv1.Conditions{
{
Type: v1beta1.TransformerReady,
Status: corev1.ConditionTrue,
},
},
},
},
}
client := fake.NewClientBuilder().WithScheme(s).Build()
// Create a dummy transformer service so the reconciler finds it
_ = client.Create(t.Context(), &corev1.Service{
ObjectMeta: metav1.ObjectMeta{
Name: "test-isvc-transformer",
Namespace: "default",
},
})
reconciler := &RawHTTPRouteReconciler{
client: client,
scheme: s,
ingressConfig: ingressConfig,
isvcConfig: isvcConfig,
}
err := reconciler.reconcileTransformerHTTPRoute(t.Context(), isvc)
g.Expect(err).ToNot(HaveOccurred())
route := &gwapiv1.HTTPRoute{}
err = client.Get(t.Context(), types.NamespacedName{
Name: "test-isvc-transformer",
Namespace: "default",
}, route)
g.Expect(err).ToNot(HaveOccurred())
g.Expect(route.Spec.Hostnames).ToNot(BeEmpty())
})
t.Run("updates transformer HTTPRoute if spec changed", func(t *testing.T) {
isvc := &v1beta1.InferenceService{
ObjectMeta: metav1.ObjectMeta{
Name: "test-isvc2",
Namespace: "default",
},
Spec: v1beta1.InferenceServiceSpec{
Predictor: v1beta1.PredictorSpec{},
Transformer: &v1beta1.TransformerSpec{},
},
Status: v1beta1.InferenceServiceStatus{
Status: duckv1.Status{
Conditions: duckv1.Conditions{
{
Type: v1beta1.TransformerReady,
Status: corev1.ConditionTrue,
},
},
},
},
}
client := fake.NewClientBuilder().WithScheme(s).Build()
_ = client.Create(t.Context(), &corev1.Service{
ObjectMeta: metav1.ObjectMeta{
Name: "test-isvc2-transformer",
Namespace: "default",
},
})
// Create an existing HTTPRoute with different spec
existingRoute := &gwapiv1.HTTPRoute{
ObjectMeta: metav1.ObjectMeta{
Name: "test-isvc2-transformer",
Namespace: "default",
},
Spec: gwapiv1.HTTPRouteSpec{
Hostnames: []gwapiv1.Hostname{"oldhost.example.com"},
},
}
_ = client.Create(t.Context(), existingRoute)
reconciler := &RawHTTPRouteReconciler{
client: client,
scheme: s,
ingressConfig: ingressConfig,
isvcConfig: isvcConfig,
}
err := reconciler.reconcileTransformerHTTPRoute(t.Context(), isvc)
g.Expect(err).ToNot(HaveOccurred())
route := &gwapiv1.HTTPRoute{}
err = client.Get(t.Context(), types.NamespacedName{
Name: "test-isvc2-transformer",
Namespace: "default",
}, route)
g.Expect(err).ToNot(HaveOccurred())
g.Expect(route.Spec.Hostnames).To(ContainElement(gwapiv1.Hostname("test-isvc2-transformer-default.example.com")))
})
t.Run("does nothing if desired is nil", func(t *testing.T) {
isvc := &v1beta1.InferenceService{
ObjectMeta: metav1.ObjectMeta{
Name: "test-isvc3",
Namespace: "default",
},
Spec: v1beta1.InferenceServiceSpec{
Predictor: v1beta1.PredictorSpec{},
Transformer: &v1beta1.TransformerSpec{},
},
Status: v1beta1.InferenceServiceStatus{
Status: duckv1.Status{
Conditions: duckv1.Conditions{
{
Type: v1beta1.TransformerReady,
Status: corev1.ConditionFalse,
},
},
},
},
}
client := fake.NewClientBuilder().WithScheme(s).Build()
reconciler := &RawHTTPRouteReconciler{
client: client,
scheme: s,
ingressConfig: ingressConfig,
isvcConfig: isvcConfig,
}
err := reconciler.reconcileTransformerHTTPRoute(t.Context(), isvc)
g.Expect(err).ToNot(HaveOccurred())
})
}
func TestRawHTTPRouteReconciler_reconcileExplainerHTTPRoute(t *testing.T) {
g := NewGomegaWithT(t)
s := scheme.Scheme
s.AddKnownTypes(v1beta1.SchemeGroupVersion, &v1beta1.InferenceService{})
s.AddKnownTypes(schema.GroupVersion{Group: gwapiv1.GroupVersion.Group, Version: gwapiv1.GroupVersion.Version}, &gwapiv1.HTTPRoute{})
ctx := t.Context()
ingressConfig := &v1beta1.IngressConfig{
IngressDomain: "example.com",
UrlScheme: "http",
DomainTemplate: "{{.Name}}-{{.Namespace}}.{{.IngressDomain}}",
KserveIngressGateway: "kserve-system/kserve-gateway",
}
isvcConfig := &v1beta1.InferenceServicesConfig{}
t.Run("creates explainer HTTPRoute when ready", func(t *testing.T) {
isvc := &v1beta1.InferenceService{
ObjectMeta: metav1.ObjectMeta{
Name: "foo",
Namespace: "bar",
},
Spec: v1beta1.InferenceServiceSpec{
Predictor: v1beta1.PredictorSpec{},
Explainer: &v1beta1.ExplainerSpec{},
},
Status: v1beta1.InferenceServiceStatus{},
}
isvc.Status.SetCondition(v1beta1.ExplainerReady, &apis.Condition{
Type: v1beta1.ExplainerReady,
Status: corev1.ConditionTrue,
})
client := fake.NewClientBuilder().WithScheme(s).Build()
// Create explainer service so default suffix is not used
_ = client.Create(ctx, &corev1.Service{
ObjectMeta: metav1.ObjectMeta{
Name: "foo-explainer",
Namespace: "bar",
},
})
reconciler := &RawHTTPRouteReconciler{
client: client,
scheme: s,
ingressConfig: ingressConfig,
isvcConfig: isvcConfig,
}
err := reconciler.reconcileExplainerHTTPRoute(ctx, isvc)
g.Expect(err).ToNot(HaveOccurred())
route := &gwapiv1.HTTPRoute{}
err = client.Get(ctx, types.NamespacedName{Name: "foo-explainer", Namespace: "bar"}, route)
g.Expect(err).ToNot(HaveOccurred())
g.Expect(route.Spec.Hostnames).ToNot(BeEmpty())
})
t.Run("does not create HTTPRoute if explainer not ready", func(t *testing.T) {
isvc := &v1beta1.InferenceService{
ObjectMeta: metav1.ObjectMeta{
Name: "foo",
Namespace: "bar",
},
Spec: v1beta1.InferenceServiceSpec{
Predictor: v1beta1.PredictorSpec{},
Explainer: &v1beta1.ExplainerSpec{},
},
Status: v1beta1.InferenceServiceStatus{},
}
// ExplainerReady not set or false
client := fake.NewClientBuilder().WithScheme(s).Build()
reconciler := &RawHTTPRouteReconciler{
client: client,
scheme: s,
ingressConfig: ingressConfig,
isvcConfig: isvcConfig,
}
err := reconciler.reconcileExplainerHTTPRoute(ctx, isvc)
g.Expect(err).ToNot(HaveOccurred())
route := &gwapiv1.HTTPRoute{}
err = client.Get(ctx, types.NamespacedName{Name: "foo-explainer", Namespace: "bar"}, route)
g.Expect(apierr.IsNotFound(err)).To(BeTrue())
})
t.Run("uses default suffix if explainer service with default suffix exists", func(t *testing.T) {
isvc := &v1beta1.InferenceService{
ObjectMeta: metav1.ObjectMeta{
Name: "foo",
Namespace: "bar",
},
Spec: v1beta1.InferenceServiceSpec{
Predictor: v1beta1.PredictorSpec{},
Explainer: &v1beta1.ExplainerSpec{},
},
Status: v1beta1.InferenceServiceStatus{},
}
isvc.Status.SetCondition(v1beta1.ExplainerReady, &apis.Condition{
Type: v1beta1.ExplainerReady,
Status: corev1.ConditionTrue,
})
// Register HTTPRoute and corev1.Service types in the scheme for this test
s.AddKnownTypes(schema.GroupVersion{Group: gwapiv1.GroupVersion.Group, Version: gwapiv1.GroupVersion.Version}, &gwapiv1.HTTPRoute{})
s.AddKnownTypes(corev1.SchemeGroupVersion, &corev1.Service{})
client := fake.NewClientBuilder().WithScheme(s).
WithObjects(&corev1.Service{
ObjectMeta: metav1.ObjectMeta{
Name: "foo-explainer-default",
Namespace: "bar",
},
}).Build()
reconciler := &RawHTTPRouteReconciler{
client: client,
scheme: s,
ingressConfig: ingressConfig,
isvcConfig: isvcConfig,
}
err := reconciler.reconcileExplainerHTTPRoute(ctx, isvc)
g.Expect(err).ToNot(HaveOccurred())
route := &gwapiv1.HTTPRoute{}
_ = client.Get(ctx, types.NamespacedName{Name: "foo-explainer-default", Namespace: "bar"}, route)
})
}
func TestRawHTTPRouteReconciler_reconcileTopLevelHTTPRoute(t *testing.T) {
g := NewGomegaWithT(t)
s := scheme.Scheme
_ = gwapiv1.Install(s)
_ = v1beta1.AddToScheme(s)
ingressConfig := &v1beta1.IngressConfig{
IngressDomain: "example.com",
UrlScheme: "http",
DomainTemplate: "{{.Name}}-{{.Namespace}}.{{.IngressDomain}}",
KserveIngressGateway: "kserve/kserve-gateway",
}
isvcConfig := &v1beta1.InferenceServicesConfig{}
t.Run("creates top-level HTTPRoute for predictor only", func(t *testing.T) {
isvc := &v1beta1.InferenceService{
ObjectMeta: metav1.ObjectMeta{
Name: "test-isvc",
Namespace: "default",
},
Spec: v1beta1.InferenceServiceSpec{
Predictor: v1beta1.PredictorSpec{},
},
Status: v1beta1.InferenceServiceStatus{},
}
isvc.Status.SetCondition(v1beta1.PredictorReady, &apis.Condition{
Type: v1beta1.PredictorReady,
Status: corev1.ConditionTrue,
})
client := fake.NewClientBuilder().WithScheme(s).WithObjects(
&corev1.Service{
ObjectMeta: metav1.ObjectMeta{
Name: "test-isvc-predictor",
Namespace: "default",
},
},
).Build()
reconciler := &RawHTTPRouteReconciler{
client: client,
scheme: s,
ingressConfig: ingressConfig,
isvcConfig: isvcConfig,
}
err := reconciler.reconcileTopLevelHTTPRoute(t.Context(), isvc)
g.Expect(err).ToNot(HaveOccurred())
route := &gwapiv1.HTTPRoute{}
err = client.Get(t.Context(), types.NamespacedName{
Name: "test-isvc",
Namespace: "default",
}, route)
g.Expect(err).ToNot(HaveOccurred())
g.Expect(route.Spec.Hostnames).To(ContainElement(gwapiv1.Hostname("test-isvc-default.example.com")))
})
t.Run("creates top-level HTTPRoute for predictor+transformer+explainer", func(t *testing.T) {
isvc := &v1beta1.InferenceService{
ObjectMeta: metav1.ObjectMeta{
Name: "test-isvc2",
Namespace: "default",
},
Spec: v1beta1.InferenceServiceSpec{
Predictor: v1beta1.PredictorSpec{},
Transformer: &v1beta1.TransformerSpec{},
Explainer: &v1beta1.ExplainerSpec{},
},
Status: v1beta1.InferenceServiceStatus{},
}
isvc.Status.SetCondition(v1beta1.PredictorReady, &apis.Condition{
Type: v1beta1.PredictorReady,
Status: corev1.ConditionTrue,
})
isvc.Status.SetCondition(v1beta1.TransformerReady, &apis.Condition{
Type: v1beta1.TransformerReady,
Status: corev1.ConditionTrue,
})
isvc.Status.SetCondition(v1beta1.ExplainerReady, &apis.Condition{
Type: v1beta1.ExplainerReady,
Status: corev1.ConditionTrue,
})
client := fake.NewClientBuilder().WithScheme(s).WithObjects(
&corev1.Service{
ObjectMeta: metav1.ObjectMeta{
Name: "test-isvc2-predictor",
Namespace: "default",
},
},
&corev1.Service{
ObjectMeta: metav1.ObjectMeta{
Name: "test-isvc2-transformer",
Namespace: "default",
},
},
&corev1.Service{
ObjectMeta: metav1.ObjectMeta{
Name: "test-isvc2-explainer",
Namespace: "default",
},
},
).Build()
reconciler := &RawHTTPRouteReconciler{
client: client,
scheme: s,
ingressConfig: ingressConfig,
isvcConfig: isvcConfig,
}
err := reconciler.reconcileTopLevelHTTPRoute(t.Context(), isvc)
g.Expect(err).ToNot(HaveOccurred())
route := &gwapiv1.HTTPRoute{}
err = client.Get(t.Context(), types.NamespacedName{
Name: "test-isvc2",
Namespace: "default",
}, route)
g.Expect(err).ToNot(HaveOccurred())
g.Expect(route.Spec.Hostnames).To(ContainElement(gwapiv1.Hostname("test-isvc2-default.example.com")))
g.Expect(route.Spec.Rules).ToNot(BeEmpty())
})
t.Run("does not create HTTPRoute if predictor not ready", func(t *testing.T) {
isvc := &v1beta1.InferenceService{
ObjectMeta: metav1.ObjectMeta{
Name: "test-isvc3",
Namespace: "default",
},
Spec: v1beta1.InferenceServiceSpec{
Predictor: v1beta1.PredictorSpec{},
},
Status: v1beta1.InferenceServiceStatus{},
}
client := fake.NewClientBuilder().WithScheme(s).Build()
reconciler := &RawHTTPRouteReconciler{
client: client,
scheme: s,
ingressConfig: ingressConfig,
isvcConfig: isvcConfig,
}
err := reconciler.reconcileTopLevelHTTPRoute(t.Context(), isvc)
g.Expect(err).ToNot(HaveOccurred())
route := &gwapiv1.HTTPRoute{}
err = client.Get(t.Context(), types.NamespacedName{
Name: "test-isvc3",
Namespace: "default",
}, route)
g.Expect(apierr.IsNotFound(err)).To(BeTrue())
})
}
func TestRawHTTPRouteReconciler_Reconcile(t *testing.T) {
g := NewGomegaWithT(t)
// Setup minimal IngressConfig and InferenceServicesConfig
ingressConfig := &v1beta1.IngressConfig{
IngressDomain: "example.com",
UrlScheme: "http",
DomainTemplate: "{{.Name}}-{{.Namespace}}.{{.IngressDomain}}",
KserveIngressGateway: "kserve/knative-gateway",
}
isvcConfig := &v1beta1.InferenceServicesConfig{
ServiceAnnotationDisallowedList: []string{},
ServiceLabelDisallowedList: []string{},
}
// Setup scheme and fake client
s := scheme.Scheme
s.AddKnownTypes(v1beta1.SchemeGroupVersion, &v1beta1.InferenceService{})
s.AddKnownTypes(schema.GroupVersion{Group: gwapiv1.GroupVersion.Group, Version: gwapiv1.GroupVersion.Version}, &gwapiv1.HTTPRoute{})
s.AddKnownTypes(corev1.SchemeGroupVersion, &corev1.Service{})
// Helper to create a ready HTTPRoute status
readyHTTPRoute := func(name, namespace string) *gwapiv1.HTTPRoute {
return &gwapiv1.HTTPRoute{
ObjectMeta: metav1.ObjectMeta{
Name: name,
Namespace: namespace,
},
Status: gwapiv1.HTTPRouteStatus{
RouteStatus: gwapiv1.RouteStatus{
Parents: []gwapiv1.RouteParentStatus{
{
Conditions: []metav1.Condition{
{
Type: string(gwapiv1.RouteConditionAccepted),
Status: metav1.ConditionTrue,
},
{
Type: string(gwapiv1.RouteConditionResolvedRefs),
Status: metav1.ConditionTrue,
},
},
},
},
},
},
}
}
// Helper to create a predictor service (needed by createAddress)
predictorService := func(isvcName, namespace string) *corev1.Service {
return &corev1.Service{
ObjectMeta: metav1.ObjectMeta{
Name: constants.PredictorServiceName(isvcName),
Namespace: namespace,
},
Spec: corev1.ServiceSpec{
ClusterIP: "10.0.0.1",
},
}
}
t.Run("Reconcile disables ingress creation for cluster-local label", func(t *testing.T) {
isvc := &v1beta1.InferenceService{
ObjectMeta: metav1.ObjectMeta{
Name: "test-isvc",
Namespace: "default",
Labels: map[string]string{
constants.NetworkVisibility: constants.ClusterLocalVisibility,
},
},
Spec: v1beta1.InferenceServiceSpec{
Predictor: v1beta1.PredictorSpec{},
},
Status: v1beta1.InferenceServiceStatus{},
}
client := fake.NewClientBuilder().WithScheme(s).WithObjects(predictorService("test-isvc", "default")).Build()
reconciler := NewRawHTTPRouteReconciler(client, s, ingressConfig, isvcConfig)
result, err := reconciler.Reconcile(t.Context(), isvc)
g.Expect(err).ToNot(HaveOccurred())
g.Expect(result).To(Equal(ctrl.Result{}))
cond := isvc.Status.GetCondition(v1beta1.IngressReady)
g.Expect(cond).NotTo(BeNil())
g.Expect(cond.Status).To(Equal(corev1.ConditionTrue))
})
t.Run("Reconcile disables ingress creation for cluster-local domain", func(t *testing.T) {
isvc := &v1beta1.InferenceService{
ObjectMeta: metav1.ObjectMeta{
Name: "test-isvc",
Namespace: "default",
},
Spec: v1beta1.InferenceServiceSpec{
Predictor: v1beta1.PredictorSpec{},
},
Status: v1beta1.InferenceServiceStatus{},
}
clusterLocalConfig := *ingressConfig
clusterLocalConfig.IngressDomain = constants.ClusterLocalDomain
client := fake.NewClientBuilder().WithScheme(s).WithObjects(predictorService("test-isvc", "default")).Build()
reconciler := NewRawHTTPRouteReconciler(client, s, &clusterLocalConfig, isvcConfig)
result, err := reconciler.Reconcile(t.Context(), isvc)
g.Expect(err).ToNot(HaveOccurred())
g.Expect(result).To(Equal(ctrl.Result{}))
cond := isvc.Status.GetCondition(v1beta1.IngressReady)
g.Expect(cond).NotTo(BeNil())
g.Expect(cond.Status).To(Equal(corev1.ConditionTrue))
})
t.Run("Reconcile returns requeue if HTTPRoute not found", func(t *testing.T) {
isvc := &v1beta1.InferenceService{
ObjectMeta: metav1.ObjectMeta{
Name: "test-isvc",
Namespace: "default",
},
Spec: v1beta1.InferenceServiceSpec{
Predictor: v1beta1.PredictorSpec{},
},
Status: v1beta1.InferenceServiceStatus{},
}
isvc.Status.SetCondition(v1beta1.PredictorReady, &apis.Condition{
Type: v1beta1.PredictorReady,
Status: corev1.ConditionTrue,
})
client := fake.NewClientBuilder().WithScheme(s).WithObjects(predictorService("test-isvc", "default")).Build()
reconciler := NewRawHTTPRouteReconciler(client, s, ingressConfig, isvcConfig)
result, err := reconciler.Reconcile(t.Context(), isvc)
g.Expect(err).ToNot(HaveOccurred())
g.Expect(result.Requeue).To(BeTrue())
cond := isvc.Status.GetCondition(v1beta1.IngressReady)
g.Expect(cond).NotTo(BeNil())
g.Expect(cond.Status).To(Equal(corev1.ConditionFalse))
// When HTTPRoutes are newly created, they have empty status which leads to ParentStatusNotAvailable
g.Expect(cond.Reason).To(Equal("ParentStatusNotAvailable"))
g.Expect(cond.Message).To(Equal("Predictor HttpRouteNotReady"))
})
t.Run("Reconcile sets IngressReady to False if HTTPRoute not ready", func(t *testing.T) {
isvc := &v1beta1.InferenceService{
ObjectMeta: metav1.ObjectMeta{
Name: "test-isvc",
Namespace: "default",
},
Spec: v1beta1.InferenceServiceSpec{
Predictor: v1beta1.PredictorSpec{},
},
Status: v1beta1.InferenceServiceStatus{},
}
isvc.Status.SetCondition(v1beta1.PredictorReady, &apis.Condition{
Type: v1beta1.PredictorReady,
Status: corev1.ConditionTrue,
})
notReadyHTTPRoute := &gwapiv1.HTTPRoute{
ObjectMeta: metav1.ObjectMeta{
Name: constants.PredictorServiceName(isvc.Name),
Namespace: isvc.Namespace,
},
Status: gwapiv1.HTTPRouteStatus{
RouteStatus: gwapiv1.RouteStatus{
Parents: []gwapiv1.RouteParentStatus{
{
Conditions: []metav1.Condition{
{
Type: string(gwapiv1.RouteConditionAccepted),
Status: metav1.ConditionFalse,
Reason: "NotAccepted",
Message: "Route not accepted",
},
},
},
},
},
},
}
client := fake.NewClientBuilder().
WithScheme(s).
WithObjects(
notReadyHTTPRoute,
readyHTTPRoute(isvc.Name, isvc.Namespace),
&corev1.Service{
ObjectMeta: metav1.ObjectMeta{
Name: constants.PredictorServiceName(isvc.Name),
Namespace: isvc.Namespace,
},
},
).
Build()
reconciler := NewRawHTTPRouteReconciler(client, s, ingressConfig, isvcConfig)
result, err := reconciler.Reconcile(t.Context(), isvc)
g.Expect(err).ToNot(HaveOccurred())
g.Expect(result.Requeue).To(BeTrue())
cond := isvc.Status.GetCondition(v1beta1.IngressReady)
g.Expect(cond).NotTo(BeNil())
g.Expect(cond.Status).To(Equal(corev1.ConditionFalse))
g.Expect(cond.Reason).To(Equal("ParentStatusNotAvailable"))
g.Expect(cond.Message).To(ContainSubstring("Predictor"))
})
t.Run("Reconcile with transformer and explainer, all HTTPRoutes ready", func(t *testing.T) {
isvc := &v1beta1.InferenceService{
ObjectMeta: metav1.ObjectMeta{
Name: "test-isvc",
Namespace: "default",
},
Spec: v1beta1.InferenceServiceSpec{
Predictor: v1beta1.PredictorSpec{},
Transformer: &v1beta1.TransformerSpec{},
Explainer: &v1beta1.ExplainerSpec{},
},
Status: v1beta1.InferenceServiceStatus{},
}
isvc.Status.SetCondition(v1beta1.PredictorReady, &apis.Condition{
Type: v1beta1.PredictorReady,
Status: corev1.ConditionTrue,
})
isvc.Status.SetCondition(v1beta1.TransformerReady, &apis.Condition{
Type: v1beta1.TransformerReady,
Status: corev1.ConditionTrue,
})
isvc.Status.SetCondition(v1beta1.ExplainerReady, &apis.Condition{
Type: v1beta1.ExplainerReady,
Status: corev1.ConditionTrue,
})
client := fake.NewClientBuilder().
WithScheme(s).
WithObjects(
readyHTTPRoute(constants.PredictorServiceName(isvc.Name), isvc.Namespace),
readyHTTPRoute(constants.TransformerServiceName(isvc.Name), isvc.Namespace),
readyHTTPRoute(constants.ExplainerServiceName(isvc.Name), isvc.Namespace),
readyHTTPRoute(isvc.Name, isvc.Namespace),
&corev1.Service{
ObjectMeta: metav1.ObjectMeta{
Name: constants.PredictorServiceName(isvc.Name),
Namespace: isvc.Namespace,
},
},
&corev1.Service{
ObjectMeta: metav1.ObjectMeta{
Name: constants.TransformerServiceName(isvc.Name),
Namespace: isvc.Namespace,
},
},
&corev1.Service{
ObjectMeta: metav1.ObjectMeta{
Name: constants.ExplainerServiceName(isvc.Name),
Namespace: isvc.Namespace,
},
},
).
Build()
reconciler := NewRawHTTPRouteReconciler(client, s, ingressConfig, isvcConfig)
result, err := reconciler.Reconcile(t.Context(), isvc)
g.Expect(err).ToNot(HaveOccurred())
// HTTPRoutes get updated by reconciler which resets their status, causing requeue
g.Expect(result.Requeue).To(BeTrue())
cond := isvc.Status.GetCondition(v1beta1.IngressReady)
g.Expect(cond).NotTo(BeNil())
g.Expect(cond.Status).To(Equal(corev1.ConditionFalse))
})
t.Run("Reconcile requeues when HTTPRoute not found", func(t *testing.T) {
isvc := &v1beta1.InferenceService{
ObjectMeta: metav1.ObjectMeta{
Name: "test-isvc-requeue",
Namespace: "default",
},
Spec: v1beta1.InferenceServiceSpec{
Predictor: v1beta1.PredictorSpec{},
},
Status: v1beta1.InferenceServiceStatus{},
}
isvc.Status.SetCondition(v1beta1.PredictorReady, &apis.Condition{
Type: v1beta1.PredictorReady,
Status: corev1.ConditionTrue,
})
// Create client without HTTPRoute objects - HTTPRoutes get created during reconciliation but have empty status
client := fake.NewClientBuilder().WithScheme(s).WithObjects(predictorService("test-isvc-requeue", "default")).Build()
reconciler := NewRawHTTPRouteReconciler(client, s, ingressConfig, isvcConfig)
result, err := reconciler.Reconcile(t.Context(), isvc)
g.Expect(err).ToNot(HaveOccurred())
g.Expect(result.Requeue).To(BeTrue())
cond := isvc.Status.GetCondition(v1beta1.IngressReady)
g.Expect(cond).NotTo(BeNil())
g.Expect(cond.Status).To(Equal(corev1.ConditionFalse))
// HTTPRoutes are created during reconciliation but have empty status, leading to ParentStatusNotAvailable
g.Expect(cond.Reason).To(Equal("ParentStatusNotAvailable"))
g.Expect(cond.Message).To(ContainSubstring("HttpRouteNotReady"))
})
t.Run("Reconcile requeues when HTTPRoute not ready", func(t *testing.T) {
isvc := &v1beta1.InferenceService{
ObjectMeta: metav1.ObjectMeta{
Name: "test-isvc-not-ready",
Namespace: "default",
},
Spec: v1beta1.InferenceServiceSpec{
Predictor: v1beta1.PredictorSpec{},
},
Status: v1beta1.InferenceServiceStatus{},
}
isvc.Status.SetCondition(v1beta1.PredictorReady, &apis.Condition{
Type: v1beta1.PredictorReady,
Status: corev1.ConditionTrue,
})
// Create HTTPRoute with not ready status - needs to have the correct spec to avoid being updated
ctx := context.TODO()
fakeClient := fake.NewClientBuilder().Build()
desiredPredictorRoute, err := createRawPredictorHTTPRoute(ctx, fakeClient, isvc, ingressConfig, isvcConfig)
g.Expect(err).ToNot(HaveOccurred())
g.Expect(desiredPredictorRoute).NotTo(BeNil())
notReadyHTTPRoute := desiredPredictorRoute.DeepCopy()
// Set controller reference to match what the reconciler will set
err = controllerutil.SetControllerReference(isvc, notReadyHTTPRoute, s)
g.Expect(err).ToNot(HaveOccurred())
notReadyHTTPRoute.Status = gwapiv1.HTTPRouteStatus{
RouteStatus: gwapiv1.RouteStatus{
Parents: []gwapiv1.RouteParentStatus{
{
Conditions: []metav1.Condition{
{
Type: string(gwapiv1.RouteConditionAccepted),
Status: metav1.ConditionFalse,
Reason: "NotAccepted",
Message: "Route not accepted by gateway",
},
},
},
},
},
}
client := fake.NewClientBuilder().
WithScheme(s).
WithObjects(
notReadyHTTPRoute,
readyHTTPRoute(isvc.Name, isvc.Namespace), // Top-level route is ready
predictorService("test-isvc-not-ready", "default"),
).
Build()
reconciler := NewRawHTTPRouteReconciler(client, s, ingressConfig, isvcConfig)
result, err := reconciler.Reconcile(t.Context(), isvc)
g.Expect(err).ToNot(HaveOccurred())
g.Expect(result.Requeue).To(BeTrue())
cond := isvc.Status.GetCondition(v1beta1.IngressReady)
g.Expect(cond).NotTo(BeNil())
g.Expect(cond.Status).To(Equal(corev1.ConditionFalse))
g.Expect(cond.Reason).To(Equal("NotAccepted"))
g.Expect(cond.Message).To(ContainSubstring("Predictor"))
})
t.Run("Reconcile requeues when transformer HTTPRoute not ready", func(t *testing.T) {
isvc := &v1beta1.InferenceService{
ObjectMeta: metav1.ObjectMeta{
Name: "test-isvc-transformer",
Namespace: "default",
},
Spec: v1beta1.InferenceServiceSpec{
Predictor: v1beta1.PredictorSpec{},
Transformer: &v1beta1.TransformerSpec{},
},
Status: v1beta1.InferenceServiceStatus{},
}
isvc.Status.SetCondition(v1beta1.PredictorReady, &apis.Condition{
Type: v1beta1.PredictorReady,
Status: corev1.ConditionTrue,
})
isvc.Status.SetCondition(v1beta1.TransformerReady, &apis.Condition{
Type: v1beta1.TransformerReady,
Status: corev1.ConditionTrue,
})
// Create ready predictor HTTPRoute but not ready transformer HTTPRoute
ctx := context.TODO()
fakeClient := fake.NewClientBuilder().Build()
desiredPredictorRoute, err := createRawPredictorHTTPRoute(ctx, fakeClient, isvc, ingressConfig, isvcConfig)
g.Expect(err).ToNot(HaveOccurred())
g.Expect(desiredPredictorRoute).NotTo(BeNil())
readyPredictorHTTPRoute := desiredPredictorRoute.DeepCopy()
// Set controller reference to match what the reconciler will set
err = controllerutil.SetControllerReference(isvc, readyPredictorHTTPRoute, s)
g.Expect(err).ToNot(HaveOccurred())
readyPredictorHTTPRoute.Status = gwapiv1.HTTPRouteStatus{
RouteStatus: gwapiv1.RouteStatus{
Parents: []gwapiv1.RouteParentStatus{
{
Conditions: []metav1.Condition{
{
Type: string(gwapiv1.RouteConditionAccepted),
Status: metav1.ConditionTrue,
},
{
Type: string(gwapiv1.RouteConditionResolvedRefs),
Status: metav1.ConditionTrue,
},
},
},
},
},
}
desiredTransformerRoute, err := createRawTransformerHTTPRoute(ctx, fakeClient, isvc, ingressConfig, isvcConfig)
g.Expect(err).ToNot(HaveOccurred())
g.Expect(desiredTransformerRoute).NotTo(BeNil())
notReadyTransformerHTTPRoute := desiredTransformerRoute.DeepCopy()
// Set controller reference to match what the reconciler will set
err = controllerutil.SetControllerReference(isvc, notReadyTransformerHTTPRoute, s)
g.Expect(err).ToNot(HaveOccurred())
// Create a temporary client to perform dry-run update on the test HTTPRoute
// This ensures it has the same defaults as what the reconciler will apply
tempClient := fake.NewClientBuilder().WithScheme(s).Build()
err = tempClient.Create(t.Context(), notReadyTransformerHTTPRoute)
g.Expect(err).ToNot(HaveOccurred())
// Perform the same dry-run update that the reconciler will do
err = tempClient.Update(t.Context(), notReadyTransformerHTTPRoute, client.DryRunAll)
g.Expect(err).ToNot(HaveOccurred())
notReadyTransformerHTTPRoute.Status = gwapiv1.HTTPRouteStatus{
RouteStatus: gwapiv1.RouteStatus{
Parents: []gwapiv1.RouteParentStatus{
{
Conditions: []metav1.Condition{
{
Type: string(gwapiv1.RouteConditionResolvedRefs),
Status: metav1.ConditionFalse,
Reason: "BackendNotFound",
Message: "Backend service not found",
},
},
},
},
},
}
// Create ready top-level HTTPRoute
desiredTopLevelRoute, err := createRawTopLevelHTTPRoute(ctx, fakeClient, isvc, ingressConfig, isvcConfig)
g.Expect(err).ToNot(HaveOccurred())
g.Expect(desiredTopLevelRoute).NotTo(BeNil())
readyTopLevelHTTPRoute := desiredTopLevelRoute.DeepCopy()
// Set controller reference to match what the reconciler will set
err = controllerutil.SetControllerReference(isvc, readyTopLevelHTTPRoute, s)
g.Expect(err).ToNot(HaveOccurred())
readyTopLevelHTTPRoute.Status = gwapiv1.HTTPRouteStatus{
RouteStatus: gwapiv1.RouteStatus{
Parents: []gwapiv1.RouteParentStatus{
{
Conditions: []metav1.Condition{
{
Type: string(gwapiv1.RouteConditionAccepted),
Status: metav1.ConditionTrue,
},
{
Type: string(gwapiv1.RouteConditionResolvedRefs),
Status: metav1.ConditionTrue,
},
},
},
},
},
}
transformerService := &corev1.Service{
ObjectMeta: metav1.ObjectMeta{
Name: constants.TransformerServiceName("test-isvc-transformer"),
Namespace: "default",
},
Spec: corev1.ServiceSpec{
ClusterIP: "10.0.0.2",
},
}
client := fake.NewClientBuilder().
WithScheme(s).
WithObjects(
readyPredictorHTTPRoute,
notReadyTransformerHTTPRoute,
readyTopLevelHTTPRoute,
transformerService,
).
Build()
reconciler := NewRawHTTPRouteReconciler(client, s, ingressConfig, isvcConfig)
result, err := reconciler.Reconcile(t.Context(), isvc)
g.Expect(err).ToNot(HaveOccurred())
g.Expect(result.Requeue).To(BeTrue())
cond := isvc.Status.GetCondition(v1beta1.IngressReady)
g.Expect(cond).NotTo(BeNil())
g.Expect(cond.Status).To(Equal(corev1.ConditionFalse))
g.Expect(cond.Reason).To(Equal("BackendNotFound"))
g.Expect(cond.Message).To(ContainSubstring("Transformer"))
})
t.Run("Reconcile requeues when explainer HTTPRoute not ready", func(t *testing.T) {
isvc := &v1beta1.InferenceService{
ObjectMeta: metav1.ObjectMeta{
Name: "test-isvc-explainer",
Namespace: "default",
},
Spec: v1beta1.InferenceServiceSpec{
Predictor: v1beta1.PredictorSpec{},
Explainer: &v1beta1.ExplainerSpec{},
},
Status: v1beta1.InferenceServiceStatus{},
}
isvc.Status.SetCondition(v1beta1.PredictorReady, &apis.Condition{
Type: v1beta1.PredictorReady,
Status: corev1.ConditionTrue,
})
isvc.Status.SetCondition(v1beta1.ExplainerReady, &apis.Condition{
Type: v1beta1.ExplainerReady,
Status: corev1.ConditionTrue,
})
// Create ready predictor HTTPRoute but not ready explainer HTTPRoute
ctx := context.TODO()
fakeClient := fake.NewClientBuilder().Build()
desiredPredictorRoute, err := createRawPredictorHTTPRoute(ctx, fakeClient, isvc, ingressConfig, isvcConfig)
g.Expect(err).ToNot(HaveOccurred())
g.Expect(desiredPredictorRoute).NotTo(BeNil())
readyPredictorHTTPRoute := desiredPredictorRoute.DeepCopy()
// Set controller reference to match what the reconciler will set
err = controllerutil.SetControllerReference(isvc, readyPredictorHTTPRoute, s)
g.Expect(err).ToNot(HaveOccurred())
readyPredictorHTTPRoute.Status = gwapiv1.HTTPRouteStatus{
RouteStatus: gwapiv1.RouteStatus{
Parents: []gwapiv1.RouteParentStatus{
{
Conditions: []metav1.Condition{
{
Type: string(gwapiv1.RouteConditionAccepted),
Status: metav1.ConditionTrue,
},
{
Type: string(gwapiv1.RouteConditionResolvedRefs),
Status: metav1.ConditionTrue,
},
},
},
},
},
}
desiredExplainerRoute, err := createRawExplainerHTTPRoute(ctx, fakeClient, isvc, ingressConfig, isvcConfig)
g.Expect(err).ToNot(HaveOccurred())
g.Expect(desiredExplainerRoute).NotTo(BeNil())
notReadyExplainerHTTPRoute := desiredExplainerRoute.DeepCopy()
// Set controller reference to match what the reconciler will set
err = controllerutil.SetControllerReference(isvc, notReadyExplainerHTTPRoute, s)
g.Expect(err).ToNot(HaveOccurred())
notReadyExplainerHTTPRoute.Status = gwapiv1.HTTPRouteStatus{
RouteStatus: gwapiv1.RouteStatus{
Parents: []gwapiv1.RouteParentStatus{
{
Conditions: []metav1.Condition{
{
Type: string(gwapiv1.RouteConditionAccepted),
Status: metav1.ConditionFalse,
Reason: "UnsupportedProtocol",
Message: "Protocol not supported",
},
},
},
},
},
}
desiredTopLevelRoute, err := createRawTopLevelHTTPRoute(ctx, fakeClient, isvc, ingressConfig, isvcConfig)
g.Expect(err).ToNot(HaveOccurred())
g.Expect(desiredTopLevelRoute).NotTo(BeNil())
readyTopLevelHTTPRoute := desiredTopLevelRoute.DeepCopy()
// Set controller reference to match what the reconciler will set
err = controllerutil.SetControllerReference(isvc, readyTopLevelHTTPRoute, s)
g.Expect(err).ToNot(HaveOccurred())
readyTopLevelHTTPRoute.Status = gwapiv1.HTTPRouteStatus{
RouteStatus: gwapiv1.RouteStatus{
Parents: []gwapiv1.RouteParentStatus{
{
Conditions: []metav1.Condition{
{
Type: string(gwapiv1.RouteConditionAccepted),
Status: metav1.ConditionTrue,
},
{
Type: string(gwapiv1.RouteConditionResolvedRefs),
Status: metav1.ConditionTrue,
},
},
},
},
},
}
client := fake.NewClientBuilder().
WithScheme(s).
WithObjects(
readyPredictorHTTPRoute,
notReadyExplainerHTTPRoute,
readyTopLevelHTTPRoute,
predictorService("test-isvc-explainer", "default"),
).
Build()
reconciler := NewRawHTTPRouteReconciler(client, s, ingressConfig, isvcConfig)
result, err := reconciler.Reconcile(t.Context(), isvc)
g.Expect(err).ToNot(HaveOccurred())
g.Expect(result.Requeue).To(BeTrue())
cond := isvc.Status.GetCondition(v1beta1.IngressReady)
g.Expect(cond).NotTo(BeNil())
g.Expect(cond.Status).To(Equal(corev1.ConditionFalse))
g.Expect(cond.Reason).To(Equal("UnsupportedProtocol"))
g.Expect(cond.Message).To(ContainSubstring("Explainer"))
})
t.Run("Reconcile requeues when top-level HTTPRoute not ready", func(t *testing.T) {
isvc := &v1beta1.InferenceService{
ObjectMeta: metav1.ObjectMeta{
Name: "test-isvc-toplevel",
Namespace: "default",
},
Spec: v1beta1.InferenceServiceSpec{
Predictor: v1beta1.PredictorSpec{},
},
Status: v1beta1.InferenceServiceStatus{},
}
isvc.Status.SetCondition(v1beta1.PredictorReady, &apis.Condition{
Type: v1beta1.PredictorReady,
Status: corev1.ConditionTrue,
})
// Create ready predictor HTTPRoute but not ready top-level HTTPRoute
ctx := context.TODO()
fakeClient := fake.NewClientBuilder().Build()
desiredTopLevelRoute, err := createRawTopLevelHTTPRoute(ctx, fakeClient, isvc, ingressConfig, isvcConfig)
g.Expect(err).ToNot(HaveOccurred())
g.Expect(desiredTopLevelRoute).NotTo(BeNil())
notReadyTopLevelHTTPRoute := desiredTopLevelRoute.DeepCopy()
// Set controller reference to match what the reconciler will set
err = controllerutil.SetControllerReference(isvc, notReadyTopLevelHTTPRoute, s)
g.Expect(err).ToNot(HaveOccurred())
notReadyTopLevelHTTPRoute.Status = gwapiv1.HTTPRouteStatus{
RouteStatus: gwapiv1.RouteStatus{
Parents: []gwapiv1.RouteParentStatus{
{
Conditions: []metav1.Condition{
{
Type: string(gwapiv1.RouteConditionAccepted),
Status: metav1.ConditionTrue,
},
{
Type: string(gwapiv1.RouteConditionResolvedRefs),
Status: metav1.ConditionFalse,
Reason: "InvalidKind",
Message: "Invalid backend kind",
},
},
},
},
},
}
desiredPredictorRoute, err := createRawPredictorHTTPRoute(ctx, fakeClient, isvc, ingressConfig, isvcConfig)
g.Expect(err).ToNot(HaveOccurred())
g.Expect(desiredPredictorRoute).NotTo(BeNil())
readyPredictorHTTPRoute := desiredPredictorRoute.DeepCopy()
// Set controller reference to match what the reconciler will set
err = controllerutil.SetControllerReference(isvc, readyPredictorHTTPRoute, s)
g.Expect(err).ToNot(HaveOccurred())
readyPredictorHTTPRoute.Status = gwapiv1.HTTPRouteStatus{
RouteStatus: gwapiv1.RouteStatus{
Parents: []gwapiv1.RouteParentStatus{
{
Conditions: []metav1.Condition{
{
Type: string(gwapiv1.RouteConditionAccepted),
Status: metav1.ConditionTrue,
},
{
Type: string(gwapiv1.RouteConditionResolvedRefs),
Status: metav1.ConditionTrue,
},
},
},
},
},
}
client := fake.NewClientBuilder().
WithScheme(s).
WithObjects(
readyPredictorHTTPRoute,
notReadyTopLevelHTTPRoute,
predictorService("test-isvc-toplevel", "default"),
).
Build()
reconciler := NewRawHTTPRouteReconciler(client, s, ingressConfig, isvcConfig)
result, err := reconciler.Reconcile(t.Context(), isvc)
g.Expect(err).ToNot(HaveOccurred())
g.Expect(result.Requeue).To(BeTrue())
cond := isvc.Status.GetCondition(v1beta1.IngressReady)
g.Expect(cond).NotTo(BeNil())
g.Expect(cond.Status).To(Equal(corev1.ConditionFalse))
g.Expect(cond.Reason).To(Equal("InvalidKind"))
g.Expect(cond.Message).To(ContainSubstring("InferenceService"))
})
t.Run("Reconcile does not requeue when all HTTPRoutes are ready", func(t *testing.T) {
isvc := &v1beta1.InferenceService{
ObjectMeta: metav1.ObjectMeta{
Name: "test-isvc-all-ready",
Namespace: "default",
},
Spec: v1beta1.InferenceServiceSpec{
Predictor: v1beta1.PredictorSpec{},
Transformer: &v1beta1.TransformerSpec{},
Explainer: &v1beta1.ExplainerSpec{},
},
Status: v1beta1.InferenceServiceStatus{},
}
isvc.Status.SetCondition(v1beta1.PredictorReady, &apis.Condition{
Type: v1beta1.PredictorReady,
Status: corev1.ConditionTrue,
})
isvc.Status.SetCondition(v1beta1.TransformerReady, &apis.Condition{
Type: v1beta1.TransformerReady,
Status: corev1.ConditionTrue,
})
isvc.Status.SetCondition(v1beta1.ExplainerReady, &apis.Condition{
Type: v1beta1.ExplainerReady,
Status: corev1.ConditionTrue,
})
ctx := context.TODO()
fakeClient := fake.NewClientBuilder().Build()
desiredPredictorRoute, err := createRawPredictorHTTPRoute(ctx, fakeClient, isvc, ingressConfig, isvcConfig)
g.Expect(err).ToNot(HaveOccurred())
g.Expect(desiredPredictorRoute).NotTo(BeNil())
readyPredictorHTTPRoute := desiredPredictorRoute.DeepCopy()
err = controllerutil.SetControllerReference(isvc, readyPredictorHTTPRoute, s)
g.Expect(err).ToNot(HaveOccurred())
readyPredictorHTTPRoute.Status = gwapiv1.HTTPRouteStatus{
RouteStatus: gwapiv1.RouteStatus{
Parents: []gwapiv1.RouteParentStatus{
{
Conditions: []metav1.Condition{
{
Type: string(gwapiv1.RouteConditionAccepted),
Status: metav1.ConditionTrue,
},
{
Type: string(gwapiv1.RouteConditionResolvedRefs),
Status: metav1.ConditionTrue,
},
},
},
},
},
}
desiredTransformerRoute, err := createRawTransformerHTTPRoute(ctx, fakeClient, isvc, ingressConfig, isvcConfig)
g.Expect(err).ToNot(HaveOccurred())
g.Expect(desiredTransformerRoute).NotTo(BeNil())
readyTransformerHTTPRoute := desiredTransformerRoute.DeepCopy()
err = controllerutil.SetControllerReference(isvc, readyTransformerHTTPRoute, s)
g.Expect(err).ToNot(HaveOccurred())
readyTransformerHTTPRoute.Status = gwapiv1.HTTPRouteStatus{
RouteStatus: gwapiv1.RouteStatus{
Parents: []gwapiv1.RouteParentStatus{
{
Conditions: []metav1.Condition{
{
Type: string(gwapiv1.RouteConditionAccepted),
Status: metav1.ConditionTrue,
},
{
Type: string(gwapiv1.RouteConditionResolvedRefs),
Status: metav1.ConditionTrue,
},
},
},
},
},
}
desiredExplainerRoute, err := createRawExplainerHTTPRoute(ctx, fakeClient, isvc, ingressConfig, isvcConfig)
g.Expect(err).ToNot(HaveOccurred())
g.Expect(desiredExplainerRoute).NotTo(BeNil())
readyExplainerHTTPRoute := desiredExplainerRoute.DeepCopy()
err = controllerutil.SetControllerReference(isvc, readyExplainerHTTPRoute, s)
g.Expect(err).ToNot(HaveOccurred())
readyExplainerHTTPRoute.Status = gwapiv1.HTTPRouteStatus{
RouteStatus: gwapiv1.RouteStatus{
Parents: []gwapiv1.RouteParentStatus{
{
Conditions: []metav1.Condition{
{
Type: string(gwapiv1.RouteConditionAccepted),
Status: metav1.ConditionTrue,
},
{
Type: string(gwapiv1.RouteConditionResolvedRefs),
Status: metav1.ConditionTrue,
},
},
},
},
},
}
desiredTopLevelRoute, err := createRawTopLevelHTTPRoute(ctx, fakeClient, isvc, ingressConfig, isvcConfig)
g.Expect(err).ToNot(HaveOccurred())
g.Expect(desiredTopLevelRoute).NotTo(BeNil())
readyTopLevelHTTPRoute := desiredTopLevelRoute.DeepCopy()
err = controllerutil.SetControllerReference(isvc, readyTopLevelHTTPRoute, s)
g.Expect(err).ToNot(HaveOccurred())
readyTopLevelHTTPRoute.Status = gwapiv1.HTTPRouteStatus{
RouteStatus: gwapiv1.RouteStatus{
Parents: []gwapiv1.RouteParentStatus{
{
Conditions: []metav1.Condition{
{
Type: string(gwapiv1.RouteConditionAccepted),
Status: metav1.ConditionTrue,
},
{
Type: string(gwapiv1.RouteConditionResolvedRefs),
Status: metav1.ConditionTrue,
},
},
},
},
},
}
transformerService := &corev1.Service{
ObjectMeta: metav1.ObjectMeta{
Name: constants.TransformerServiceName("test-isvc-all-ready"),
Namespace: "default",
},
Spec: corev1.ServiceSpec{
ClusterIP: "10.0.0.2",
},
}
client := fake.NewClientBuilder().
WithScheme(s).
WithObjects(
readyPredictorHTTPRoute,
readyTransformerHTTPRoute,
readyExplainerHTTPRoute,
readyTopLevelHTTPRoute,
transformerService,
).
Build()
reconciler := NewRawHTTPRouteReconciler(client, s, ingressConfig, isvcConfig)
result, err := reconciler.Reconcile(t.Context(), isvc)
g.Expect(err).ToNot(HaveOccurred())
g.Expect(result.Requeue).To(BeFalse())
g.Expect(result.RequeueAfter).To(BeZero())
cond := isvc.Status.GetCondition(v1beta1.IngressReady)
g.Expect(cond).NotTo(BeNil())
g.Expect(cond.Status).To(Equal(corev1.ConditionTrue))
})
t.Run("Reconcile handles HTTPRoute not found (IsNotFound error)", func(t *testing.T) {
// This test specifically covers the edge case where checkHTTPRouteStatuses
// encounters an IsNotFound error when trying to get HTTPRoutes
isvc := &v1beta1.InferenceService{
ObjectMeta: metav1.ObjectMeta{
Name: "test-isvc-not-found",
Namespace: "default",
},
Spec: v1beta1.InferenceServiceSpec{
Predictor: v1beta1.PredictorSpec{},
},
Status: v1beta1.InferenceServiceStatus{},
}
isvc.Status.SetCondition(v1beta1.PredictorReady, &apis.Condition{
Type: v1beta1.PredictorReady,
Status: corev1.ConditionTrue,
})
// Create an interceptor client that blocks HTTPRoute creation
// This ensures that when checkHTTPRouteStatuses tries to get HTTPRoutes,
// it will encounter IsNotFound errors and trigger the edge case
baseClient := fake.NewClientBuilder().
WithScheme(s).
WithObjects(
// Add a Service to simulate that components are ready
&corev1.Service{
ObjectMeta: metav1.ObjectMeta{
Name: constants.PredictorServiceName(isvc.Name),
Namespace: isvc.Namespace,
},
},
).
Build()
interceptorClient := &httpRouteClientInterceptor{
Client: baseClient,
blockHTTPRouteCreation: true,
}
reconciler := NewRawHTTPRouteReconciler(interceptorClient, s, ingressConfig, isvcConfig)
result, err := reconciler.Reconcile(t.Context(), isvc)
g.Expect(err).ToNot(HaveOccurred())
g.Expect(result.Requeue).To(BeTrue())
cond := isvc.Status.GetCondition(v1beta1.IngressReady)
g.Expect(cond).NotTo(BeNil())
g.Expect(cond.Status).To(Equal(corev1.ConditionFalse))
// This is the specific edge case we're testing - when HTTPRoute is not found,
// the condition should be set with reason "Predictor Deployment NotReady" and
// message "Predictor HTTPRoute not created"
g.Expect(cond.Reason).To(Equal("Predictor Deployment NotReady"))
g.Expect(cond.Message).To(Equal("Predictor HTTPRoute not created"))
})
}
func TestResolveTimeout(t *testing.T) {
g := NewGomegaWithT(t)
testCases := map[string]struct {
disableTimeout bool
timeoutSeconds *int64
expected *gwapiv1.Duration
}{
"default timeout when not disabled and no override": {
disableTimeout: false,
timeoutSeconds: nil,
expected: DefaultTimeout,
},
"custom timeout when not disabled": {
disableTimeout: false,
timeoutSeconds: ptr.To(int64(120)),
expected: ptr.To(gwapiv1.Duration("120s")),
},
"nil timeout when disabled and no override": {
disableTimeout: true,
timeoutSeconds: nil,
expected: nil,
},
"nil timeout when disabled even with override": {
disableTimeout: true,
timeoutSeconds: ptr.To(int64(120)),
expected: nil,
},
}
for name, tc := range testCases {
t.Run(name, func(t *testing.T) {
result := resolveTimeout(tc.disableTimeout, tc.timeoutSeconds)
g.Expect(result).To(BeComparableTo(tc.expected))
})
}
}
func TestCreateHTTPRouteRuleNilTimeout(t *testing.T) {
g := NewGomegaWithT(t)
matches := []gwapiv1.HTTPRouteMatch{
{
Path: &gwapiv1.HTTPPathMatch{
Type: ptr.To(gwapiv1.PathMatchRegularExpression),
Value: ptr.To("/predict"),
},
},
}
filters := []gwapiv1.HTTPRouteFilter{addIsvcHeaders("test-isvc", "default")}
t.Run("nil timeout omits Timeouts field", func(t *testing.T) {
rule := createHTTPRouteRule(matches, filters, "test-service", "default", 80, nil)
g.Expect(rule.Timeouts).To(BeNil())
})
t.Run("non-nil timeout sets Timeouts field", func(t *testing.T) {
rule := createHTTPRouteRule(matches, filters, "test-service", "default", 80, DefaultTimeout)
g.Expect(rule.Timeouts).ToNot(BeNil())
g.Expect(rule.Timeouts.Request).To(BeComparableTo(DefaultTimeout))
})
}
func TestCreateRawPredictorHTTPRouteDisableTimeout(t *testing.T) {
format.MaxLength = 0
g := NewGomegaWithT(t)
isvc := &v1beta1.InferenceService{
ObjectMeta: metav1.ObjectMeta{
Name: "test-isvc",
Namespace: "default",
},
Spec: v1beta1.InferenceServiceSpec{
Predictor: v1beta1.PredictorSpec{},
},
Status: v1beta1.InferenceServiceStatus{
Status: duckv1.Status{
Conditions: []apis.Condition{
{
Type: v1beta1.PredictorReady,
Status: corev1.ConditionTrue,
},
},
},
},
}
isvcConfig := &v1beta1.InferenceServicesConfig{
ServiceAnnotationDisallowedList: []string{},
ServiceLabelDisallowedList: []string{},
}
t.Run("timeout disabled produces no Timeouts field", func(t *testing.T) {
ingressConfig := &v1beta1.IngressConfig{
IngressDomain: "example.com",
UrlScheme: "http",
DomainTemplate: "{{.Name}}-{{.Namespace}}.{{.IngressDomain}}",
KserveIngressGateway: "kserve/kserve-gateway",
EnableGatewayAPI: true,
DisableHTTPRouteTimeout: true,
}
ctx := context.TODO()
fakeClient := fake.NewClientBuilder().Build()
httpRoute, err := createRawPredictorHTTPRoute(ctx, fakeClient, isvc, ingressConfig, isvcConfig)
g.Expect(err).ToNot(HaveOccurred())
g.Expect(httpRoute).ToNot(BeNil())
for _, rule := range httpRoute.Spec.Rules {
g.Expect(rule.Timeouts).To(BeNil(), "expected Timeouts to be nil when DisableHTTPRouteTimeout is true")
}
})
t.Run("timeout enabled produces Timeouts field", func(t *testing.T) {
ingressConfig := &v1beta1.IngressConfig{
IngressDomain: "example.com",
UrlScheme: "http",
DomainTemplate: "{{.Name}}-{{.Namespace}}.{{.IngressDomain}}",
KserveIngressGateway: "kserve/kserve-gateway",
EnableGatewayAPI: true,
DisableHTTPRouteTimeout: false,
}
ctx := context.TODO()
fakeClient := fake.NewClientBuilder().Build()
httpRoute, err := createRawPredictorHTTPRoute(ctx, fakeClient, isvc, ingressConfig, isvcConfig)
g.Expect(err).ToNot(HaveOccurred())
g.Expect(httpRoute).ToNot(BeNil())
for _, rule := range httpRoute.Spec.Rules {
g.Expect(rule.Timeouts).ToNot(BeNil(), "expected Timeouts to be set when DisableHTTPRouteTimeout is false")
g.Expect(rule.Timeouts.Request).To(BeComparableTo(DefaultTimeout))
}
})
}