mirror of
https://github.com/hanzo-ml/kserve.git
synced 2026-10-30 20:53:49 +00:00
2602 lines
80 KiB
Go
2602 lines
80 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 (
|
|
"context"
|
|
"testing"
|
|
|
|
. "github.com/onsi/gomega"
|
|
corev1 "k8s.io/api/core/v1"
|
|
apierrors "k8s.io/apimachinery/pkg/api/errors"
|
|
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
|
"k8s.io/apimachinery/pkg/runtime"
|
|
"k8s.io/utils/ptr"
|
|
"knative.dev/pkg/apis"
|
|
"sigs.k8s.io/controller-runtime/pkg/client"
|
|
"sigs.k8s.io/controller-runtime/pkg/client/fake"
|
|
gwapiv1 "sigs.k8s.io/gateway-api/apis/v1"
|
|
|
|
"github.com/kserve/kserve/pkg/apis/serving/v1alpha2"
|
|
|
|
. "github.com/kserve/kserve/pkg/controller/v1alpha2/llmisvc/fixture"
|
|
|
|
"github.com/kserve/kserve/pkg/controller/v1alpha2/llmisvc"
|
|
)
|
|
|
|
// expectURLs returns an assert function that expects no error and exact URL match
|
|
func expectURLs(expected ...string) func(g Gomega, urls []string, err error) {
|
|
return func(g Gomega, urls []string, err error) {
|
|
g.Expect(err).ToNot(HaveOccurred())
|
|
g.Expect(urls).To(Equal(expected))
|
|
}
|
|
}
|
|
|
|
// expectURLsContain returns an assert function that expects no error and URLs containing the expected elements
|
|
func expectURLsContain(expected ...string) func(g Gomega, urls []string, err error) {
|
|
return func(g Gomega, urls []string, err error) {
|
|
g.Expect(err).ToNot(HaveOccurred())
|
|
g.Expect(urls).To(ContainElements(expected))
|
|
}
|
|
}
|
|
|
|
// expectError returns an assert function that expects an error matching the predicate
|
|
func expectError(check func(error) bool) func(g Gomega, urls []string, err error) {
|
|
return func(g Gomega, urls []string, err error) {
|
|
g.Expect(err).To(HaveOccurred())
|
|
g.Expect(check(err)).To(BeTrue(), "Error check failed for: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestDiscoverURLs(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
route *gwapiv1.HTTPRoute
|
|
gateways []*gwapiv1.Gateway
|
|
services []*corev1.Service
|
|
preferredUrlScheme string
|
|
modelRoutingHeader string
|
|
assert func(g Gomega, urls []string, err error)
|
|
}{
|
|
// ===== Basic address resolution =====
|
|
{
|
|
name: "basic external address resolution",
|
|
route: HTTPRoute("test-route",
|
|
InNamespace[*gwapiv1.HTTPRoute]("test-ns"),
|
|
WithParentRef(GatewayRef("test-gateway", RefInNamespace("test-ns"))),
|
|
),
|
|
gateways: []*gwapiv1.Gateway{HTTPGateway("test-gateway", "test-ns", "203.0.113.1")},
|
|
assert: expectURLs("http://203.0.113.1/"),
|
|
},
|
|
{
|
|
name: "address ordering consistency - same addresses different order",
|
|
route: HTTPRoute("consistency-route",
|
|
InNamespace[*gwapiv1.HTTPRoute]("test-ns"),
|
|
WithParentRef(GatewayRef("consistency-gateway", RefInNamespace("test-ns"))),
|
|
),
|
|
gateways: []*gwapiv1.Gateway{
|
|
Gateway("consistency-gateway",
|
|
InNamespace[*gwapiv1.Gateway]("test-ns"),
|
|
WithListener(gwapiv1.HTTPProtocolType),
|
|
WithAddresses("203.0.113.200", "203.0.113.100"),
|
|
),
|
|
},
|
|
assert: expectURLs("http://203.0.113.100/", "http://203.0.113.200/"),
|
|
},
|
|
// ===== Hostname handling =====
|
|
{
|
|
name: "route hostname within listener wildcard",
|
|
route: HTTPRoute("hostname-route",
|
|
InNamespace[*gwapiv1.HTTPRoute]("test-ns"),
|
|
WithParentRef(GatewayRef("hostname-gateway", RefInNamespace("test-ns"))),
|
|
WithHostnames("api.example.com"),
|
|
),
|
|
gateways: []*gwapiv1.Gateway{
|
|
Gateway("hostname-gateway",
|
|
InNamespace[*gwapiv1.Gateway]("test-ns"),
|
|
WithListeners(gwapiv1.Listener{
|
|
Protocol: gwapiv1.HTTPProtocolType,
|
|
Port: 80,
|
|
Hostname: ptr.To(gwapiv1.Hostname("*.example.com")),
|
|
}),
|
|
WithAddresses("203.0.113.1"),
|
|
),
|
|
},
|
|
assert: expectURLs("http://api.example.com/"),
|
|
},
|
|
{
|
|
name: "route wildcard hostname - use gateway address",
|
|
route: HTTPRoute("wildcard-route",
|
|
InNamespace[*gwapiv1.HTTPRoute]("test-ns"),
|
|
WithParentRef(GatewayRef("wildcard-gateway", RefInNamespace("test-ns"))),
|
|
WithHostnames("*"),
|
|
),
|
|
gateways: []*gwapiv1.Gateway{
|
|
Gateway("wildcard-gateway",
|
|
InNamespace[*gwapiv1.Gateway]("test-ns"),
|
|
WithListener(gwapiv1.HTTPProtocolType),
|
|
WithAddresses("203.0.113.100"),
|
|
),
|
|
},
|
|
assert: expectURLs("http://203.0.113.100/"),
|
|
},
|
|
{
|
|
name: "multiple hostnames - generates multiple URLs",
|
|
route: HTTPRoute("multi-hostname-route",
|
|
InNamespace[*gwapiv1.HTTPRoute]("test-ns"),
|
|
WithParentRef(GatewayRef("multi-hostname-gateway", RefInNamespace("test-ns"))),
|
|
WithHostnames("api.example.com", "alt.example.com"),
|
|
),
|
|
gateways: []*gwapiv1.Gateway{
|
|
Gateway("multi-hostname-gateway",
|
|
InNamespace[*gwapiv1.Gateway]("test-ns"),
|
|
WithListeners(gwapiv1.Listener{
|
|
Protocol: gwapiv1.HTTPProtocolType,
|
|
Port: 80,
|
|
Hostname: ptr.To(gwapiv1.Hostname("*.example.com")),
|
|
}),
|
|
WithAddresses("203.0.113.1"),
|
|
),
|
|
},
|
|
assert: expectURLs("http://alt.example.com/", "http://api.example.com/"),
|
|
},
|
|
|
|
// ===== Path handling =====
|
|
{
|
|
name: "custom path extraction",
|
|
route: HTTPRoute("path-route",
|
|
InNamespace[*gwapiv1.HTTPRoute]("test-ns"),
|
|
WithParentRef(GatewayRef("path-gateway", RefInNamespace("test-ns"))),
|
|
WithPath("/api/v1"),
|
|
),
|
|
gateways: []*gwapiv1.Gateway{HTTPGateway("path-gateway", "test-ns", "203.0.113.1")},
|
|
assert: expectURLs("http://203.0.113.1/api/v1"),
|
|
},
|
|
{
|
|
name: "multi-rule path extraction - prefers Service-backed rule path",
|
|
route: HTTPRoute("multi-rule-route",
|
|
InNamespace[*gwapiv1.HTTPRoute]("test-ns"),
|
|
WithParentRef(GatewayRef("multi-rule-gateway", RefInNamespace("test-ns"))),
|
|
WithHTTPRule(
|
|
Matches(PathPrefixMatch("/ns/name/v1/completions")),
|
|
WithBackendRefs(BackendRefInferencePool("pool")),
|
|
),
|
|
WithHTTPRule(
|
|
Matches(PathPrefixMatch("/ns/name/v1/chat/completions")),
|
|
WithBackendRefs(BackendRefInferencePool("pool")),
|
|
),
|
|
WithHTTPRule(
|
|
Matches(PathPrefixMatch("/ns/name/v1/messages")),
|
|
WithBackendRefs(BackendRefInferencePool("pool")),
|
|
),
|
|
WithHTTPRule(
|
|
Matches(PathPrefixMatch("/ns/name")),
|
|
WithBackendRefs(BackendRefService("svc")),
|
|
),
|
|
),
|
|
gateways: []*gwapiv1.Gateway{
|
|
Gateway("multi-rule-gateway",
|
|
InNamespace[*gwapiv1.Gateway]("test-ns"),
|
|
WithListener(gwapiv1.HTTPProtocolType),
|
|
WithAddresses("203.0.113.1"),
|
|
),
|
|
},
|
|
assert: expectURLs("http://203.0.113.1/ns/name"),
|
|
},
|
|
{
|
|
name: "multi-rule path extraction - falls back to shortest when no Service backend",
|
|
route: HTTPRoute("no-svc-route",
|
|
InNamespace[*gwapiv1.HTTPRoute]("test-ns"),
|
|
WithParentRef(GatewayRef("no-svc-gateway", RefInNamespace("test-ns"))),
|
|
WithHTTPRule(
|
|
Matches(PathPrefixMatch("/ns/name/v1/completions")),
|
|
WithBackendRefs(BackendRefInferencePool("pool")),
|
|
),
|
|
WithHTTPRule(
|
|
Matches(PathPrefixMatch("/ns/name/v1/chat/completions")),
|
|
WithBackendRefs(BackendRefInferencePool("pool")),
|
|
),
|
|
WithHTTPRule(
|
|
Matches(PathPrefixMatch("/ns/name/v1/messages")),
|
|
WithBackendRefs(BackendRefInferencePool("pool")),
|
|
),
|
|
),
|
|
gateways: []*gwapiv1.Gateway{
|
|
Gateway("no-svc-gateway",
|
|
InNamespace[*gwapiv1.Gateway]("test-ns"),
|
|
WithListener(gwapiv1.HTTPProtocolType),
|
|
WithAddresses("203.0.113.1"),
|
|
),
|
|
},
|
|
assert: expectURLs("http://203.0.113.1/ns/name/v1/messages"),
|
|
},
|
|
{
|
|
name: "multi-rule path extraction - Service with default Kind (nil)",
|
|
route: HTTPRoute("default-kind-route",
|
|
InNamespace[*gwapiv1.HTTPRoute]("test-ns"),
|
|
WithParentRef(GatewayRef("default-kind-gateway", RefInNamespace("test-ns"))),
|
|
WithHTTPRule(
|
|
Matches(PathPrefixMatch("/ns/name/v1/completions")),
|
|
WithBackendRefs(BackendRefInferencePool("pool")),
|
|
),
|
|
WithHTTPRule(
|
|
Matches(PathPrefixMatch("/ns/name")),
|
|
WithBackendRefs(gwapiv1.HTTPBackendRef{
|
|
BackendRef: gwapiv1.BackendRef{
|
|
BackendObjectReference: gwapiv1.BackendObjectReference{
|
|
// Kind nil defaults to "Service" per Gateway API spec
|
|
Name: "svc",
|
|
Port: ptr.To(gwapiv1.PortNumber(8000)),
|
|
},
|
|
},
|
|
}),
|
|
),
|
|
),
|
|
gateways: []*gwapiv1.Gateway{
|
|
Gateway("default-kind-gateway",
|
|
InNamespace[*gwapiv1.Gateway]("test-ns"),
|
|
WithListener(gwapiv1.HTTPProtocolType),
|
|
WithAddresses("203.0.113.1"),
|
|
),
|
|
},
|
|
assert: expectURLs("http://203.0.113.1/ns/name"),
|
|
},
|
|
{
|
|
name: "multi-rule path extraction - nil match.Path is skipped",
|
|
route: HTTPRoute("nil-path-route",
|
|
InNamespace[*gwapiv1.HTTPRoute]("test-ns"),
|
|
WithParentRef(GatewayRef("nil-path-gateway", RefInNamespace("test-ns"))),
|
|
WithHTTPRule(
|
|
Matches(gwapiv1.HTTPRouteMatch{
|
|
// No Path set - header-only match
|
|
Headers: []gwapiv1.HTTPHeaderMatch{{
|
|
Name: "x-custom",
|
|
Value: "val",
|
|
}},
|
|
}),
|
|
WithBackendRefs(BackendRefService("svc")),
|
|
),
|
|
WithHTTPRule(
|
|
Matches(PathPrefixMatch("/ns/name")),
|
|
WithBackendRefs(BackendRefService("svc")),
|
|
),
|
|
),
|
|
gateways: []*gwapiv1.Gateway{
|
|
Gateway("nil-path-gateway",
|
|
InNamespace[*gwapiv1.Gateway]("test-ns"),
|
|
WithListener(gwapiv1.HTTPProtocolType),
|
|
WithAddresses("203.0.113.1"),
|
|
),
|
|
},
|
|
assert: expectURLs("http://203.0.113.1/ns/name"),
|
|
},
|
|
{
|
|
name: "empty route rules - default path",
|
|
route: HTTPRoute("empty-rules-route",
|
|
InNamespace[*gwapiv1.HTTPRoute]("test-ns"),
|
|
WithParentRef(GatewayRef("empty-rules-gateway", RefInNamespace("test-ns"))),
|
|
),
|
|
gateways: []*gwapiv1.Gateway{HTTPGateway("empty-rules-gateway", "test-ns", "203.0.113.1")},
|
|
assert: expectURLs("http://203.0.113.1/"),
|
|
},
|
|
|
|
// ===== Model-based routing path extraction =====
|
|
{
|
|
name: "model-based routing - discovers both path-based and header-based URLs",
|
|
route: HTTPRoute("mbr-route",
|
|
InNamespace[*gwapiv1.HTTPRoute]("test-ns"),
|
|
WithParentRef(GatewayRef("mbr-gateway", RefInNamespace("test-ns"))),
|
|
WithHTTPRule(
|
|
Matches(PathPrefixMatch("/ns/name/v1/completions")),
|
|
WithBackendRefs(BackendRefInferencePool("pool")),
|
|
),
|
|
WithHTTPRule(
|
|
Matches(ExactPathWithHeaderMatch("/v1/completions", "X-Gateway-Model-Name", "publishers/ns/models/m")),
|
|
WithBackendRefs(BackendRefInferencePool("pool")),
|
|
),
|
|
WithHTTPRule(
|
|
Matches(PathPrefixMatch("/ns/name")),
|
|
WithBackendRefs(BackendRefService("svc")),
|
|
),
|
|
WithHTTPRule(
|
|
Matches(HeaderOnlyMatch("X-Gateway-Model-Name", "publishers/ns/models/m")),
|
|
WithBackendRefs(BackendRefService("svc")),
|
|
),
|
|
),
|
|
gateways: []*gwapiv1.Gateway{
|
|
Gateway("mbr-gateway",
|
|
InNamespace[*gwapiv1.Gateway]("test-ns"),
|
|
WithListener(gwapiv1.HTTPProtocolType),
|
|
WithAddresses("203.0.113.1"),
|
|
),
|
|
},
|
|
modelRoutingHeader: "X-Gateway-Model-Name",
|
|
assert: expectURLs("http://203.0.113.1/", "http://203.0.113.1/ns/name"),
|
|
},
|
|
{
|
|
name: "model-based routing - ignores user-provided header matches",
|
|
route: HTTPRoute("user-header-route",
|
|
InNamespace[*gwapiv1.HTTPRoute]("test-ns"),
|
|
WithParentRef(GatewayRef("user-header-gateway", RefInNamespace("test-ns"))),
|
|
WithHTTPRule(
|
|
Matches(PathPrefixMatch("/ns/name")),
|
|
WithBackendRefs(BackendRefService("svc")),
|
|
),
|
|
WithHTTPRule(
|
|
Matches(ExactPathWithHeaderMatch("/custom", "X-Custom-Header", "val")),
|
|
WithBackendRefs(BackendRefService("svc")),
|
|
),
|
|
),
|
|
gateways: []*gwapiv1.Gateway{
|
|
Gateway("user-header-gateway",
|
|
InNamespace[*gwapiv1.Gateway]("test-ns"),
|
|
WithListener(gwapiv1.HTTPProtocolType),
|
|
WithAddresses("203.0.113.1"),
|
|
),
|
|
},
|
|
modelRoutingHeader: "X-Gateway-Model-Name",
|
|
assert: expectURLs("http://203.0.113.1/ns/name"),
|
|
},
|
|
{
|
|
name: "model-based routing - empty header config includes all paths",
|
|
route: HTTPRoute("no-header-config-route",
|
|
InNamespace[*gwapiv1.HTTPRoute]("test-ns"),
|
|
WithParentRef(GatewayRef("no-header-config-gw", RefInNamespace("test-ns"))),
|
|
WithHTTPRule(
|
|
Matches(PathPrefixMatch("/ns/name")),
|
|
WithBackendRefs(BackendRefService("svc")),
|
|
),
|
|
WithHTTPRule(
|
|
Matches(HeaderOnlyMatch("X-Gateway-Model-Name", "publishers/ns/models/m")),
|
|
WithBackendRefs(BackendRefService("svc")),
|
|
),
|
|
),
|
|
gateways: []*gwapiv1.Gateway{
|
|
Gateway("no-header-config-gw",
|
|
InNamespace[*gwapiv1.Gateway]("test-ns"),
|
|
WithListener(gwapiv1.HTTPProtocolType),
|
|
WithAddresses("203.0.113.1"),
|
|
),
|
|
},
|
|
modelRoutingHeader: "",
|
|
assert: expectURLs("http://203.0.113.1/ns/name"),
|
|
},
|
|
{
|
|
name: "model-based routing - backward compat: path-only rules unchanged",
|
|
route: HTTPRoute("compat-route",
|
|
InNamespace[*gwapiv1.HTTPRoute]("test-ns"),
|
|
WithParentRef(GatewayRef("compat-gateway", RefInNamespace("test-ns"))),
|
|
WithHTTPRule(
|
|
Matches(PathPrefixMatch("/ns/name/v1/completions")),
|
|
WithBackendRefs(BackendRefInferencePool("pool")),
|
|
),
|
|
WithHTTPRule(
|
|
Matches(PathPrefixMatch("/ns/name/v1/chat/completions")),
|
|
WithBackendRefs(BackendRefInferencePool("pool")),
|
|
),
|
|
WithHTTPRule(
|
|
Matches(PathPrefixMatch("/ns/name/v1/messages")),
|
|
WithBackendRefs(BackendRefInferencePool("pool")),
|
|
),
|
|
WithHTTPRule(
|
|
Matches(PathPrefixMatch("/ns/name")),
|
|
WithBackendRefs(BackendRefService("svc")),
|
|
),
|
|
),
|
|
gateways: []*gwapiv1.Gateway{
|
|
Gateway("compat-gateway",
|
|
InNamespace[*gwapiv1.Gateway]("test-ns"),
|
|
WithListener(gwapiv1.HTTPProtocolType),
|
|
WithAddresses("203.0.113.1"),
|
|
),
|
|
},
|
|
modelRoutingHeader: "X-Gateway-Model-Name",
|
|
assert: expectURLs("http://203.0.113.1/ns/name"),
|
|
},
|
|
|
|
// ===== Scheme from listener =====
|
|
{
|
|
name: "HTTPS scheme from gateway listener",
|
|
route: HTTPRoute("https-route",
|
|
InNamespace[*gwapiv1.HTTPRoute]("test-ns"),
|
|
WithParentRef(GatewayRef("https-gateway", RefInNamespace("test-ns"))),
|
|
),
|
|
gateways: []*gwapiv1.Gateway{HTTPSGateway("https-gateway", "test-ns", "203.0.113.1")},
|
|
assert: expectURLs("https://203.0.113.1/"),
|
|
},
|
|
{
|
|
name: "TLS protocol listener uses HTTPS scheme",
|
|
route: HTTPRoute("tls-route",
|
|
InNamespace[*gwapiv1.HTTPRoute]("test-ns"),
|
|
WithParentRef(GatewayRef("tls-gateway", RefInNamespace("test-ns"))),
|
|
),
|
|
gateways: []*gwapiv1.Gateway{
|
|
Gateway("tls-gateway",
|
|
InNamespace[*gwapiv1.Gateway]("test-ns"),
|
|
WithListeners(gwapiv1.Listener{
|
|
Name: "tls",
|
|
Protocol: gwapiv1.TLSProtocolType,
|
|
Port: 443,
|
|
}),
|
|
WithAddresses("203.0.113.1"),
|
|
),
|
|
},
|
|
assert: expectURLs("https://203.0.113.1/"),
|
|
},
|
|
{
|
|
name: "IPv6 gateway address - brackets in URL",
|
|
route: HTTPRoute("ipv6-route",
|
|
InNamespace[*gwapiv1.HTTPRoute]("test-ns"),
|
|
WithParentRef(GatewayRef("ipv6-gateway", RefInNamespace("test-ns"))),
|
|
),
|
|
gateways: []*gwapiv1.Gateway{HTTPGateway("ipv6-gateway", "test-ns", "2001:db8::1")},
|
|
assert: expectURLs("http://[2001:db8::1]/"),
|
|
},
|
|
|
|
// ===== Multiple parent refs =====
|
|
{
|
|
name: "multiple parent refs - sorted selection",
|
|
route: HTTPRoute("multi-parent-route",
|
|
InNamespace[*gwapiv1.HTTPRoute]("default-ns"),
|
|
WithParentRefs(
|
|
GatewayRef("z-gateway", RefInNamespace("z-namespace")),
|
|
GatewayRef("a-gateway", RefInNamespace("a-namespace")),
|
|
GatewayRef("b-gateway", RefInNamespace("a-namespace")),
|
|
),
|
|
),
|
|
gateways: []*gwapiv1.Gateway{
|
|
Gateway("a-gateway",
|
|
InNamespace[*gwapiv1.Gateway]("a-namespace"),
|
|
WithListener(gwapiv1.HTTPProtocolType),
|
|
WithAddresses("203.0.113.1"),
|
|
),
|
|
Gateway("z-gateway",
|
|
InNamespace[*gwapiv1.Gateway]("z-namespace"),
|
|
WithListener(gwapiv1.HTTPProtocolType),
|
|
WithAddresses("203.0.113.2"),
|
|
),
|
|
Gateway("b-gateway",
|
|
InNamespace[*gwapiv1.Gateway]("a-namespace"),
|
|
WithListener(gwapiv1.HTTPProtocolType),
|
|
WithAddresses("203.0.113.3"),
|
|
),
|
|
},
|
|
assert: expectURLs(
|
|
"http://203.0.113.2/",
|
|
"http://203.0.113.1/",
|
|
"http://203.0.113.3/",
|
|
),
|
|
},
|
|
{
|
|
name: "route with multiple parent refs to different gateways",
|
|
route: HTTPRoute("multi-gateway-route",
|
|
InNamespace[*gwapiv1.HTTPRoute]("test-ns"),
|
|
WithParentRefs(
|
|
gwapiv1.ParentReference{
|
|
Name: "gateway-a",
|
|
Namespace: ptr.To(gwapiv1.Namespace("gw-ns")),
|
|
Group: ptr.To(gwapiv1.Group("gateway.networking.k8s.io")),
|
|
Kind: ptr.To(gwapiv1.Kind("Gateway")),
|
|
},
|
|
gwapiv1.ParentReference{
|
|
Name: "gateway-b",
|
|
Namespace: ptr.To(gwapiv1.Namespace("gw-ns")),
|
|
Group: ptr.To(gwapiv1.Group("gateway.networking.k8s.io")),
|
|
Kind: ptr.To(gwapiv1.Kind("Gateway")),
|
|
},
|
|
),
|
|
),
|
|
gateways: []*gwapiv1.Gateway{
|
|
Gateway("gateway-a",
|
|
InNamespace[*gwapiv1.Gateway]("gw-ns"),
|
|
WithListeners(gwapiv1.Listener{
|
|
Name: "https",
|
|
Protocol: gwapiv1.HTTPSProtocolType,
|
|
Port: 443,
|
|
}),
|
|
WithAddresses("gateway-a.example.com"),
|
|
),
|
|
Gateway("gateway-b",
|
|
InNamespace[*gwapiv1.Gateway]("gw-ns"),
|
|
WithListeners(gwapiv1.Listener{
|
|
Name: "http",
|
|
Protocol: gwapiv1.HTTPProtocolType,
|
|
Port: 8080,
|
|
}),
|
|
WithAddresses("gateway-b.example.com"),
|
|
),
|
|
},
|
|
assert: expectURLsContain(
|
|
"https://gateway-a.example.com/",
|
|
"http://gateway-b.example.com:8080/",
|
|
),
|
|
},
|
|
{
|
|
name: "parent ref without namespace - use route namespace",
|
|
route: HTTPRoute("no-ns-route",
|
|
InNamespace[*gwapiv1.HTTPRoute]("route-ns"),
|
|
WithParentRef(GatewayRef("same-ns-gateway")),
|
|
),
|
|
gateways: []*gwapiv1.Gateway{HTTPGateway("same-ns-gateway", "route-ns", "203.0.113.1")},
|
|
assert: expectURLs("http://203.0.113.1/"),
|
|
},
|
|
|
|
// ===== Address types =====
|
|
{
|
|
name: "private addresses only - still returns URLs",
|
|
route: HTTPRoute("private-route",
|
|
InNamespace[*gwapiv1.HTTPRoute]("test-ns"),
|
|
WithParentRef(GatewayRef("private-gateway", RefInNamespace("test-ns"))),
|
|
),
|
|
gateways: []*gwapiv1.Gateway{
|
|
Gateway("private-gateway",
|
|
InNamespace[*gwapiv1.Gateway]("test-ns"),
|
|
WithListener(gwapiv1.HTTPProtocolType),
|
|
WithAddresses("192.168.1.100", "10.0.0.50"),
|
|
),
|
|
},
|
|
assert: expectURLs("http://10.0.0.50/", "http://192.168.1.100/"),
|
|
},
|
|
{
|
|
name: "hostname addresses - basic resolution",
|
|
route: HTTPRoute("hostname-addr-route",
|
|
InNamespace[*gwapiv1.HTTPRoute]("test-ns"),
|
|
WithParentRef(GatewayRef("hostname-addr-gateway", RefInNamespace("test-ns"))),
|
|
),
|
|
gateways: []*gwapiv1.Gateway{
|
|
Gateway("hostname-addr-gateway",
|
|
InNamespace[*gwapiv1.Gateway]("test-ns"),
|
|
WithListener(gwapiv1.HTTPProtocolType),
|
|
WithHostnameAddresses("api.example.com", "lb.example.com"),
|
|
),
|
|
},
|
|
assert: expectURLs("http://api.example.com/", "http://lb.example.com/"),
|
|
},
|
|
{
|
|
name: "mixed hostname and IP addresses - deterministic selection",
|
|
route: HTTPRoute("mixed-addr-route",
|
|
InNamespace[*gwapiv1.HTTPRoute]("test-ns"),
|
|
WithParentRef(GatewayRef("mixed-addr-gateway", RefInNamespace("test-ns"))),
|
|
),
|
|
gateways: []*gwapiv1.Gateway{
|
|
Gateway("mixed-addr-gateway",
|
|
InNamespace[*gwapiv1.Gateway]("test-ns"),
|
|
WithListener(gwapiv1.HTTPProtocolType),
|
|
WithMixedAddresses(
|
|
IPAddress("203.0.113.1"),
|
|
HostnameAddress("api.example.com"),
|
|
HostnameAddress("lb.example.com"),
|
|
),
|
|
),
|
|
},
|
|
assert: expectURLs(
|
|
"http://203.0.113.1/",
|
|
"http://api.example.com/",
|
|
"http://lb.example.com/",
|
|
),
|
|
},
|
|
// ===== Error cases =====
|
|
{
|
|
name: "gateway not found should cause not found error",
|
|
route: HTTPRoute("nonexistent-route",
|
|
InNamespace[*gwapiv1.HTTPRoute]("test-ns"),
|
|
WithParentRef(GatewayRef("nonexistent-gateway", RefInNamespace("test-ns"))),
|
|
),
|
|
gateways: nil,
|
|
assert: expectError(apierrors.IsNotFound),
|
|
},
|
|
{
|
|
name: "no addresses at all - NoURLsDiscoveredError",
|
|
route: HTTPRoute("no-addr-route",
|
|
InNamespace[*gwapiv1.HTTPRoute]("test-ns"),
|
|
WithParentRef(GatewayRef("no-addr-gateway", RefInNamespace("test-ns"))),
|
|
),
|
|
gateways: []*gwapiv1.Gateway{
|
|
Gateway("no-addr-gateway",
|
|
InNamespace[*gwapiv1.Gateway]("test-ns"),
|
|
WithListener(gwapiv1.HTTPProtocolType),
|
|
),
|
|
},
|
|
assert: expectError(llmisvc.IsNoURLsDiscovered),
|
|
},
|
|
{
|
|
name: "gateway with only TCP listeners returns error",
|
|
route: HTTPRoute("test-route",
|
|
InNamespace[*gwapiv1.HTTPRoute]("test-ns"),
|
|
WithParentRef(GatewayRef("test-gateway", RefInNamespace("test-ns"))),
|
|
),
|
|
gateways: []*gwapiv1.Gateway{
|
|
Gateway("test-gateway",
|
|
InNamespace[*gwapiv1.Gateway]("test-ns"),
|
|
WithListeners(gwapiv1.Listener{
|
|
Name: "tcp-listener",
|
|
Protocol: gwapiv1.TCPProtocolType,
|
|
Port: 9000,
|
|
}),
|
|
WithAddresses("203.0.113.1"),
|
|
),
|
|
},
|
|
assert: expectError(func(err error) bool { return err != nil }),
|
|
},
|
|
|
|
// ===== Port handling =====
|
|
{
|
|
name: "custom port handling - non-standard HTTP port",
|
|
route: HTTPRoute("custom-port-route",
|
|
InNamespace[*gwapiv1.HTTPRoute]("test-ns"),
|
|
WithParentRef(GatewayRef("custom-port-gateway", RefInNamespace("test-ns"))),
|
|
),
|
|
gateways: []*gwapiv1.Gateway{
|
|
Gateway("custom-port-gateway",
|
|
InNamespace[*gwapiv1.Gateway]("test-ns"),
|
|
WithListeners(gwapiv1.Listener{
|
|
Protocol: gwapiv1.HTTPProtocolType,
|
|
Port: 8080,
|
|
}),
|
|
WithAddresses("203.0.113.1"),
|
|
),
|
|
},
|
|
assert: expectURLs("http://203.0.113.1:8080/"),
|
|
},
|
|
{
|
|
name: "custom port handling - non-standard HTTPS port",
|
|
route: HTTPRoute("custom-https-port-route",
|
|
InNamespace[*gwapiv1.HTTPRoute]("test-ns"),
|
|
WithParentRef(GatewayRef("custom-https-port-gateway", RefInNamespace("test-ns"))),
|
|
WithHostnames("secure.example.com"),
|
|
),
|
|
gateways: []*gwapiv1.Gateway{
|
|
Gateway("custom-https-port-gateway",
|
|
InNamespace[*gwapiv1.Gateway]("test-ns"),
|
|
WithListeners(gwapiv1.Listener{
|
|
Protocol: gwapiv1.HTTPSProtocolType,
|
|
Port: 8443,
|
|
}),
|
|
WithAddresses("203.0.113.1"),
|
|
),
|
|
},
|
|
assert: expectURLs("https://secure.example.com:8443/"),
|
|
},
|
|
{
|
|
name: "standard ports omitted - HTTP port 80",
|
|
route: HTTPRoute("standard-http-route",
|
|
InNamespace[*gwapiv1.HTTPRoute]("test-ns"),
|
|
WithParentRef(GatewayRef("standard-http-gateway", RefInNamespace("test-ns"))),
|
|
),
|
|
gateways: []*gwapiv1.Gateway{
|
|
Gateway("standard-http-gateway",
|
|
InNamespace[*gwapiv1.Gateway]("test-ns"),
|
|
WithListeners(gwapiv1.Listener{
|
|
Protocol: gwapiv1.HTTPProtocolType,
|
|
Port: 80,
|
|
}),
|
|
WithAddresses("203.0.113.1"),
|
|
),
|
|
},
|
|
assert: expectURLs("http://203.0.113.1/"),
|
|
},
|
|
{
|
|
name: "standard ports omitted - HTTPS port 443",
|
|
route: HTTPRoute("standard-https-route",
|
|
InNamespace[*gwapiv1.HTTPRoute]("test-ns"),
|
|
WithParentRef(GatewayRef("standard-https-gateway", RefInNamespace("test-ns"))),
|
|
),
|
|
gateways: []*gwapiv1.Gateway{
|
|
Gateway("standard-https-gateway",
|
|
InNamespace[*gwapiv1.Gateway]("test-ns"),
|
|
WithListeners(gwapiv1.Listener{
|
|
Protocol: gwapiv1.HTTPSProtocolType,
|
|
Port: 443,
|
|
}),
|
|
WithAddresses("203.0.113.1"),
|
|
),
|
|
},
|
|
assert: expectURLs("https://203.0.113.1/"),
|
|
},
|
|
|
|
// ===== sectionName listener selection =====
|
|
{
|
|
name: "sectionName isolates to specific listener - no leakage from other listeners",
|
|
route: HTTPRoute("test-route",
|
|
InNamespace[*gwapiv1.HTTPRoute]("test-ns"),
|
|
WithParentRef(gwapiv1.ParentReference{
|
|
Name: "multi-listener-gateway",
|
|
Namespace: ptr.To(gwapiv1.Namespace("gw-ns")),
|
|
SectionName: ptr.To(gwapiv1.SectionName("http")),
|
|
Group: ptr.To(gwapiv1.Group("gateway.networking.k8s.io")),
|
|
Kind: ptr.To(gwapiv1.Kind("Gateway")),
|
|
}),
|
|
),
|
|
gateways: []*gwapiv1.Gateway{
|
|
Gateway("multi-listener-gateway",
|
|
InNamespace[*gwapiv1.Gateway]("gw-ns"),
|
|
WithListeners(
|
|
gwapiv1.Listener{
|
|
Name: "http",
|
|
Protocol: gwapiv1.HTTPProtocolType,
|
|
Port: 80,
|
|
Hostname: ptr.To(gwapiv1.Hostname("http.example.com")),
|
|
},
|
|
gwapiv1.Listener{
|
|
Name: "https",
|
|
Protocol: gwapiv1.HTTPSProtocolType,
|
|
Port: 443,
|
|
Hostname: ptr.To(gwapiv1.Hostname("https.example.com")),
|
|
},
|
|
gwapiv1.Listener{
|
|
Name: "internal",
|
|
Protocol: gwapiv1.HTTPProtocolType,
|
|
Port: 8080,
|
|
Hostname: ptr.To(gwapiv1.Hostname("internal.example.com")),
|
|
},
|
|
),
|
|
WithAddresses("203.0.113.1"),
|
|
),
|
|
},
|
|
assert: func(g Gomega, urls []string, err error) {
|
|
g.Expect(err).ToNot(HaveOccurred())
|
|
g.Expect(urls).To(Equal([]string{"http://http.example.com/"}))
|
|
g.Expect(urls).ToNot(ContainElement(ContainSubstring("https.example.com")))
|
|
g.Expect(urls).ToNot(ContainElement(ContainSubstring("internal.example.com")))
|
|
},
|
|
},
|
|
|
|
// ===== Comprehensive URL generation =====
|
|
{
|
|
name: "sectionName does not match any listener - should error, not silently use wrong listener",
|
|
route: HTTPRoute("mismatched-section-route",
|
|
InNamespace[*gwapiv1.HTTPRoute]("test-ns"),
|
|
WithParentRef(gwapiv1.ParentReference{
|
|
Name: "mismatched-section-gateway",
|
|
Namespace: ptr.To(gwapiv1.Namespace("test-ns")),
|
|
SectionName: ptr.To(gwapiv1.SectionName("nonexistent-listener")),
|
|
Group: ptr.To(gwapiv1.Group("gateway.networking.k8s.io")),
|
|
Kind: ptr.To(gwapiv1.Kind("Gateway")),
|
|
}),
|
|
WithHostnames("api.example.com"),
|
|
),
|
|
gateways: []*gwapiv1.Gateway{
|
|
Gateway("mismatched-section-gateway",
|
|
InNamespace[*gwapiv1.Gateway]("test-ns"),
|
|
WithListeners(
|
|
gwapiv1.Listener{
|
|
Name: "http-listener",
|
|
Protocol: gwapiv1.HTTPProtocolType,
|
|
Port: 80,
|
|
},
|
|
gwapiv1.Listener{
|
|
Name: "https-listener",
|
|
Protocol: gwapiv1.HTTPSProtocolType,
|
|
Port: 443,
|
|
},
|
|
),
|
|
WithAddresses("203.0.113.1"),
|
|
),
|
|
},
|
|
assert: func(g Gomega, urls []string, err error) {
|
|
g.Expect(err).To(HaveOccurred())
|
|
},
|
|
},
|
|
{
|
|
name: "gateway with no listeners - should error, not panic",
|
|
route: HTTPRoute("no-listener-route",
|
|
InNamespace[*gwapiv1.HTTPRoute]("test-ns"),
|
|
WithParentRef(GatewayRef("no-listener-gateway", RefInNamespace("test-ns"))),
|
|
),
|
|
gateways: []*gwapiv1.Gateway{
|
|
Gateway("no-listener-gateway",
|
|
InNamespace[*gwapiv1.Gateway]("test-ns"),
|
|
// No WithListener — Gateway has empty Listeners slice
|
|
WithAddresses("203.0.113.1"),
|
|
),
|
|
},
|
|
assert: func(g Gomega, urls []string, err error) {
|
|
g.Expect(err).To(HaveOccurred())
|
|
},
|
|
},
|
|
{
|
|
name: "multiple hostnames and addresses - comprehensive URL generation",
|
|
route: HTTPRoute("comprehensive-route",
|
|
InNamespace[*gwapiv1.HTTPRoute]("test-ns"),
|
|
WithParentRef(GatewayRef("comprehensive-gateway", RefInNamespace("test-ns"))),
|
|
WithHostnames("api.example.com", "v2.example.com"),
|
|
),
|
|
gateways: []*gwapiv1.Gateway{
|
|
Gateway("comprehensive-gateway",
|
|
InNamespace[*gwapiv1.Gateway]("test-ns"),
|
|
WithListener(gwapiv1.HTTPProtocolType),
|
|
WithAddresses("203.0.113.1", "203.0.113.2"),
|
|
),
|
|
},
|
|
assert: expectURLs("http://api.example.com/", "http://v2.example.com/"),
|
|
},
|
|
|
|
// ===== Listener hostname fallback =====
|
|
{
|
|
name: "listener hostname fallback - no route hostnames",
|
|
route: HTTPRoute("listener-hostname-route",
|
|
InNamespace[*gwapiv1.HTTPRoute]("test-ns"),
|
|
WithParentRef(GatewayRef("listener-hostname-gateway", RefInNamespace("test-ns"))),
|
|
),
|
|
gateways: []*gwapiv1.Gateway{
|
|
Gateway("listener-hostname-gateway",
|
|
InNamespace[*gwapiv1.Gateway]("test-ns"),
|
|
WithListeners(gwapiv1.Listener{
|
|
Protocol: gwapiv1.HTTPProtocolType,
|
|
Port: 80,
|
|
Hostname: ptr.To(gwapiv1.Hostname("listener.example.com")),
|
|
}),
|
|
WithAddresses("203.0.113.1"),
|
|
),
|
|
},
|
|
assert: expectURLs("http://listener.example.com/"),
|
|
},
|
|
{
|
|
name: "listener hostname fallback - route has wildcard hostname",
|
|
route: HTTPRoute("wildcard-listener-route",
|
|
InNamespace[*gwapiv1.HTTPRoute]("test-ns"),
|
|
WithParentRef(GatewayRef("wildcard-listener-gateway", RefInNamespace("test-ns"))),
|
|
WithHostnames("*"),
|
|
),
|
|
gateways: []*gwapiv1.Gateway{
|
|
Gateway("wildcard-listener-gateway",
|
|
InNamespace[*gwapiv1.Gateway]("test-ns"),
|
|
WithListeners(gwapiv1.Listener{
|
|
Protocol: gwapiv1.HTTPProtocolType,
|
|
Port: 80,
|
|
Hostname: ptr.To(gwapiv1.Hostname("listener.example.com")),
|
|
}),
|
|
WithAddresses("203.0.113.1"),
|
|
),
|
|
},
|
|
assert: expectURLs("http://listener.example.com/"),
|
|
},
|
|
{
|
|
name: "listener hostname fallback - route hostname takes precedence",
|
|
route: HTTPRoute("precedence-route",
|
|
InNamespace[*gwapiv1.HTTPRoute]("test-ns"),
|
|
WithParentRef(GatewayRef("precedence-gateway", RefInNamespace("test-ns"))),
|
|
WithHostnames("route.example.com"),
|
|
),
|
|
gateways: []*gwapiv1.Gateway{
|
|
Gateway("precedence-gateway",
|
|
InNamespace[*gwapiv1.Gateway]("test-ns"),
|
|
WithListeners(gwapiv1.Listener{
|
|
Protocol: gwapiv1.HTTPProtocolType,
|
|
Port: 80,
|
|
Hostname: ptr.To(gwapiv1.Hostname("listener.example.com")),
|
|
}),
|
|
WithAddresses("203.0.113.1"),
|
|
),
|
|
},
|
|
assert: expectURLs("http://route.example.com/"),
|
|
},
|
|
{
|
|
name: "listener hostname fallback - empty listener hostname uses addresses",
|
|
route: HTTPRoute("empty-listener-route",
|
|
InNamespace[*gwapiv1.HTTPRoute]("test-ns"),
|
|
WithParentRef(GatewayRef("empty-listener-gateway", RefInNamespace("test-ns"))),
|
|
),
|
|
gateways: []*gwapiv1.Gateway{
|
|
Gateway("empty-listener-gateway",
|
|
InNamespace[*gwapiv1.Gateway]("test-ns"),
|
|
WithListeners(gwapiv1.Listener{
|
|
Protocol: gwapiv1.HTTPProtocolType,
|
|
Port: 80,
|
|
}),
|
|
WithAddresses("203.0.113.1"),
|
|
),
|
|
},
|
|
assert: expectURLs("http://203.0.113.1/"),
|
|
},
|
|
|
|
// ===== Listener wildcard hostname =====
|
|
{
|
|
name: "listener wildcard hostname - basic wildcard expansion",
|
|
route: HTTPRoute("wildcard-expansion-route",
|
|
InNamespace[*gwapiv1.HTTPRoute]("test-ns"),
|
|
WithParentRef(GatewayRef("wildcard-expansion-gateway", RefInNamespace("test-ns"))),
|
|
),
|
|
gateways: []*gwapiv1.Gateway{
|
|
Gateway("wildcard-expansion-gateway",
|
|
InNamespace[*gwapiv1.Gateway]("test-ns"),
|
|
WithListeners(gwapiv1.Listener{
|
|
Protocol: gwapiv1.HTTPProtocolType,
|
|
Port: 80,
|
|
Hostname: ptr.To(gwapiv1.Hostname("*.example.com")),
|
|
}),
|
|
WithAddresses("203.0.113.1"),
|
|
),
|
|
},
|
|
// Wildcards are expanded to inference.example.com
|
|
assert: expectURLs("http://inference.example.com/"),
|
|
},
|
|
{
|
|
name: "listener wildcard hostname - wildcard with subdomain",
|
|
route: HTTPRoute("subdomain-wildcard-route",
|
|
InNamespace[*gwapiv1.HTTPRoute]("test-ns"),
|
|
WithParentRef(GatewayRef("subdomain-wildcard-gateway", RefInNamespace("test-ns"))),
|
|
),
|
|
gateways: []*gwapiv1.Gateway{
|
|
Gateway("subdomain-wildcard-gateway",
|
|
InNamespace[*gwapiv1.Gateway]("test-ns"),
|
|
WithListeners(gwapiv1.Listener{
|
|
Protocol: gwapiv1.HTTPProtocolType,
|
|
Port: 80,
|
|
Hostname: ptr.To(gwapiv1.Hostname("*.api.example.com")),
|
|
}),
|
|
WithAddresses("203.0.113.1"),
|
|
),
|
|
},
|
|
// Wildcards are expanded to inference.api.example.com
|
|
assert: expectURLs("http://inference.api.example.com/"),
|
|
},
|
|
{
|
|
name: "listener wildcard hostname - route hostname takes precedence over wildcard",
|
|
route: HTTPRoute("wildcard-precedence-route",
|
|
InNamespace[*gwapiv1.HTTPRoute]("test-ns"),
|
|
WithParentRef(GatewayRef("wildcard-precedence-gateway", RefInNamespace("test-ns"))),
|
|
WithHostnames("specific.example.com"),
|
|
),
|
|
gateways: []*gwapiv1.Gateway{
|
|
Gateway("wildcard-precedence-gateway",
|
|
InNamespace[*gwapiv1.Gateway]("test-ns"),
|
|
WithListeners(gwapiv1.Listener{
|
|
Protocol: gwapiv1.HTTPProtocolType,
|
|
Port: 80,
|
|
Hostname: ptr.To(gwapiv1.Hostname("*.example.com")),
|
|
}),
|
|
WithAddresses("203.0.113.1"),
|
|
),
|
|
},
|
|
assert: expectURLs("http://specific.example.com/"),
|
|
},
|
|
{
|
|
name: "listener wildcard hostname - HTTPS with wildcard",
|
|
route: HTTPRoute("https-wildcard-route",
|
|
InNamespace[*gwapiv1.HTTPRoute]("test-ns"),
|
|
WithParentRef(GatewayRef("https-wildcard-gateway", RefInNamespace("test-ns"))),
|
|
),
|
|
gateways: []*gwapiv1.Gateway{
|
|
Gateway("https-wildcard-gateway",
|
|
InNamespace[*gwapiv1.Gateway]("test-ns"),
|
|
WithListeners(gwapiv1.Listener{
|
|
Protocol: gwapiv1.HTTPSProtocolType,
|
|
Port: 443,
|
|
Hostname: ptr.To(gwapiv1.Hostname("*.secure.example.com")),
|
|
}),
|
|
WithAddresses("203.0.113.1"),
|
|
),
|
|
},
|
|
// HTTPS with wildcard expanded to inference.secure.example.com
|
|
assert: expectURLs("https://inference.secure.example.com/"),
|
|
},
|
|
{
|
|
name: "listener wildcard hostname - custom port with wildcard",
|
|
route: HTTPRoute("custom-port-wildcard-route",
|
|
InNamespace[*gwapiv1.HTTPRoute]("test-ns"),
|
|
WithParentRef(GatewayRef("custom-port-wildcard-gateway", RefInNamespace("test-ns"))),
|
|
),
|
|
gateways: []*gwapiv1.Gateway{
|
|
Gateway("custom-port-wildcard-gateway",
|
|
InNamespace[*gwapiv1.Gateway]("test-ns"),
|
|
WithListeners(gwapiv1.Listener{
|
|
Protocol: gwapiv1.HTTPProtocolType,
|
|
Port: 8080,
|
|
Hostname: ptr.To(gwapiv1.Hostname("*.example.com")),
|
|
}),
|
|
WithAddresses("203.0.113.1"),
|
|
),
|
|
},
|
|
// Custom port with wildcard expanded
|
|
assert: expectURLs("http://inference.example.com:8080/"),
|
|
},
|
|
|
|
// ===== preferredUrlScheme =====
|
|
{
|
|
name: "preferredUrlScheme=https prioritizes HTTPS listener",
|
|
route: HTTPRoute("test-route",
|
|
InNamespace[*gwapiv1.HTTPRoute]("test-ns"),
|
|
WithParentRef(GatewayRef("test-gateway", RefInNamespace("test-ns"))),
|
|
),
|
|
gateways: []*gwapiv1.Gateway{
|
|
Gateway("test-gateway",
|
|
InNamespace[*gwapiv1.Gateway]("test-ns"),
|
|
WithListeners(
|
|
gwapiv1.Listener{
|
|
Name: "http-listener",
|
|
Protocol: gwapiv1.HTTPProtocolType,
|
|
Port: 80,
|
|
},
|
|
gwapiv1.Listener{
|
|
Name: "https-listener",
|
|
Protocol: gwapiv1.HTTPSProtocolType,
|
|
Port: 443,
|
|
},
|
|
),
|
|
WithAddresses("203.0.113.1"),
|
|
),
|
|
},
|
|
preferredUrlScheme: "https",
|
|
assert: expectURLs("https://203.0.113.1/", "http://203.0.113.1/"),
|
|
},
|
|
{
|
|
name: "preferredUrlScheme=http prioritizes HTTP listener",
|
|
route: HTTPRoute("test-route",
|
|
InNamespace[*gwapiv1.HTTPRoute]("test-ns"),
|
|
WithParentRef(GatewayRef("test-gateway", RefInNamespace("test-ns"))),
|
|
),
|
|
gateways: []*gwapiv1.Gateway{
|
|
Gateway("test-gateway",
|
|
InNamespace[*gwapiv1.Gateway]("test-ns"),
|
|
WithListeners(
|
|
gwapiv1.Listener{
|
|
Name: "http-listener",
|
|
Protocol: gwapiv1.HTTPProtocolType,
|
|
Port: 80,
|
|
},
|
|
gwapiv1.Listener{
|
|
Name: "https-listener",
|
|
Protocol: gwapiv1.HTTPSProtocolType,
|
|
Port: 443,
|
|
},
|
|
),
|
|
WithAddresses("203.0.113.1"),
|
|
),
|
|
},
|
|
preferredUrlScheme: "http",
|
|
assert: expectURLs("http://203.0.113.1/", "https://203.0.113.1/"),
|
|
},
|
|
{
|
|
name: "preferredUrlScheme mismatch falls back to available listener",
|
|
route: HTTPRoute("test-route",
|
|
InNamespace[*gwapiv1.HTTPRoute]("test-ns"),
|
|
WithParentRef(GatewayRef("test-gateway", RefInNamespace("test-ns"))),
|
|
),
|
|
gateways: []*gwapiv1.Gateway{
|
|
Gateway("test-gateway",
|
|
InNamespace[*gwapiv1.Gateway]("test-ns"),
|
|
WithListeners(gwapiv1.Listener{
|
|
Name: "http-listener",
|
|
Protocol: gwapiv1.HTTPProtocolType,
|
|
Port: 80,
|
|
}),
|
|
WithAddresses("203.0.113.1"),
|
|
),
|
|
},
|
|
preferredUrlScheme: "https",
|
|
assert: expectURLs("http://203.0.113.1/"),
|
|
},
|
|
{
|
|
name: "preferredUrlScheme matching listener preserves non-standard port",
|
|
route: HTTPRoute("test-route",
|
|
InNamespace[*gwapiv1.HTTPRoute]("test-ns"),
|
|
WithParentRef(GatewayRef("test-gateway", RefInNamespace("test-ns"))),
|
|
),
|
|
gateways: []*gwapiv1.Gateway{
|
|
Gateway("test-gateway",
|
|
InNamespace[*gwapiv1.Gateway]("test-ns"),
|
|
WithListeners(gwapiv1.Listener{
|
|
Name: "https-listener",
|
|
Protocol: gwapiv1.HTTPSProtocolType,
|
|
Port: 8443,
|
|
}),
|
|
WithAddresses("203.0.113.1"),
|
|
),
|
|
},
|
|
preferredUrlScheme: "https",
|
|
assert: expectURLs("https://203.0.113.1:8443/"),
|
|
},
|
|
{
|
|
name: "empty preferredUrlScheme returns ALL listeners (HTTPS first)",
|
|
route: HTTPRoute("test-route",
|
|
InNamespace[*gwapiv1.HTTPRoute]("test-ns"),
|
|
WithParentRef(GatewayRef("test-gateway", RefInNamespace("test-ns"))),
|
|
),
|
|
gateways: []*gwapiv1.Gateway{
|
|
Gateway("test-gateway",
|
|
InNamespace[*gwapiv1.Gateway]("test-ns"),
|
|
WithListeners(
|
|
gwapiv1.Listener{
|
|
Name: "http-listener",
|
|
Protocol: gwapiv1.HTTPProtocolType,
|
|
Port: 80,
|
|
},
|
|
gwapiv1.Listener{
|
|
Name: "https-listener",
|
|
Protocol: gwapiv1.HTTPSProtocolType,
|
|
Port: 443,
|
|
},
|
|
),
|
|
WithAddresses("203.0.113.1"),
|
|
),
|
|
},
|
|
preferredUrlScheme: "",
|
|
assert: expectURLs("https://203.0.113.1/", "http://203.0.113.1/"),
|
|
},
|
|
{
|
|
name: "empty preferredUrlScheme falls back to HTTP if no HTTPS",
|
|
route: HTTPRoute("test-route",
|
|
InNamespace[*gwapiv1.HTTPRoute]("test-ns"),
|
|
WithParentRef(GatewayRef("test-gateway", RefInNamespace("test-ns"))),
|
|
),
|
|
gateways: []*gwapiv1.Gateway{
|
|
Gateway("test-gateway",
|
|
InNamespace[*gwapiv1.Gateway]("test-ns"),
|
|
WithListeners(gwapiv1.Listener{
|
|
Name: "http-listener",
|
|
Protocol: gwapiv1.HTTPProtocolType,
|
|
Port: 8080,
|
|
}),
|
|
WithAddresses("203.0.113.1"),
|
|
),
|
|
},
|
|
preferredUrlScheme: "",
|
|
assert: expectURLs("http://203.0.113.1:8080/"),
|
|
},
|
|
{
|
|
name: "sectionName takes precedence over preferredUrlScheme",
|
|
route: HTTPRoute("test-route",
|
|
InNamespace[*gwapiv1.HTTPRoute]("test-ns"),
|
|
WithParentRef(gwapiv1.ParentReference{
|
|
Name: "test-gateway",
|
|
Namespace: ptr.To(gwapiv1.Namespace("test-ns")),
|
|
SectionName: ptr.To(gwapiv1.SectionName("http-listener")),
|
|
Group: ptr.To(gwapiv1.Group("gateway.networking.k8s.io")),
|
|
Kind: ptr.To(gwapiv1.Kind("Gateway")),
|
|
}),
|
|
),
|
|
gateways: []*gwapiv1.Gateway{
|
|
Gateway("test-gateway",
|
|
InNamespace[*gwapiv1.Gateway]("test-ns"),
|
|
WithListeners(
|
|
gwapiv1.Listener{
|
|
Name: "http-listener",
|
|
Protocol: gwapiv1.HTTPProtocolType,
|
|
Port: 80,
|
|
},
|
|
gwapiv1.Listener{
|
|
Name: "https-listener",
|
|
Protocol: gwapiv1.HTTPSProtocolType,
|
|
Port: 443,
|
|
},
|
|
),
|
|
WithAddresses("203.0.113.1"),
|
|
),
|
|
},
|
|
preferredUrlScheme: "https", // ignored because sectionName is set
|
|
assert: expectURLs("http://203.0.113.1/"),
|
|
},
|
|
{
|
|
name: "gateway with multiple HTTP-capable listeners - all listeners advertised",
|
|
route: HTTPRoute("test-route",
|
|
InNamespace[*gwapiv1.HTTPRoute]("test-ns"),
|
|
WithParentRef(GatewayRef("multi-listener-gateway", RefInNamespace("gw-ns"))),
|
|
),
|
|
gateways: []*gwapiv1.Gateway{
|
|
Gateway("multi-listener-gateway",
|
|
InNamespace[*gwapiv1.Gateway]("gw-ns"),
|
|
WithListeners(
|
|
gwapiv1.Listener{
|
|
Name: "http",
|
|
Protocol: gwapiv1.HTTPProtocolType,
|
|
Port: 80,
|
|
Hostname: ptr.To(gwapiv1.Hostname("api.example.com")),
|
|
},
|
|
gwapiv1.Listener{
|
|
Name: "https",
|
|
Protocol: gwapiv1.HTTPSProtocolType,
|
|
Port: 443,
|
|
Hostname: ptr.To(gwapiv1.Hostname("api.example.com")),
|
|
},
|
|
),
|
|
WithAddresses("203.0.113.1"),
|
|
),
|
|
},
|
|
assert: expectURLs("https://api.example.com/", "http://api.example.com/"),
|
|
},
|
|
|
|
// ===== Internal service discovery =====
|
|
{
|
|
name: "discovers internal URL via gateway label",
|
|
route: HTTPRoute("test-route",
|
|
InNamespace[*gwapiv1.HTTPRoute]("test-ns"),
|
|
WithParentRef(GatewayRef("my-gateway", RefInNamespace("gateway-ns"))),
|
|
),
|
|
gateways: []*gwapiv1.Gateway{
|
|
Gateway("my-gateway",
|
|
InNamespace[*gwapiv1.Gateway]("gateway-ns"),
|
|
WithListeners(gwapiv1.Listener{
|
|
Name: "http",
|
|
Protocol: gwapiv1.HTTPProtocolType,
|
|
Port: 80,
|
|
}),
|
|
WithAddresses("203.0.113.1"),
|
|
),
|
|
},
|
|
services: []*corev1.Service{
|
|
Service("gateway-service",
|
|
InNamespace[*corev1.Service]("gateway-ns"),
|
|
WithGatewayLabel("my-gateway"),
|
|
),
|
|
},
|
|
assert: expectURLs(
|
|
"http://203.0.113.1/",
|
|
"http://gateway-service.gateway-ns.svc.cluster.local/",
|
|
),
|
|
},
|
|
{
|
|
name: "discovers internal URL via same-name service fallback",
|
|
route: HTTPRoute("test-route",
|
|
InNamespace[*gwapiv1.HTTPRoute]("test-ns"),
|
|
WithParentRef(GatewayRef("my-gateway", RefInNamespace("gateway-ns"))),
|
|
),
|
|
gateways: []*gwapiv1.Gateway{
|
|
Gateway("my-gateway",
|
|
InNamespace[*gwapiv1.Gateway]("gateway-ns"),
|
|
WithListeners(gwapiv1.Listener{
|
|
Name: "http",
|
|
Protocol: gwapiv1.HTTPProtocolType,
|
|
Port: 80,
|
|
}),
|
|
WithAddresses("203.0.113.1"),
|
|
),
|
|
},
|
|
services: []*corev1.Service{
|
|
Service("my-gateway", InNamespace[*corev1.Service]("gateway-ns")),
|
|
},
|
|
assert: expectURLs(
|
|
"http://203.0.113.1/",
|
|
"http://my-gateway.gateway-ns.svc.cluster.local/",
|
|
),
|
|
},
|
|
{
|
|
name: "internal URL includes non-standard port",
|
|
route: HTTPRoute("test-route",
|
|
InNamespace[*gwapiv1.HTTPRoute]("test-ns"),
|
|
WithParentRef(GatewayRef("my-gateway", RefInNamespace("gateway-ns"))),
|
|
),
|
|
gateways: []*gwapiv1.Gateway{
|
|
Gateway("my-gateway",
|
|
InNamespace[*gwapiv1.Gateway]("gateway-ns"),
|
|
WithListeners(gwapiv1.Listener{
|
|
Name: "http-alt",
|
|
Protocol: gwapiv1.HTTPProtocolType,
|
|
Port: 8080,
|
|
}),
|
|
WithAddresses("203.0.113.1"),
|
|
),
|
|
},
|
|
services: []*corev1.Service{
|
|
Service("gateway-service",
|
|
InNamespace[*corev1.Service]("gateway-ns"),
|
|
WithGatewayLabel("my-gateway"),
|
|
),
|
|
},
|
|
assert: expectURLs(
|
|
"http://203.0.113.1:8080/",
|
|
"http://gateway-service.gateway-ns.svc.cluster.local:8080/",
|
|
),
|
|
},
|
|
{
|
|
name: "internal URL uses same scheme as gateway listener",
|
|
route: HTTPRoute("test-route",
|
|
InNamespace[*gwapiv1.HTTPRoute]("test-ns"),
|
|
WithParentRef(GatewayRef("my-gateway", RefInNamespace("gateway-ns"))),
|
|
),
|
|
gateways: []*gwapiv1.Gateway{
|
|
Gateway("my-gateway",
|
|
InNamespace[*gwapiv1.Gateway]("gateway-ns"),
|
|
WithListeners(gwapiv1.Listener{
|
|
Name: "https",
|
|
Protocol: gwapiv1.HTTPSProtocolType,
|
|
Port: 443,
|
|
}),
|
|
WithAddresses("203.0.113.1"),
|
|
),
|
|
},
|
|
services: []*corev1.Service{
|
|
Service("gateway-service",
|
|
InNamespace[*corev1.Service]("gateway-ns"),
|
|
WithGatewayLabel("my-gateway"),
|
|
),
|
|
},
|
|
// Internal URL matches the listener's scheme and port
|
|
assert: expectURLs(
|
|
"https://203.0.113.1/",
|
|
"https://gateway-service.gateway-ns.svc.cluster.local/",
|
|
),
|
|
},
|
|
{
|
|
name: "ClusterIP-backed gateway - no duplicate when status address matches backing service hostname",
|
|
route: HTTPRoute("test-route",
|
|
InNamespace[*gwapiv1.HTTPRoute]("test-ns"),
|
|
WithParentRef(GatewayRef("my-gateway", RefInNamespace("gateway-ns"))),
|
|
),
|
|
gateways: []*gwapiv1.Gateway{
|
|
Gateway("my-gateway",
|
|
InNamespace[*gwapiv1.Gateway]("gateway-ns"),
|
|
WithListeners(gwapiv1.Listener{
|
|
Name: "https",
|
|
Protocol: gwapiv1.HTTPSProtocolType,
|
|
Port: 443,
|
|
}),
|
|
WithHostnameAddresses("gateway-service.gateway-ns.svc.cluster.local"),
|
|
),
|
|
},
|
|
services: []*corev1.Service{
|
|
Service("gateway-service",
|
|
InNamespace[*corev1.Service]("gateway-ns"),
|
|
WithGatewayLabel("my-gateway"),
|
|
),
|
|
},
|
|
assert: expectURLs(
|
|
"https://gateway-service.gateway-ns.svc.cluster.local/",
|
|
),
|
|
},
|
|
{
|
|
name: "no internal URL without backing service",
|
|
route: HTTPRoute("test-route",
|
|
InNamespace[*gwapiv1.HTTPRoute]("test-ns"),
|
|
WithParentRef(GatewayRef("my-gateway", RefInNamespace("gateway-ns"))),
|
|
),
|
|
gateways: []*gwapiv1.Gateway{
|
|
Gateway("my-gateway",
|
|
InNamespace[*gwapiv1.Gateway]("gateway-ns"),
|
|
WithListeners(gwapiv1.Listener{
|
|
Name: "http",
|
|
Protocol: gwapiv1.HTTPProtocolType,
|
|
Port: 80,
|
|
}),
|
|
WithAddresses("203.0.113.1"),
|
|
),
|
|
},
|
|
services: nil,
|
|
assert: expectURLs("http://203.0.113.1/"),
|
|
},
|
|
{
|
|
name: "no external addresses but backing service exists - returns internal URL only",
|
|
route: HTTPRoute("test-route",
|
|
InNamespace[*gwapiv1.HTTPRoute]("test-ns"),
|
|
WithParentRef(GatewayRef("my-gateway", RefInNamespace("gateway-ns"))),
|
|
),
|
|
gateways: []*gwapiv1.Gateway{
|
|
Gateway("my-gateway",
|
|
InNamespace[*gwapiv1.Gateway]("gateway-ns"),
|
|
WithListeners(gwapiv1.Listener{
|
|
Name: "http",
|
|
Protocol: gwapiv1.HTTPProtocolType,
|
|
Port: 8080,
|
|
}),
|
|
// No addresses - LoadBalancer pending
|
|
),
|
|
},
|
|
services: []*corev1.Service{
|
|
Service("gateway-service",
|
|
InNamespace[*corev1.Service]("gateway-ns"),
|
|
WithGatewayLabel("my-gateway"),
|
|
),
|
|
},
|
|
assert: expectURLs("http://gateway-service.gateway-ns.svc.cluster.local:8080/"),
|
|
},
|
|
{
|
|
name: "model-based routing with backing service - internal URLs for both paths",
|
|
route: HTTPRoute("mbr-internal-route",
|
|
InNamespace[*gwapiv1.HTTPRoute]("test-ns"),
|
|
WithParentRef(GatewayRef("mbr-internal-gw", RefInNamespace("gateway-ns"))),
|
|
WithHTTPRule(
|
|
Matches(PathPrefixMatch("/ns/name")),
|
|
WithBackendRefs(BackendRefService("svc")),
|
|
),
|
|
WithHTTPRule(
|
|
Matches(HeaderOnlyMatch("X-Gateway-Model-Name", "publishers/ns/models/m")),
|
|
WithBackendRefs(BackendRefService("svc")),
|
|
),
|
|
),
|
|
gateways: []*gwapiv1.Gateway{
|
|
Gateway("mbr-internal-gw",
|
|
InNamespace[*gwapiv1.Gateway]("gateway-ns"),
|
|
WithListeners(gwapiv1.Listener{
|
|
Name: "http",
|
|
Protocol: gwapiv1.HTTPProtocolType,
|
|
Port: 8080,
|
|
}),
|
|
),
|
|
},
|
|
services: []*corev1.Service{
|
|
Service("gateway-service",
|
|
InNamespace[*corev1.Service]("gateway-ns"),
|
|
WithGatewayLabel("mbr-internal-gw"),
|
|
),
|
|
},
|
|
modelRoutingHeader: "X-Gateway-Model-Name",
|
|
assert: expectURLs(
|
|
"http://gateway-service.gateway-ns.svc.cluster.local:8080/",
|
|
"http://gateway-service.gateway-ns.svc.cluster.local:8080/ns/name",
|
|
),
|
|
},
|
|
{
|
|
name: "multiple gateways with backing services - each gets internal URL",
|
|
route: HTTPRoute("test-route",
|
|
InNamespace[*gwapiv1.HTTPRoute]("test-ns"),
|
|
WithParentRefs(
|
|
gwapiv1.ParentReference{
|
|
Name: "gateway-a",
|
|
Namespace: ptr.To(gwapiv1.Namespace("gw-ns")),
|
|
Group: ptr.To(gwapiv1.Group("gateway.networking.k8s.io")),
|
|
Kind: ptr.To(gwapiv1.Kind("Gateway")),
|
|
},
|
|
gwapiv1.ParentReference{
|
|
Name: "gateway-b",
|
|
Namespace: ptr.To(gwapiv1.Namespace("gw-ns")),
|
|
Group: ptr.To(gwapiv1.Group("gateway.networking.k8s.io")),
|
|
Kind: ptr.To(gwapiv1.Kind("Gateway")),
|
|
},
|
|
),
|
|
),
|
|
gateways: []*gwapiv1.Gateway{
|
|
Gateway("gateway-a",
|
|
InNamespace[*gwapiv1.Gateway]("gw-ns"),
|
|
WithListeners(gwapiv1.Listener{
|
|
Name: "https",
|
|
Protocol: gwapiv1.HTTPSProtocolType,
|
|
Port: 443,
|
|
}),
|
|
WithAddresses("gw-a.example.com"),
|
|
),
|
|
Gateway("gateway-b",
|
|
InNamespace[*gwapiv1.Gateway]("gw-ns"),
|
|
WithListeners(gwapiv1.Listener{
|
|
Name: "http",
|
|
Protocol: gwapiv1.HTTPProtocolType,
|
|
Port: 80,
|
|
}),
|
|
WithAddresses("gw-b.example.com"),
|
|
),
|
|
},
|
|
services: []*corev1.Service{
|
|
Service("gateway-a", InNamespace[*corev1.Service]("gw-ns")),
|
|
Service("gateway-b-svc",
|
|
InNamespace[*corev1.Service]("gw-ns"),
|
|
WithGatewayLabel("gateway-b"),
|
|
),
|
|
},
|
|
assert: expectURLsContain(
|
|
"https://gw-a.example.com/",
|
|
"https://gateway-a.gw-ns.svc.cluster.local/",
|
|
"http://gw-b.example.com/",
|
|
"http://gateway-b-svc.gw-ns.svc.cluster.local/",
|
|
),
|
|
},
|
|
|
|
// ===== Multi-listener internal URL behavior =====
|
|
// These tests verify that internal URLs are generated for all listeners
|
|
// whose port is confirmed on the backing Service. When the Service
|
|
// declares no ports, all listeners match (backward compat).
|
|
{
|
|
name: "multi-listener with backing service - internal URL for all listeners",
|
|
route: HTTPRoute("test-route",
|
|
InNamespace[*gwapiv1.HTTPRoute]("test-ns"),
|
|
WithParentRef(GatewayRef("multi-gw", RefInNamespace("gw-ns"))),
|
|
),
|
|
gateways: []*gwapiv1.Gateway{
|
|
Gateway("multi-gw",
|
|
InNamespace[*gwapiv1.Gateway]("gw-ns"),
|
|
WithListeners(
|
|
gwapiv1.Listener{
|
|
Name: "http",
|
|
Protocol: gwapiv1.HTTPProtocolType,
|
|
Port: 80,
|
|
},
|
|
gwapiv1.Listener{
|
|
Name: "https",
|
|
Protocol: gwapiv1.HTTPSProtocolType,
|
|
Port: 443,
|
|
},
|
|
),
|
|
WithAddresses("203.0.113.1"),
|
|
),
|
|
},
|
|
services: []*corev1.Service{
|
|
Service("multi-gw-svc",
|
|
InNamespace[*corev1.Service]("gw-ns"),
|
|
WithGatewayLabel("multi-gw"),
|
|
),
|
|
},
|
|
// External: both listeners generate URLs (HTTPS sorted first by default)
|
|
// Internal: all listeners generate URLs (HTTPS sorted first by default)
|
|
assert: expectURLs(
|
|
"https://203.0.113.1/",
|
|
"http://203.0.113.1/",
|
|
"https://multi-gw-svc.gw-ns.svc.cluster.local/",
|
|
"http://multi-gw-svc.gw-ns.svc.cluster.local/",
|
|
),
|
|
},
|
|
{
|
|
name: "multi-listener with backing service - internal URLs for all listeners with preferredScheme ordering",
|
|
route: HTTPRoute("test-route",
|
|
InNamespace[*gwapiv1.HTTPRoute]("test-ns"),
|
|
WithParentRef(GatewayRef("multi-gw", RefInNamespace("gw-ns"))),
|
|
),
|
|
gateways: []*gwapiv1.Gateway{
|
|
Gateway("multi-gw",
|
|
InNamespace[*gwapiv1.Gateway]("gw-ns"),
|
|
WithListeners(
|
|
gwapiv1.Listener{
|
|
Name: "http",
|
|
Protocol: gwapiv1.HTTPProtocolType,
|
|
Port: 80,
|
|
},
|
|
gwapiv1.Listener{
|
|
Name: "https",
|
|
Protocol: gwapiv1.HTTPSProtocolType,
|
|
Port: 443,
|
|
},
|
|
),
|
|
WithAddresses("203.0.113.1"),
|
|
),
|
|
},
|
|
services: []*corev1.Service{
|
|
Service("multi-gw-svc",
|
|
InNamespace[*corev1.Service]("gw-ns"),
|
|
WithGatewayLabel("multi-gw"),
|
|
),
|
|
},
|
|
preferredUrlScheme: "http",
|
|
// With preferredScheme=http, HTTP listener sorts first for both external and internal
|
|
assert: expectURLs(
|
|
"http://203.0.113.1/",
|
|
"https://203.0.113.1/",
|
|
"http://multi-gw-svc.gw-ns.svc.cluster.local/",
|
|
"https://multi-gw-svc.gw-ns.svc.cluster.local/",
|
|
),
|
|
},
|
|
{
|
|
name: "multi-listener with sectionName and backing service - internal URL uses selected listener",
|
|
route: HTTPRoute("test-route",
|
|
InNamespace[*gwapiv1.HTTPRoute]("test-ns"),
|
|
WithParentRef(gwapiv1.ParentReference{
|
|
Name: "multi-gw",
|
|
Namespace: ptr.To(gwapiv1.Namespace("gw-ns")),
|
|
SectionName: ptr.To(gwapiv1.SectionName("https")),
|
|
Group: ptr.To(gwapiv1.Group("gateway.networking.k8s.io")),
|
|
Kind: ptr.To(gwapiv1.Kind("Gateway")),
|
|
}),
|
|
),
|
|
gateways: []*gwapiv1.Gateway{
|
|
Gateway("multi-gw",
|
|
InNamespace[*gwapiv1.Gateway]("gw-ns"),
|
|
WithListeners(
|
|
gwapiv1.Listener{
|
|
Name: "http",
|
|
Protocol: gwapiv1.HTTPProtocolType,
|
|
Port: 80,
|
|
},
|
|
gwapiv1.Listener{
|
|
Name: "https",
|
|
Protocol: gwapiv1.HTTPSProtocolType,
|
|
Port: 443,
|
|
},
|
|
gwapiv1.Listener{
|
|
Name: "http-alt",
|
|
Protocol: gwapiv1.HTTPProtocolType,
|
|
Port: 8080,
|
|
},
|
|
),
|
|
WithAddresses("203.0.113.1"),
|
|
),
|
|
},
|
|
services: []*corev1.Service{
|
|
Service("multi-gw-svc",
|
|
InNamespace[*corev1.Service]("gw-ns"),
|
|
WithGatewayLabel("multi-gw"),
|
|
),
|
|
},
|
|
// sectionName="https" selects only the HTTPS listener (port 443)
|
|
// Both external and internal URLs should use HTTPS scheme and port 443
|
|
assert: func(g Gomega, urls []string, err error) {
|
|
g.Expect(err).ToNot(HaveOccurred())
|
|
g.Expect(urls).To(Equal([]string{
|
|
"https://203.0.113.1/",
|
|
"https://multi-gw-svc.gw-ns.svc.cluster.local/",
|
|
}))
|
|
// Verify no port leakage from other listeners
|
|
g.Expect(urls).ToNot(ContainElement(ContainSubstring(":8080")))
|
|
g.Expect(urls).ToNot(ContainElement(ContainSubstring(":80")))
|
|
},
|
|
},
|
|
{
|
|
name: "multi-listener non-standard ports with backing service - internal URLs for all listeners",
|
|
route: HTTPRoute("test-route",
|
|
InNamespace[*gwapiv1.HTTPRoute]("test-ns"),
|
|
WithParentRef(GatewayRef("multi-gw", RefInNamespace("gw-ns"))),
|
|
),
|
|
gateways: []*gwapiv1.Gateway{
|
|
Gateway("multi-gw",
|
|
InNamespace[*gwapiv1.Gateway]("gw-ns"),
|
|
WithListeners(
|
|
gwapiv1.Listener{
|
|
Name: "http-alt",
|
|
Protocol: gwapiv1.HTTPProtocolType,
|
|
Port: 8080,
|
|
},
|
|
gwapiv1.Listener{
|
|
Name: "https-alt",
|
|
Protocol: gwapiv1.HTTPSProtocolType,
|
|
Port: 8443,
|
|
},
|
|
),
|
|
WithAddresses("203.0.113.1"),
|
|
),
|
|
},
|
|
services: []*corev1.Service{
|
|
Service("multi-gw-svc",
|
|
InNamespace[*corev1.Service]("gw-ns"),
|
|
WithGatewayLabel("multi-gw"),
|
|
),
|
|
},
|
|
// Without preferredScheme, HTTPS sorts before HTTP
|
|
// All listeners generate internal URLs
|
|
assert: expectURLs(
|
|
"https://203.0.113.1:8443/",
|
|
"http://203.0.113.1:8080/",
|
|
"https://multi-gw-svc.gw-ns.svc.cluster.local:8443/",
|
|
"http://multi-gw-svc.gw-ns.svc.cluster.local:8080/",
|
|
),
|
|
},
|
|
{
|
|
name: "single listener non-standard port with backing service - internal URL includes port",
|
|
route: HTTPRoute("test-route",
|
|
InNamespace[*gwapiv1.HTTPRoute]("test-ns"),
|
|
WithParentRef(GatewayRef("single-gw", RefInNamespace("gw-ns"))),
|
|
),
|
|
gateways: []*gwapiv1.Gateway{
|
|
Gateway("single-gw",
|
|
InNamespace[*gwapiv1.Gateway]("gw-ns"),
|
|
WithListeners(gwapiv1.Listener{
|
|
Name: "http-custom",
|
|
Protocol: gwapiv1.HTTPProtocolType,
|
|
Port: 9090,
|
|
}),
|
|
WithAddresses("203.0.113.1"),
|
|
),
|
|
},
|
|
services: []*corev1.Service{
|
|
Service("single-gw-svc",
|
|
InNamespace[*corev1.Service]("gw-ns"),
|
|
WithGatewayLabel("single-gw"),
|
|
),
|
|
},
|
|
// Single listener on port 9090 - both external and internal URLs should include the port
|
|
assert: expectURLs(
|
|
"http://203.0.113.1:9090/",
|
|
"http://single-gw-svc.gw-ns.svc.cluster.local:9090/",
|
|
),
|
|
},
|
|
{
|
|
name: "sectionName selects non-standard port listener - no port leakage from other listeners",
|
|
route: HTTPRoute("test-route",
|
|
InNamespace[*gwapiv1.HTTPRoute]("test-ns"),
|
|
WithParentRef(gwapiv1.ParentReference{
|
|
Name: "multi-port-gw",
|
|
Namespace: ptr.To(gwapiv1.Namespace("gw-ns")),
|
|
SectionName: ptr.To(gwapiv1.SectionName("https-alt")),
|
|
Group: ptr.To(gwapiv1.Group("gateway.networking.k8s.io")),
|
|
Kind: ptr.To(gwapiv1.Kind("Gateway")),
|
|
}),
|
|
),
|
|
gateways: []*gwapiv1.Gateway{
|
|
Gateway("multi-port-gw",
|
|
InNamespace[*gwapiv1.Gateway]("gw-ns"),
|
|
WithListeners(
|
|
gwapiv1.Listener{
|
|
Name: "http",
|
|
Protocol: gwapiv1.HTTPProtocolType,
|
|
Port: 80,
|
|
},
|
|
gwapiv1.Listener{
|
|
Name: "https-alt",
|
|
Protocol: gwapiv1.HTTPSProtocolType,
|
|
Port: 8443,
|
|
},
|
|
gwapiv1.Listener{
|
|
Name: "http-alt",
|
|
Protocol: gwapiv1.HTTPProtocolType,
|
|
Port: 8080,
|
|
},
|
|
),
|
|
WithAddresses("203.0.113.1"),
|
|
),
|
|
},
|
|
services: []*corev1.Service{
|
|
Service("multi-port-gw-svc",
|
|
InNamespace[*corev1.Service]("gw-ns"),
|
|
WithGatewayLabel("multi-port-gw"),
|
|
),
|
|
},
|
|
// sectionName selects "https-alt" (port 8443) - only that listener's port should appear
|
|
assert: func(g Gomega, urls []string, err error) {
|
|
g.Expect(err).ToNot(HaveOccurred())
|
|
g.Expect(urls).To(Equal([]string{
|
|
"https://203.0.113.1:8443/",
|
|
"https://multi-port-gw-svc.gw-ns.svc.cluster.local:8443/",
|
|
}))
|
|
// No port leakage from other listeners
|
|
g.Expect(urls).ToNot(ContainElement(ContainSubstring(":80")))
|
|
g.Expect(urls).ToNot(ContainElement(ContainSubstring(":8080")))
|
|
},
|
|
},
|
|
{
|
|
name: "multi-listener no external addresses with backing service - internal URLs for all listeners",
|
|
route: HTTPRoute("test-route",
|
|
InNamespace[*gwapiv1.HTTPRoute]("test-ns"),
|
|
WithParentRef(GatewayRef("no-addr-gw", RefInNamespace("gw-ns"))),
|
|
),
|
|
gateways: []*gwapiv1.Gateway{
|
|
Gateway("no-addr-gw",
|
|
InNamespace[*gwapiv1.Gateway]("gw-ns"),
|
|
WithListeners(
|
|
gwapiv1.Listener{
|
|
Name: "http",
|
|
Protocol: gwapiv1.HTTPProtocolType,
|
|
Port: 80,
|
|
},
|
|
gwapiv1.Listener{
|
|
Name: "https",
|
|
Protocol: gwapiv1.HTTPSProtocolType,
|
|
Port: 443,
|
|
},
|
|
),
|
|
// No addresses - e.g. LoadBalancer pending
|
|
),
|
|
},
|
|
services: []*corev1.Service{
|
|
Service("no-addr-gw-svc",
|
|
InNamespace[*corev1.Service]("gw-ns"),
|
|
WithGatewayLabel("no-addr-gw"),
|
|
),
|
|
},
|
|
// No external addresses means no external URLs.
|
|
// Internal URLs are generated for all listeners.
|
|
// Without preferredScheme, HTTPS sorts before HTTP.
|
|
assert: expectURLs(
|
|
"https://no-addr-gw-svc.gw-ns.svc.cluster.local/",
|
|
"http://no-addr-gw-svc.gw-ns.svc.cluster.local/",
|
|
),
|
|
},
|
|
{
|
|
name: "multi-listener with service ports - only matching listeners produce internal URLs",
|
|
route: HTTPRoute("test-route",
|
|
InNamespace[*gwapiv1.HTTPRoute]("test-ns"),
|
|
WithParentRef(GatewayRef("multi-gw", RefInNamespace("gw-ns"))),
|
|
),
|
|
gateways: []*gwapiv1.Gateway{
|
|
Gateway("multi-gw",
|
|
InNamespace[*gwapiv1.Gateway]("gw-ns"),
|
|
WithListeners(
|
|
gwapiv1.Listener{Name: "http", Protocol: gwapiv1.HTTPProtocolType, Port: 80},
|
|
gwapiv1.Listener{Name: "https", Protocol: gwapiv1.HTTPSProtocolType, Port: 443},
|
|
gwapiv1.Listener{Name: "http-alt", Protocol: gwapiv1.HTTPProtocolType, Port: 8080},
|
|
),
|
|
WithAddresses("203.0.113.1"),
|
|
),
|
|
},
|
|
services: []*corev1.Service{
|
|
Service("multi-gw-svc",
|
|
InNamespace[*corev1.Service]("gw-ns"),
|
|
WithGatewayLabel("multi-gw"),
|
|
WithPorts(
|
|
corev1.ServicePort{Name: "http", Port: 80},
|
|
corev1.ServicePort{Name: "http-alt", Port: 8080},
|
|
),
|
|
),
|
|
},
|
|
assert: expectURLs(
|
|
"https://203.0.113.1/",
|
|
"http://203.0.113.1/",
|
|
"http://203.0.113.1:8080/",
|
|
"http://multi-gw-svc.gw-ns.svc.cluster.local/",
|
|
"http://multi-gw-svc.gw-ns.svc.cluster.local:8080/",
|
|
),
|
|
},
|
|
{
|
|
name: "multi-listener with service ports - no matching ports produces no internal URLs",
|
|
route: HTTPRoute("test-route",
|
|
InNamespace[*gwapiv1.HTTPRoute]("test-ns"),
|
|
WithParentRef(GatewayRef("multi-gw", RefInNamespace("gw-ns"))),
|
|
),
|
|
gateways: []*gwapiv1.Gateway{
|
|
Gateway("multi-gw",
|
|
InNamespace[*gwapiv1.Gateway]("gw-ns"),
|
|
WithListeners(
|
|
gwapiv1.Listener{Name: "http", Protocol: gwapiv1.HTTPProtocolType, Port: 80},
|
|
gwapiv1.Listener{Name: "https", Protocol: gwapiv1.HTTPSProtocolType, Port: 443},
|
|
),
|
|
WithAddresses("203.0.113.1"),
|
|
),
|
|
},
|
|
services: []*corev1.Service{
|
|
Service("multi-gw-svc",
|
|
InNamespace[*corev1.Service]("gw-ns"),
|
|
WithGatewayLabel("multi-gw"),
|
|
WithPorts(corev1.ServicePort{Name: "grpc", Port: 9090}),
|
|
),
|
|
},
|
|
assert: expectURLs(
|
|
"https://203.0.113.1/",
|
|
"http://203.0.113.1/",
|
|
),
|
|
},
|
|
{
|
|
name: "ClusterIP-backed gateway with multi-listener - dedup prevents duplicate internal URLs",
|
|
route: HTTPRoute("test-route",
|
|
InNamespace[*gwapiv1.HTTPRoute]("test-ns"),
|
|
WithParentRef(GatewayRef("my-gateway", RefInNamespace("gw-ns"))),
|
|
),
|
|
gateways: []*gwapiv1.Gateway{
|
|
Gateway("my-gateway",
|
|
InNamespace[*gwapiv1.Gateway]("gw-ns"),
|
|
WithListeners(
|
|
gwapiv1.Listener{Name: "http", Protocol: gwapiv1.HTTPProtocolType, Port: 80},
|
|
gwapiv1.Listener{Name: "https", Protocol: gwapiv1.HTTPSProtocolType, Port: 443},
|
|
),
|
|
WithHostnameAddresses("gw-svc.gw-ns.svc.cluster.local"),
|
|
),
|
|
},
|
|
services: []*corev1.Service{
|
|
Service("gw-svc",
|
|
InNamespace[*corev1.Service]("gw-ns"),
|
|
WithGatewayLabel("my-gateway"),
|
|
),
|
|
},
|
|
assert: expectURLs(
|
|
"https://gw-svc.gw-ns.svc.cluster.local/",
|
|
"http://gw-svc.gw-ns.svc.cluster.local/",
|
|
),
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
g := NewWithT(t)
|
|
ctx := t.Context()
|
|
|
|
scheme := runtime.NewScheme()
|
|
g.Expect(gwapiv1.Install(scheme)).To(Succeed())
|
|
g.Expect(corev1.AddToScheme(scheme)).To(Succeed())
|
|
|
|
var objects []client.Object
|
|
if tt.route != nil {
|
|
objects = append(objects, tt.route)
|
|
}
|
|
for _, gw := range tt.gateways {
|
|
objects = append(objects, gw)
|
|
}
|
|
for _, svc := range tt.services {
|
|
objects = append(objects, svc)
|
|
}
|
|
objects = append(objects, DefaultGatewayClass())
|
|
|
|
fakeClient := fake.NewClientBuilder().
|
|
WithScheme(scheme).
|
|
WithObjects(objects...).
|
|
Build()
|
|
|
|
gateways, gwErr := llmisvc.DiscoverGateways(ctx, fakeClient, tt.route)
|
|
if gwErr != nil {
|
|
tt.assert(g, nil, gwErr)
|
|
return
|
|
}
|
|
|
|
urls, err := llmisvc.DiscoverURLs(ctx, fakeClient, gateways, tt.route, llmisvc.Config{UrlScheme: tt.preferredUrlScheme, ModelBasedRoutingHeaderName: tt.modelRoutingHeader})
|
|
|
|
var actualURLs []string
|
|
for _, url := range urls {
|
|
actualURLs = append(actualURLs, url.URL.String())
|
|
}
|
|
|
|
tt.assert(g, actualURLs, err)
|
|
})
|
|
}
|
|
|
|
t.Run("Origin tracks the source gateway for each discovered URL", func(t *testing.T) {
|
|
g := NewWithT(t)
|
|
ctx := t.Context()
|
|
|
|
scheme := runtime.NewScheme()
|
|
g.Expect(gwapiv1.Install(scheme)).To(Succeed())
|
|
g.Expect(corev1.AddToScheme(scheme)).To(Succeed())
|
|
|
|
route := HTTPRoute("test-route",
|
|
InNamespace[*gwapiv1.HTTPRoute]("test-ns"),
|
|
WithParentRefs(
|
|
gwapiv1.ParentReference{
|
|
Name: "gateway-a",
|
|
Namespace: ptr.To(gwapiv1.Namespace("gw-ns")),
|
|
Group: ptr.To(gwapiv1.Group("gateway.networking.k8s.io")),
|
|
Kind: ptr.To(gwapiv1.Kind("Gateway")),
|
|
},
|
|
gwapiv1.ParentReference{
|
|
Name: "gateway-b",
|
|
Namespace: ptr.To(gwapiv1.Namespace("gw-ns")),
|
|
Group: ptr.To(gwapiv1.Group("gateway.networking.k8s.io")),
|
|
Kind: ptr.To(gwapiv1.Kind("Gateway")),
|
|
},
|
|
),
|
|
)
|
|
|
|
fakeClient := fake.NewClientBuilder().
|
|
WithScheme(scheme).
|
|
WithObjects(
|
|
route,
|
|
Gateway("gateway-a",
|
|
InNamespace[*gwapiv1.Gateway]("gw-ns"),
|
|
WithListeners(gwapiv1.Listener{
|
|
Name: "https",
|
|
Protocol: gwapiv1.HTTPSProtocolType,
|
|
Port: 443,
|
|
}),
|
|
WithAddresses("gw-a.example.com"),
|
|
),
|
|
Gateway("gateway-b",
|
|
InNamespace[*gwapiv1.Gateway]("gw-ns"),
|
|
WithListeners(gwapiv1.Listener{
|
|
Name: "http",
|
|
Protocol: gwapiv1.HTTPProtocolType,
|
|
Port: 80,
|
|
}),
|
|
WithAddresses("gw-b.example.com"),
|
|
),
|
|
DefaultGatewayClass(),
|
|
).
|
|
Build()
|
|
|
|
gateways, gwErr := llmisvc.DiscoverGateways(ctx, fakeClient, route)
|
|
g.Expect(gwErr).ToNot(HaveOccurred())
|
|
discovered, err := llmisvc.DiscoverURLs(ctx, fakeClient, gateways, route, llmisvc.Config{})
|
|
g.Expect(err).ToNot(HaveOccurred())
|
|
g.Expect(discovered).ToNot(BeEmpty())
|
|
|
|
for _, d := range discovered {
|
|
g.Expect(d.Origin).ToNot(BeNil(), "Origin must be set for URL %s", d.URL.String())
|
|
g.Expect(string(d.Origin.Kind)).To(Equal("Gateway"))
|
|
g.Expect(string(d.Origin.Group)).To(Equal(gwapiv1.GroupName))
|
|
g.Expect(string(ptr.Deref(d.Origin.Namespace, ""))).To(Equal("gw-ns"))
|
|
|
|
host := d.URL.URL().Hostname()
|
|
switch host {
|
|
case "gw-a.example.com":
|
|
g.Expect(string(d.Origin.Name)).To(Equal("gateway-a"))
|
|
case "gw-b.example.com":
|
|
g.Expect(string(d.Origin.Name)).To(Equal("gateway-b"))
|
|
default:
|
|
t.Errorf("unexpected host %s", host)
|
|
}
|
|
}
|
|
})
|
|
}
|
|
|
|
func TestFilterURLs(t *testing.T) {
|
|
toDiscoveredURLs := func(urls []string) ([]llmisvc.DiscoveredURL, error) {
|
|
discovered := make([]llmisvc.DiscoveredURL, 0, len(urls))
|
|
for _, urlStr := range urls {
|
|
u, err := apis.ParseURL(urlStr)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
discovered = append(discovered, llmisvc.DiscoveredURL{URL: u})
|
|
}
|
|
|
|
return discovered, nil
|
|
}
|
|
t.Run("mixed internal and external URLs", func(t *testing.T) {
|
|
g := NewGomegaWithT(t)
|
|
inputURLs := []string{
|
|
"http://192.168.1.10/",
|
|
"http://api.example.com/",
|
|
"http://10.0.0.20/",
|
|
"https://secure.example.com/",
|
|
"http://localhost/",
|
|
"http://203.0.113.1/",
|
|
}
|
|
expectedInternal := []string{
|
|
"http://192.168.1.10/",
|
|
"http://10.0.0.20/",
|
|
"http://localhost/",
|
|
}
|
|
expectedExternal := []string{
|
|
"http://api.example.com/",
|
|
"https://secure.example.com/",
|
|
"http://203.0.113.1/",
|
|
}
|
|
|
|
discovered, err := toDiscoveredURLs(inputURLs)
|
|
g.Expect(err).ToNot(HaveOccurred())
|
|
|
|
internalURLs := llmisvc.FilterInternalURLs(discovered)
|
|
actualInternal := make([]string, 0, len(internalURLs))
|
|
for _, url := range internalURLs {
|
|
actualInternal = append(actualInternal, url.URL.String())
|
|
}
|
|
g.Expect(actualInternal).To(Equal(expectedInternal))
|
|
|
|
externalURLs := llmisvc.FilterExternalURLs(discovered)
|
|
actualExternal := make([]string, 0, len(externalURLs))
|
|
for _, url := range externalURLs {
|
|
actualExternal = append(actualExternal, url.URL.String())
|
|
}
|
|
g.Expect(actualExternal).To(Equal(expectedExternal))
|
|
})
|
|
|
|
t.Run("URLs with custom ports", func(t *testing.T) {
|
|
g := NewGomegaWithT(t)
|
|
inputURLs := []string{
|
|
"http://192.168.1.10:8080/",
|
|
"http://api.example.com:8080/",
|
|
"https://secure.example.com:8443/",
|
|
"http://localhost:3000/",
|
|
}
|
|
expectedInternal := []string{
|
|
"http://192.168.1.10:8080/",
|
|
"http://localhost:3000/",
|
|
}
|
|
expectedExternal := []string{
|
|
"http://api.example.com:8080/",
|
|
"https://secure.example.com:8443/",
|
|
}
|
|
|
|
discovered, err := toDiscoveredURLs(inputURLs)
|
|
g.Expect(err).ToNot(HaveOccurred())
|
|
|
|
internalURLs := llmisvc.FilterInternalURLs(discovered)
|
|
actualInternal := make([]string, 0, len(internalURLs))
|
|
for _, url := range internalURLs {
|
|
actualInternal = append(actualInternal, url.URL.String())
|
|
}
|
|
g.Expect(actualInternal).To(Equal(expectedInternal))
|
|
|
|
externalURLs := llmisvc.FilterExternalURLs(discovered)
|
|
actualExternal := make([]string, 0, len(externalURLs))
|
|
for _, url := range externalURLs {
|
|
actualExternal = append(actualExternal, url.URL.String())
|
|
}
|
|
g.Expect(actualExternal).To(Equal(expectedExternal))
|
|
})
|
|
|
|
t.Run("internal hostname types", func(t *testing.T) {
|
|
g := NewGomegaWithT(t)
|
|
inputURLs := []string{
|
|
"http://localhost/",
|
|
"http://service.local/",
|
|
"http://app.localhost/",
|
|
"http://backend.internal/",
|
|
"http://api.example.com/",
|
|
}
|
|
expectedInternal := []string{
|
|
"http://localhost/",
|
|
"http://service.local/",
|
|
"http://app.localhost/",
|
|
"http://backend.internal/",
|
|
}
|
|
expectedExternal := []string{
|
|
"http://api.example.com/",
|
|
}
|
|
|
|
discovered, err := toDiscoveredURLs(inputURLs)
|
|
g.Expect(err).ToNot(HaveOccurred())
|
|
|
|
internalURLs := llmisvc.FilterInternalURLs(discovered)
|
|
actualInternal := make([]string, 0, len(internalURLs))
|
|
for _, url := range internalURLs {
|
|
actualInternal = append(actualInternal, url.URL.String())
|
|
}
|
|
g.Expect(actualInternal).To(Equal(expectedInternal))
|
|
|
|
externalURLs := llmisvc.FilterExternalURLs(discovered)
|
|
actualExternal := make([]string, 0, len(externalURLs))
|
|
for _, url := range externalURLs {
|
|
actualExternal = append(actualExternal, url.URL.String())
|
|
}
|
|
g.Expect(actualExternal).To(Equal(expectedExternal))
|
|
})
|
|
|
|
t.Run("all internal URLs", func(t *testing.T) {
|
|
g := NewGomegaWithT(t)
|
|
inputURLs := []string{
|
|
"http://192.168.1.10/",
|
|
"http://10.0.0.20/",
|
|
"http://localhost/",
|
|
}
|
|
expectedInternal := []string{
|
|
"http://192.168.1.10/",
|
|
"http://10.0.0.20/",
|
|
"http://localhost/",
|
|
}
|
|
expectedExternal := []string{}
|
|
|
|
discovered, err := toDiscoveredURLs(inputURLs)
|
|
g.Expect(err).ToNot(HaveOccurred())
|
|
|
|
internalURLs := llmisvc.FilterInternalURLs(discovered)
|
|
actualInternal := make([]string, 0, len(internalURLs))
|
|
for _, url := range internalURLs {
|
|
actualInternal = append(actualInternal, url.URL.String())
|
|
}
|
|
g.Expect(actualInternal).To(Equal(expectedInternal))
|
|
|
|
externalURLs := llmisvc.FilterExternalURLs(discovered)
|
|
actualExternal := make([]string, 0, len(externalURLs))
|
|
for _, url := range externalURLs {
|
|
actualExternal = append(actualExternal, url.URL.String())
|
|
}
|
|
g.Expect(actualExternal).To(Equal(expectedExternal))
|
|
})
|
|
|
|
t.Run("all external URLs", func(t *testing.T) {
|
|
g := NewGomegaWithT(t)
|
|
inputURLs := []string{
|
|
"http://api.example.com/",
|
|
"https://secure.example.com/",
|
|
"http://203.0.113.1/",
|
|
}
|
|
expectedInternal := []string{}
|
|
expectedExternal := []string{
|
|
"http://api.example.com/",
|
|
"https://secure.example.com/",
|
|
"http://203.0.113.1/",
|
|
}
|
|
|
|
discovered, err := toDiscoveredURLs(inputURLs)
|
|
g.Expect(err).ToNot(HaveOccurred())
|
|
|
|
internalURLs := llmisvc.FilterInternalURLs(discovered)
|
|
actualInternal := make([]string, 0, len(internalURLs))
|
|
for _, url := range internalURLs {
|
|
actualInternal = append(actualInternal, url.URL.String())
|
|
}
|
|
g.Expect(actualInternal).To(Equal(expectedInternal))
|
|
|
|
externalURLs := llmisvc.FilterExternalURLs(discovered)
|
|
actualExternal := make([]string, 0, len(externalURLs))
|
|
for _, url := range externalURLs {
|
|
actualExternal = append(actualExternal, url.URL.String())
|
|
}
|
|
g.Expect(actualExternal).To(Equal(expectedExternal))
|
|
})
|
|
|
|
t.Run("empty URL slice", func(t *testing.T) {
|
|
g := NewGomegaWithT(t)
|
|
inputURLs := []string{}
|
|
expectedInternal := []string{}
|
|
expectedExternal := []string{}
|
|
|
|
discovered, err := toDiscoveredURLs(inputURLs)
|
|
g.Expect(err).ToNot(HaveOccurred())
|
|
|
|
internalURLs := llmisvc.FilterInternalURLs(discovered)
|
|
actualInternal := make([]string, 0, len(internalURLs))
|
|
for _, url := range internalURLs {
|
|
actualInternal = append(actualInternal, url.URL.String())
|
|
}
|
|
g.Expect(actualInternal).To(Equal(expectedInternal))
|
|
|
|
externalURLs := llmisvc.FilterExternalURLs(discovered)
|
|
actualExternal := make([]string, 0, len(externalURLs))
|
|
for _, url := range externalURLs {
|
|
actualExternal = append(actualExternal, url.URL.String())
|
|
}
|
|
g.Expect(actualExternal).To(Equal(expectedExternal))
|
|
})
|
|
|
|
t.Run("URLs with paths", func(t *testing.T) {
|
|
g := NewGomegaWithT(t)
|
|
inputURLs := []string{
|
|
"http://192.168.1.10/api/v1/models",
|
|
"http://api.example.com/api/v1/models",
|
|
"http://localhost:8080/health",
|
|
}
|
|
expectedInternal := []string{
|
|
"http://192.168.1.10/api/v1/models",
|
|
"http://localhost:8080/health",
|
|
}
|
|
expectedExternal := []string{
|
|
"http://api.example.com/api/v1/models",
|
|
}
|
|
|
|
discovered, err := toDiscoveredURLs(inputURLs)
|
|
g.Expect(err).ToNot(HaveOccurred())
|
|
|
|
internalURLs := llmisvc.FilterInternalURLs(discovered)
|
|
actualInternal := make([]string, 0, len(internalURLs))
|
|
for _, url := range internalURLs {
|
|
actualInternal = append(actualInternal, url.URL.String())
|
|
}
|
|
g.Expect(actualInternal).To(Equal(expectedInternal))
|
|
|
|
externalURLs := llmisvc.FilterExternalURLs(discovered)
|
|
actualExternal := make([]string, 0, len(externalURLs))
|
|
for _, url := range externalURLs {
|
|
actualExternal = append(actualExternal, url.URL.String())
|
|
}
|
|
g.Expect(actualExternal).To(Equal(expectedExternal))
|
|
})
|
|
|
|
t.Run("IsInternalURL and IsExternalURL are opposites", func(t *testing.T) {
|
|
g := NewGomegaWithT(t)
|
|
testURLs := []string{
|
|
"http://192.168.1.10/",
|
|
"http://api.example.com/",
|
|
"http://localhost/",
|
|
"https://secure.example.com:8443/",
|
|
}
|
|
|
|
for _, urlStr := range testURLs {
|
|
url, err := apis.ParseURL(urlStr)
|
|
g.Expect(err).ToNot(HaveOccurred())
|
|
|
|
isInternal := llmisvc.IsInternalURL(url)
|
|
isExternal := llmisvc.IsExternalURL(url)
|
|
|
|
g.Expect(isInternal).To(Equal(!isExternal), "URL %s should be either internal or external, not both", urlStr)
|
|
}
|
|
})
|
|
|
|
t.Run("AddressTypeName", func(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
url string
|
|
expected string
|
|
}{
|
|
{
|
|
name: "external hostname",
|
|
url: "https://api.example.com/ns/name",
|
|
expected: "gateway-external",
|
|
},
|
|
{
|
|
name: "external IP",
|
|
url: "http://203.0.113.1/ns/name",
|
|
expected: "gateway-external",
|
|
},
|
|
{
|
|
name: "private IP",
|
|
url: "http://192.168.1.100/ns/name",
|
|
expected: "internal",
|
|
},
|
|
{
|
|
name: "localhost",
|
|
url: "http://localhost/ns/name",
|
|
expected: "internal",
|
|
},
|
|
{
|
|
name: "cluster-local service",
|
|
url: "http://my-service.default.svc.cluster.local/ns/name",
|
|
expected: "gateway-internal",
|
|
},
|
|
{
|
|
name: "cluster-local with port",
|
|
url: "http://gateway.ns.svc.cluster.local:8080/ns/name",
|
|
expected: "gateway-internal",
|
|
},
|
|
{
|
|
name: "external hostname model routing",
|
|
url: "https://api.example.com/",
|
|
expected: "gateway-external-model-routing",
|
|
},
|
|
{
|
|
name: "cluster-local model routing",
|
|
url: "http://my-service.default.svc.cluster.local/",
|
|
expected: "gateway-internal-model-routing",
|
|
},
|
|
{
|
|
name: "internal model routing",
|
|
url: "http://localhost/",
|
|
expected: "internal-model-routing",
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
g := NewGomegaWithT(t)
|
|
parsedURL, err := apis.ParseURL(tt.url)
|
|
g.Expect(err).ToNot(HaveOccurred())
|
|
|
|
llmSvc := &v1alpha2.LLMInferenceService{
|
|
Spec: v1alpha2.LLMInferenceServiceSpec{
|
|
Model: v1alpha2.LLMModelSpec{Name: ptr.To("name")},
|
|
},
|
|
}
|
|
sa := llmisvc.SourcedAddress(context.Background(), llmisvc.DiscoveredURL{URL: parsedURL}, llmSvc)
|
|
g.Expect(sa.Name).ToNot(BeNil())
|
|
g.Expect(*sa.Name).To(Equal(tt.expected))
|
|
})
|
|
}
|
|
})
|
|
|
|
t.Run("SourcedAddress Models", func(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
url string
|
|
namespace string
|
|
modelName string
|
|
loraAdapters []string
|
|
emptyLoRA bool
|
|
expectedModels []v1alpha2.ModelSourcedAddressStatus
|
|
}{
|
|
{
|
|
name: "path-based URL with base model only",
|
|
url: "http://203.0.113.1/ns/name",
|
|
namespace: "test-ns",
|
|
modelName: "facebook/opt-125m",
|
|
expectedModels: []v1alpha2.ModelSourcedAddressStatus{
|
|
{Name: "publishers/test-ns/models/facebook/opt-125m"},
|
|
{Name: "facebook/opt-125m"},
|
|
},
|
|
},
|
|
{
|
|
name: "model-routing URL with base model only",
|
|
url: "http://203.0.113.1/",
|
|
namespace: "test-ns",
|
|
modelName: "facebook/opt-125m",
|
|
expectedModels: []v1alpha2.ModelSourcedAddressStatus{
|
|
{Name: "publishers/test-ns/models/facebook/opt-125m"},
|
|
},
|
|
},
|
|
{
|
|
name: "path-based URL with LoRA adapters",
|
|
url: "http://203.0.113.1/ns/name",
|
|
namespace: "test-ns",
|
|
modelName: "facebook/opt-125m",
|
|
loraAdapters: []string{"adapter-a", "adapter-b"},
|
|
expectedModels: []v1alpha2.ModelSourcedAddressStatus{
|
|
{Name: "publishers/test-ns/models/facebook/opt-125m"},
|
|
{Name: "facebook/opt-125m"},
|
|
{Name: "publishers/test-ns/models/adapter-a"},
|
|
{Name: "adapter-a"},
|
|
{Name: "publishers/test-ns/models/adapter-b"},
|
|
{Name: "adapter-b"},
|
|
},
|
|
},
|
|
{
|
|
name: "model-routing URL with LoRA adapters",
|
|
url: "http://203.0.113.1/",
|
|
namespace: "test-ns",
|
|
modelName: "facebook/opt-125m",
|
|
loraAdapters: []string{"adapter-a", "adapter-b"},
|
|
expectedModels: []v1alpha2.ModelSourcedAddressStatus{
|
|
{Name: "publishers/test-ns/models/facebook/opt-125m"},
|
|
{Name: "publishers/test-ns/models/adapter-a"},
|
|
{Name: "publishers/test-ns/models/adapter-b"},
|
|
},
|
|
},
|
|
{
|
|
name: "model-routing URL with empty LoRA adapters list",
|
|
url: "http://203.0.113.1/",
|
|
namespace: "test-ns",
|
|
modelName: "facebook/opt-125m",
|
|
emptyLoRA: true,
|
|
expectedModels: []v1alpha2.ModelSourcedAddressStatus{
|
|
{Name: "publishers/test-ns/models/facebook/opt-125m"},
|
|
},
|
|
},
|
|
{
|
|
name: "cluster-local model-routing URL",
|
|
url: "http://my-service.default.svc.cluster.local/",
|
|
namespace: "my-ns",
|
|
modelName: "my-model",
|
|
expectedModels: []v1alpha2.ModelSourcedAddressStatus{
|
|
{Name: "publishers/my-ns/models/my-model"},
|
|
},
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
g := NewGomegaWithT(t)
|
|
parsedURL, err := apis.ParseURL(tt.url)
|
|
g.Expect(err).ToNot(HaveOccurred())
|
|
|
|
llmSvc := &v1alpha2.LLMInferenceService{
|
|
ObjectMeta: metav1.ObjectMeta{Namespace: tt.namespace},
|
|
Spec: v1alpha2.LLMInferenceServiceSpec{
|
|
Model: v1alpha2.LLMModelSpec{Name: ptr.To(tt.modelName)},
|
|
},
|
|
}
|
|
if tt.emptyLoRA {
|
|
llmSvc.Spec.Model.LoRA = &v1alpha2.LoRASpec{}
|
|
} else if len(tt.loraAdapters) > 0 {
|
|
llmSvc.Spec.Model.LoRA = &v1alpha2.LoRASpec{}
|
|
for _, name := range tt.loraAdapters {
|
|
llmSvc.Spec.Model.LoRA.Adapters = append(llmSvc.Spec.Model.LoRA.Adapters, v1alpha2.LLMModelSpec{
|
|
Name: ptr.To(name),
|
|
})
|
|
}
|
|
}
|
|
|
|
sa := llmisvc.SourcedAddress(context.Background(), llmisvc.DiscoveredURL{URL: parsedURL}, llmSvc)
|
|
g.Expect(sa.Models).To(Equal(tt.expectedModels))
|
|
})
|
|
}
|
|
})
|
|
}
|
|
|
|
func TestIsInferencePoolV1Alpha2Supported(t *testing.T) {
|
|
v1alpha2Group := gwapiv1.Group("inference.networking.x-k8s.io")
|
|
v1Group := gwapiv1.Group("inference.networking.k8s.io")
|
|
poolKind := gwapiv1.Kind("InferencePool")
|
|
|
|
tests := []struct {
|
|
name string
|
|
route *gwapiv1.HTTPRoute
|
|
expected metav1.ConditionStatus
|
|
}{
|
|
{
|
|
name: "nil route returns Unknown",
|
|
route: nil,
|
|
expected: metav1.ConditionUnknown,
|
|
},
|
|
{
|
|
name: "route not using v1alpha2 InferencePool returns Unknown",
|
|
route: &gwapiv1.HTTPRoute{
|
|
Spec: gwapiv1.HTTPRouteSpec{
|
|
Rules: []gwapiv1.HTTPRouteRule{
|
|
{
|
|
BackendRefs: []gwapiv1.HTTPBackendRef{
|
|
{BackendRef: gwapiv1.BackendRef{BackendObjectReference: gwapiv1.BackendObjectReference{
|
|
Group: &v1Group,
|
|
Kind: &poolKind,
|
|
Name: "test-pool",
|
|
}}},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
expected: metav1.ConditionUnknown,
|
|
},
|
|
{
|
|
name: "route using v1alpha2 with no status returns Unknown",
|
|
route: &gwapiv1.HTTPRoute{
|
|
Spec: gwapiv1.HTTPRouteSpec{
|
|
Rules: []gwapiv1.HTTPRouteRule{
|
|
{
|
|
BackendRefs: []gwapiv1.HTTPBackendRef{
|
|
{BackendRef: gwapiv1.BackendRef{BackendObjectReference: gwapiv1.BackendObjectReference{
|
|
Group: &v1alpha2Group,
|
|
Kind: &poolKind,
|
|
Name: "test-pool",
|
|
}}},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
expected: metav1.ConditionUnknown,
|
|
},
|
|
{
|
|
name: "route using v1alpha2 with ResolvedRefs=True returns True (supported)",
|
|
route: &gwapiv1.HTTPRoute{
|
|
Spec: gwapiv1.HTTPRouteSpec{
|
|
Rules: []gwapiv1.HTTPRouteRule{
|
|
{
|
|
BackendRefs: []gwapiv1.HTTPBackendRef{
|
|
{BackendRef: gwapiv1.BackendRef{BackendObjectReference: gwapiv1.BackendObjectReference{
|
|
Group: &v1alpha2Group,
|
|
Kind: &poolKind,
|
|
Name: "test-pool",
|
|
}}},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
Status: gwapiv1.HTTPRouteStatus{
|
|
RouteStatus: gwapiv1.RouteStatus{
|
|
Parents: []gwapiv1.RouteParentStatus{
|
|
{
|
|
Conditions: []metav1.Condition{
|
|
{
|
|
Type: string(gwapiv1.RouteConditionResolvedRefs),
|
|
Status: metav1.ConditionTrue,
|
|
Reason: "ResolvedRefs",
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
expected: metav1.ConditionTrue,
|
|
},
|
|
{
|
|
name: "route using v1alpha2 with ResolvedRefs=False/InvalidKind returns False (rejected)",
|
|
route: &gwapiv1.HTTPRoute{
|
|
Spec: gwapiv1.HTTPRouteSpec{
|
|
Rules: []gwapiv1.HTTPRouteRule{
|
|
{
|
|
BackendRefs: []gwapiv1.HTTPBackendRef{
|
|
{BackendRef: gwapiv1.BackendRef{BackendObjectReference: gwapiv1.BackendObjectReference{
|
|
Group: &v1alpha2Group,
|
|
Kind: &poolKind,
|
|
Name: "test-pool",
|
|
}}},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
Status: gwapiv1.HTTPRouteStatus{
|
|
RouteStatus: gwapiv1.RouteStatus{
|
|
Parents: []gwapiv1.RouteParentStatus{
|
|
{
|
|
Conditions: []metav1.Condition{
|
|
{
|
|
Type: string(gwapiv1.RouteConditionResolvedRefs),
|
|
Status: metav1.ConditionFalse,
|
|
Reason: "InvalidKind",
|
|
Message: "Group is invalid: inference.networking.x-k8s.io",
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
expected: metav1.ConditionFalse,
|
|
},
|
|
{
|
|
name: "route using v1alpha2 with ResolvedRefs=False but different reason returns True (not InvalidKind rejection)",
|
|
route: &gwapiv1.HTTPRoute{
|
|
Spec: gwapiv1.HTTPRouteSpec{
|
|
Rules: []gwapiv1.HTTPRouteRule{
|
|
{
|
|
BackendRefs: []gwapiv1.HTTPBackendRef{
|
|
{BackendRef: gwapiv1.BackendRef{BackendObjectReference: gwapiv1.BackendObjectReference{
|
|
Group: &v1alpha2Group,
|
|
Kind: &poolKind,
|
|
Name: "test-pool",
|
|
}}},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
Status: gwapiv1.HTTPRouteStatus{
|
|
RouteStatus: gwapiv1.RouteStatus{
|
|
Parents: []gwapiv1.RouteParentStatus{
|
|
{
|
|
Conditions: []metav1.Condition{
|
|
{
|
|
Type: string(gwapiv1.RouteConditionResolvedRefs),
|
|
Status: metav1.ConditionFalse,
|
|
Reason: "BackendNotFound",
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
expected: metav1.ConditionTrue,
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
g := NewGomegaWithT(t)
|
|
result := llmisvc.IsInferencePoolV1Alpha2Supported(tt.route)
|
|
g.Expect(result).To(Equal(tt.expected))
|
|
})
|
|
}
|
|
}
|