mirror of
https://github.com/hanzo-ml/kserve.git
synced 2026-10-30 20:53:49 +00:00
969 lines
35 KiB
Go
969 lines
35 KiB
Go
/*
|
|
Copyright 2025 The KServe Authors.
|
|
|
|
Licensed under the Apache License, Version 2.0 (the "License");
|
|
you may not use this file except in compliance with the License.
|
|
You may obtain a copy of the License at
|
|
|
|
http://www.apache.org/licenses/LICENSE-2.0
|
|
|
|
Unless required by applicable law or agreed to in writing, software
|
|
distributed under the License is distributed on an "AS IS" BASIS,
|
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
See the License for the specific language governing permissions and
|
|
limitations under the License.
|
|
*/
|
|
|
|
package llmisvc
|
|
|
|
import (
|
|
"cmp"
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"net"
|
|
"slices"
|
|
"strconv"
|
|
"strings"
|
|
|
|
corev1 "k8s.io/api/core/v1"
|
|
apierrors "k8s.io/apimachinery/pkg/api/errors"
|
|
"k8s.io/apimachinery/pkg/api/meta"
|
|
"k8s.io/apimachinery/pkg/types"
|
|
"k8s.io/utils/ptr"
|
|
"knative.dev/pkg/apis"
|
|
"knative.dev/pkg/network"
|
|
|
|
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
|
"sigs.k8s.io/controller-runtime/pkg/client"
|
|
"sigs.k8s.io/controller-runtime/pkg/log"
|
|
igwapi "sigs.k8s.io/gateway-api-inference-extension/api/v1"
|
|
gwapiv1 "sigs.k8s.io/gateway-api/apis/v1"
|
|
|
|
"github.com/kserve/kserve/pkg/constants"
|
|
)
|
|
|
|
var wildcardHostname = constants.GetEnvOrDefault("GATEWAY_API_WILDCARD_HOSTNAME", "inference")
|
|
|
|
// DiscoveredURL pairs a URL with the networking resource that produced it.
|
|
type DiscoveredURL struct {
|
|
URL *apis.URL
|
|
Origin *gwapiv1.ObjectReference
|
|
}
|
|
|
|
// ResolvedGateway contains a Gateway and its associated GatewayClass.
|
|
type ResolvedGateway struct {
|
|
Gateway *gwapiv1.Gateway
|
|
GatewayClass *gwapiv1.GatewayClass
|
|
ParentRef gwapiv1.ParentReference
|
|
}
|
|
|
|
// DiscoverGateways finds and resolves all gateways referenced by an HTTPRoute
|
|
// It fetches the Gateway and GatewayClass resources to provide complete routing context
|
|
func DiscoverGateways(ctx context.Context, c client.Client, route *gwapiv1.HTTPRoute) ([]ResolvedGateway, error) {
|
|
gateways := make([]ResolvedGateway, 0)
|
|
for _, parentRef := range route.Spec.ParentRefs {
|
|
// Resolve namespace (defaults to route's namespace if not specified)
|
|
ns := ptr.Deref((&parentRef).Namespace, gwapiv1.Namespace(route.Namespace))
|
|
gwNS, gwName := string(ns), string((&parentRef).Name)
|
|
|
|
gateway := &gwapiv1.Gateway{}
|
|
if err := c.Get(ctx, types.NamespacedName{Namespace: gwNS, Name: gwName}, gateway); err != nil {
|
|
return nil, fmt.Errorf("failed to get Gateway %s/%s for route %s/%s: %w", gwNS, gwName, route.Namespace, route.Name, err)
|
|
}
|
|
|
|
gatewayClass := &gwapiv1.GatewayClass{}
|
|
if err := c.Get(ctx, types.NamespacedName{Name: string(gateway.Spec.GatewayClassName)}, gatewayClass); err != nil {
|
|
return nil, fmt.Errorf("failed to get GatewayClass %q for gateway %s/%s: %w", string(gateway.Spec.GatewayClassName), gwNS, gwName, err)
|
|
}
|
|
gateways = append(gateways, ResolvedGateway{
|
|
Gateway: gateway,
|
|
GatewayClass: gatewayClass,
|
|
ParentRef: parentRef,
|
|
})
|
|
}
|
|
return gateways, nil
|
|
}
|
|
|
|
// DiscoverGatewayService attempts to find the Service backing a Gateway.
|
|
// Returns nil if no backing service is found (not an error).
|
|
func DiscoverGatewayService(ctx context.Context, c client.Client, gateway *gwapiv1.Gateway) (*corev1.Service, error) {
|
|
logger := log.FromContext(ctx)
|
|
|
|
// Look for Service with known gateway label first
|
|
svcList := &corev1.ServiceList{}
|
|
if err := c.List(ctx, svcList,
|
|
client.InNamespace(gateway.Namespace),
|
|
client.MatchingLabels{
|
|
"gateway.networking.k8s.io/gateway-name": gateway.Name,
|
|
},
|
|
); err != nil {
|
|
return nil, fmt.Errorf("failed to list services for gateway %s/%s: %w", gateway.Namespace, gateway.Name, err)
|
|
}
|
|
if len(svcList.Items) > 0 {
|
|
if len(svcList.Items) > 1 {
|
|
logger.Info("Multiple services found with gateway label, using first alphabetically",
|
|
"gateway", gateway.Name, "count", len(svcList.Items))
|
|
slices.SortFunc(svcList.Items, func(a, b corev1.Service) int {
|
|
return cmp.Compare(a.Name, b.Name)
|
|
})
|
|
}
|
|
svc := &svcList.Items[0]
|
|
logger.V(1).Info("Discovered gateway service via label", "gateway", gateway.Name, "service", svc.Name)
|
|
return svc, nil
|
|
}
|
|
|
|
// Fallback: Look for Service with the same name as Gateway
|
|
svc := &corev1.Service{}
|
|
err := c.Get(ctx, types.NamespacedName{
|
|
Namespace: gateway.Namespace,
|
|
Name: gateway.Name,
|
|
}, svc)
|
|
if err == nil {
|
|
logger.V(1).Info("Discovered gateway service via name match", "gateway", gateway.Name, "service", svc.Name)
|
|
return svc, nil
|
|
}
|
|
if !apierrors.IsNotFound(err) {
|
|
return nil, fmt.Errorf("failed to get service %s/%s: %w", gateway.Namespace, gateway.Name, err)
|
|
}
|
|
|
|
logger.V(1).Info("No backing service found for gateway", "gateway", fmt.Sprintf("%s/%s", gateway.Namespace, gateway.Name))
|
|
return nil, nil
|
|
}
|
|
|
|
// DiscoverURLs extracts accessible URLs from an HTTPRoute by examining its gateways
|
|
// It constructs URLs based on gateway listeners and addresses, and also discovers
|
|
// internal URLs from backing services
|
|
func DiscoverURLs(ctx context.Context, c client.Client, gateways []ResolvedGateway, route *gwapiv1.HTTPRoute, cfg Config) ([]DiscoveredURL, error) {
|
|
logger := log.FromContext(ctx)
|
|
|
|
var urls []DiscoveredURL
|
|
|
|
for _, g := range gateways {
|
|
listeners, err := selectListeners(g.Gateway, g.ParentRef.SectionName, cfg.UrlScheme)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to select listeners for gateway %s/%s: %w", g.Gateway.Namespace, g.Gateway.Name, err)
|
|
}
|
|
|
|
origin := &gwapiv1.ObjectReference{
|
|
Group: gwapiv1.GroupName,
|
|
Kind: "Gateway",
|
|
Name: gwapiv1.ObjectName(g.Gateway.Name),
|
|
Namespace: ptr.To(gwapiv1.Namespace(g.Gateway.Namespace)),
|
|
}
|
|
|
|
paths := extractRoutePaths(route, cfg.ModelBasedRoutingHeaderName)
|
|
addresses := g.Gateway.Status.Addresses
|
|
|
|
// Discover backing Service for internal URL generation (once per gateway).
|
|
backingSvc, err := DiscoverGatewayService(ctx, c, g.Gateway)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to discover gateway backing service for %s/%s: %w", g.Gateway.Namespace, g.Gateway.Name, err)
|
|
}
|
|
|
|
seen := make(map[string]struct{})
|
|
|
|
for _, path := range paths {
|
|
// Discover external URLs from Gateway status addresses (if available)
|
|
if len(addresses) > 0 {
|
|
for _, listener := range listeners {
|
|
scheme, err := resolveScheme(listener)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to resolve scheme for gateway %s/%s listener %s: %w",
|
|
g.Gateway.Namespace, g.Gateway.Name, listener.Name, err)
|
|
}
|
|
|
|
hostnames := extractHostnamesForListener(route, listener, addresses)
|
|
gatewayURLs, err := combineIntoURLs(hostnames, scheme, listener.Port, path)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to combine URLs for Gateway %s/%s: %w", g.Gateway.Namespace, g.Gateway.Name, err)
|
|
}
|
|
for _, u := range gatewayURLs {
|
|
seen[u.String()] = struct{}{}
|
|
urls = append(urls, DiscoveredURL{URL: u, Origin: origin})
|
|
}
|
|
}
|
|
}
|
|
|
|
// Generate internal URLs from the backing Service for each listener whose
|
|
// port is confirmed on the Service. Unlike external URLs (derived from
|
|
// authoritative gateway.status.addresses), internal URLs are discovered
|
|
// heuristically from the backing Service, so we validate listener ports
|
|
// against the Service's declared ports before emitting. When the Service
|
|
// declares no ports, all listeners are assumed to match (backward compat).
|
|
if backingSvc != nil {
|
|
internalHost := network.GetServiceHostname(backingSvc.Name, backingSvc.Namespace)
|
|
for _, listener := range listeners {
|
|
if !listenerMatchesServicePorts(listener, backingSvc.Spec.Ports) {
|
|
logger.V(1).Info("Skipping listener for internal URL: port not found on backing service",
|
|
"gateway", fmt.Sprintf("%s/%s", g.Gateway.Namespace, g.Gateway.Name),
|
|
"listener", listener.Name,
|
|
"listenerPort", listener.Port,
|
|
"service", fmt.Sprintf("%s/%s", backingSvc.Namespace, backingSvc.Name),
|
|
)
|
|
continue
|
|
}
|
|
|
|
candidateURLs, err := combineIntoURLs([]string{internalHost}, schemeForProtocol(listener.Protocol), listener.Port, path)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to build internal URL for Gateway %s/%s listener %s: %w",
|
|
g.Gateway.Namespace, g.Gateway.Name, listener.Name, err)
|
|
}
|
|
for _, u := range candidateURLs {
|
|
if _, exists := seen[u.String()]; exists {
|
|
continue
|
|
}
|
|
seen[u.String()] = struct{}{}
|
|
urls = append(urls, DiscoveredURL{URL: u, Origin: origin})
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Error only if no URLs discovered at all (neither external nor internal)
|
|
if len(urls) == 0 && len(gateways) > 0 {
|
|
g := gateways[0]
|
|
return nil, &NoURLsDiscoveredError{
|
|
GatewayNamespace: g.Gateway.Namespace,
|
|
GatewayName: g.Gateway.Name,
|
|
}
|
|
}
|
|
|
|
return urls, nil
|
|
}
|
|
|
|
// extractHostnamesForListener determines the hostnames to use for URL generation
|
|
func extractHostnamesForListener(route *gwapiv1.HTTPRoute, listener *gwapiv1.Listener, addresses []gwapiv1.GatewayStatusAddress) []string {
|
|
hostnames := extractRouteHostnames(route)
|
|
// If Hostname is set in the spec, use the Hostname specified.
|
|
// Using the LoadBalancer addresses in `Gateway.Status.Addresses` will return 404 in those cases.
|
|
if len(hostnames) == 0 && listener.Hostname != nil && *listener.Hostname != "" {
|
|
if host, isWildcard := strings.CutPrefix(string(*listener.Hostname), "*."); isWildcard {
|
|
// Hostnames that are prefixed with a wildcard label (`*.`) are interpreted
|
|
// as a suffix match. That means that a match for `*.example.com` would match
|
|
// both `test.example.com`, and `foo.test.example.com`, but not `example.com`.
|
|
hostnames = append(hostnames, fmt.Sprintf("%s.%s", wildcardHostname, host))
|
|
} else {
|
|
hostnames = []string{host}
|
|
}
|
|
}
|
|
if len(hostnames) == 0 {
|
|
hostnames = extractAddressValues(addresses)
|
|
}
|
|
return hostnames
|
|
}
|
|
|
|
// extractRoutePaths returns the distinct base URL paths from an HTTPRoute's rules.
|
|
// It considers two access methods independently:
|
|
// - Path-based matches (no headers): the traditional /{namespace}/{name} routing
|
|
// - Model-based routing matches (with the configured modelRoutingHeader): header-based routing
|
|
//
|
|
// For each access method, the shallowest path (fewest "/" segments) from
|
|
// Service-backed rules is preferred; InferencePool-backed paths are used as
|
|
// fallback. The results are merged and deduplicated.
|
|
//
|
|
// Matches with headers that do NOT match modelRoutingHeader are skipped so
|
|
// that user-provided header rules are not misidentified as model-based routing.
|
|
func extractRoutePaths(route *gwapiv1.HTTPRoute, modelRoutingHeader string) []string {
|
|
type pathBucket struct {
|
|
servicePaths []string
|
|
otherPaths []string
|
|
}
|
|
// We keep two separate buckets because the two access methods produce
|
|
// different "base URL" paths that must both appear in the status:
|
|
// - pathBased → /{namespace}/{name} (the original routing scheme)
|
|
// - modelRouting → / (reachable only with the model-routing header)
|
|
// Mixing them into one list would let the model-routing "/" shadow the
|
|
// deeper path-based URL, which breaks backward-compatible discovery.
|
|
var pathBased, modelRouting pathBucket
|
|
|
|
for _, rule := range route.Spec.Rules {
|
|
svc := hasServiceBackend(rule)
|
|
for _, match := range rule.Matches {
|
|
p := "/"
|
|
if match.Path != nil {
|
|
p = ptr.Deref(match.Path.Value, "/")
|
|
}
|
|
|
|
if len(match.Headers) == 0 {
|
|
// Traditional path-only match — always collected.
|
|
if svc {
|
|
pathBased.servicePaths = append(pathBased.servicePaths, p)
|
|
} else {
|
|
pathBased.otherPaths = append(pathBased.otherPaths, p)
|
|
}
|
|
} else if isModelBasedRoutingMatch(match, modelRoutingHeader) {
|
|
// Model-based routing match — collected only when the header
|
|
// is our configured model-routing header so we don't
|
|
// accidentally promote user-provided header rules to URLs.
|
|
if svc {
|
|
modelRouting.servicePaths = append(modelRouting.servicePaths, p)
|
|
} else {
|
|
modelRouting.otherPaths = append(modelRouting.otherPaths, p)
|
|
}
|
|
}
|
|
// Matches with other headers are intentionally ignored: they may
|
|
// be user-provided rules whose paths should not be advertised.
|
|
}
|
|
}
|
|
|
|
// Pick the shallowest path from a bucket. Service-backed paths are
|
|
// preferred because they represent the canonical entry-point; InferencePool
|
|
// paths (which are sub-paths like /ns/name/v1/completions) are only used
|
|
// as a fallback when no Service rule exists.
|
|
shallowest := func(b pathBucket) string {
|
|
paths := b.servicePaths
|
|
if len(paths) == 0 {
|
|
paths = b.otherPaths
|
|
}
|
|
if len(paths) == 0 {
|
|
return ""
|
|
}
|
|
return slices.MinFunc(paths, func(a, b string) int {
|
|
if d := strings.Count(a, "/") - strings.Count(b, "/"); d != 0 {
|
|
return d
|
|
}
|
|
return len(a) - len(b)
|
|
})
|
|
}
|
|
|
|
var result []string
|
|
if p := shallowest(pathBased); p != "" {
|
|
result = append(result, p)
|
|
}
|
|
if p := shallowest(modelRouting); p != "" {
|
|
result = append(result, p)
|
|
}
|
|
|
|
if len(result) == 0 {
|
|
return []string{"/"}
|
|
}
|
|
|
|
slices.SortFunc(result, func(a, b string) int {
|
|
if d := strings.Count(a, "/") - strings.Count(b, "/"); d != 0 {
|
|
return d
|
|
}
|
|
return len(a) - len(b)
|
|
})
|
|
|
|
return slices.Compact(result)
|
|
}
|
|
|
|
// isModelBasedRoutingMatch returns true if the match contains a header condition
|
|
// for the configured model-based routing header.
|
|
//
|
|
// This distinguishes controller-managed model-routing rules from arbitrary
|
|
// user-provided header rules. Only matches whose header name equals the
|
|
// configured modelRoutingHeader (e.g. "X-Gateway-Model-Name") are treated as
|
|
// model-routing endpoints. When modelRoutingHeader is empty (feature not
|
|
// configured), no match qualifies — so header-bearing rules are simply ignored
|
|
// during path extraction, preserving the pre-model-routing behavior.
|
|
func isModelBasedRoutingMatch(match gwapiv1.HTTPRouteMatch, modelRoutingHeader string) bool {
|
|
if modelRoutingHeader == "" {
|
|
return false
|
|
}
|
|
for _, h := range match.Headers {
|
|
if string(h.Name) == modelRoutingHeader {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// hasServiceBackend returns true if the rule has at least one backendRef with Kind "Service"
|
|
// or with no Kind set (defaults to Service per Gateway API spec).
|
|
func hasServiceBackend(rule gwapiv1.HTTPRouteRule) bool {
|
|
for _, ref := range rule.BackendRefs {
|
|
kind := ptr.Deref(ref.Kind, gwapiv1.Kind("Service"))
|
|
if kind == "Service" {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// schemeForProtocol returns the URL scheme for a Gateway API protocol.
|
|
// Returns empty string for protocols that don't support HTTP routing.
|
|
func schemeForProtocol(protocol gwapiv1.ProtocolType) string {
|
|
switch protocol {
|
|
case gwapiv1.HTTPProtocolType, gwapiv1.HTTPSProtocolType:
|
|
return strings.ToLower(string(protocol))
|
|
case gwapiv1.TLSProtocolType:
|
|
return "https"
|
|
default:
|
|
return ""
|
|
}
|
|
}
|
|
|
|
// selectListeners returns the applicable listeners for URL generation.
|
|
// - If sectionName is provided, returns only that specific listener
|
|
// - Otherwise, returns ALL HTTP-capable listeners sorted by: preferredScheme first, then HTTPS, then HTTP
|
|
func selectListeners(gateway *gwapiv1.Gateway, sectionName *gwapiv1.SectionName, preferredScheme string) ([]*gwapiv1.Listener, error) {
|
|
// If sectionName provided, find exact match (single listener)
|
|
if sectionName != nil {
|
|
for i := range gateway.Spec.Listeners {
|
|
if gateway.Spec.Listeners[i].Name == *sectionName {
|
|
return []*gwapiv1.Listener{&gateway.Spec.Listeners[i]}, nil
|
|
}
|
|
}
|
|
return nil, fmt.Errorf("listener %q not found in gateway %s/%s", *sectionName, gateway.Namespace, gateway.Name)
|
|
}
|
|
|
|
// Collect all HTTP-capable listeners
|
|
var listeners []*gwapiv1.Listener
|
|
for i := range gateway.Spec.Listeners {
|
|
if scheme := schemeForProtocol(gateway.Spec.Listeners[i].Protocol); scheme != "" {
|
|
listeners = append(listeners, &gateway.Spec.Listeners[i])
|
|
}
|
|
}
|
|
if len(listeners) == 0 {
|
|
return nil, fmt.Errorf("no HTTP-capable listener found in gateway %s/%s", gateway.Namespace, gateway.Name)
|
|
}
|
|
|
|
// Sort: preferredScheme first, then HTTPS before HTTP
|
|
precedence := func(l *gwapiv1.Listener) int {
|
|
scheme := schemeForProtocol(l.Protocol)
|
|
if scheme == preferredScheme {
|
|
return 0
|
|
}
|
|
if scheme == "https" {
|
|
return 1
|
|
}
|
|
return 2
|
|
}
|
|
slices.SortFunc(listeners, func(a, b *gwapiv1.Listener) int {
|
|
return precedence(a) - precedence(b)
|
|
})
|
|
|
|
return listeners, nil
|
|
}
|
|
|
|
// listenerMatchesServicePorts checks port number only, not protocol. Gateway
|
|
// listeners use L7 protocols (HTTP, HTTPS) while Service ports are L4 (TCP) -
|
|
// they operate at different layers and are not directly comparable.
|
|
func listenerMatchesServicePorts(listener *gwapiv1.Listener, svcPorts []corev1.ServicePort) bool {
|
|
if len(svcPorts) == 0 {
|
|
return true
|
|
}
|
|
for _, sp := range svcPorts {
|
|
if listener.Port == sp.Port {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// resolveScheme returns the URL scheme derived from the listener's protocol.
|
|
func resolveScheme(listener *gwapiv1.Listener) (string, error) {
|
|
scheme := schemeForProtocol(listener.Protocol)
|
|
if scheme == "" {
|
|
return "", fmt.Errorf("listener %q uses unsupported protocol %s for HTTP routing", listener.Name, listener.Protocol)
|
|
}
|
|
return scheme, nil
|
|
}
|
|
|
|
// extractRouteHostnames extracts valid hostnames from an HTTPRoute specification
|
|
// It filters out empty strings and wildcard hostnames which cannot be used in URLs
|
|
func extractRouteHostnames(route *gwapiv1.HTTPRoute) []string {
|
|
var hostnames []string
|
|
for _, h := range route.Spec.Hostnames {
|
|
host := string(h)
|
|
// Skip empty hostnames and wildcards as they cannot form valid URLs
|
|
if host != "" && host != "*" {
|
|
hostnames = append(hostnames, host)
|
|
}
|
|
}
|
|
return hostnames
|
|
}
|
|
|
|
// extractAddressValues extracts the address values from Gateway status addresses
|
|
// These addresses are typically IP addresses or hostnames where the Gateway is accessible
|
|
// Used as fallback when no specific hostnames are defined in the HTTPRoute
|
|
func extractAddressValues(addresses []gwapiv1.GatewayStatusAddress) []string {
|
|
var values []string
|
|
for _, addr := range addresses {
|
|
// Only include non-empty address values
|
|
if addr.Value != "" {
|
|
values = append(values, addr.Value)
|
|
}
|
|
}
|
|
return values
|
|
}
|
|
|
|
// combineIntoURLs constructs complete URLs from hostnames, scheme, port, and path components
|
|
// It handles standard ports (80 for HTTP, 443 for HTTPS) by omitting them from the URL
|
|
// Returns a sorted list of URLs for consistent ordering
|
|
func combineIntoURLs(hostnames []string, scheme string, port gwapiv1.PortNumber, path string) ([]*apis.URL, error) {
|
|
urls := make([]*apis.URL, 0, len(hostnames))
|
|
|
|
// Sort hostnames for consistent URL ordering
|
|
sortedHostnames := make([]string, len(hostnames))
|
|
copy(sortedHostnames, hostnames)
|
|
slices.Sort(sortedHostnames)
|
|
|
|
for _, hostname := range sortedHostnames {
|
|
var urlStr string
|
|
// Include port in URL only if it's not the standard port for the scheme
|
|
if (scheme == "http" && port != 80) || (scheme == "https" && port != 443) {
|
|
urlStr = fmt.Sprintf("%s://%s%s", scheme, joinHostPort(hostname, &port), path)
|
|
} else {
|
|
// Use standard port - omit from URL for cleaner appearance
|
|
urlStr = fmt.Sprintf("%s://%s%s", scheme, joinHostPort(hostname, nil), path)
|
|
}
|
|
|
|
url, err := apis.ParseURL(urlStr)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to parse URL %s: %w", urlStr, err)
|
|
}
|
|
|
|
urls = append(urls, url)
|
|
}
|
|
|
|
return urls, nil
|
|
}
|
|
|
|
// joinHostPort safely combines a hostname and port into a host:port string
|
|
// Uses net.JoinHostPort to handle IPv6 addresses correctly with brackets
|
|
// Returns just the host if port is nil or zero (with IPv6 bracket wrapping)
|
|
func joinHostPort(host string, port *gwapiv1.PortNumber) string {
|
|
if port != nil && *port != 0 {
|
|
return net.JoinHostPort(host, strconv.Itoa(int(*port)))
|
|
}
|
|
// IPv6 addresses need brackets in URLs even without an explicit port
|
|
if strings.Contains(host, ":") {
|
|
return "[" + host + "]"
|
|
}
|
|
return host
|
|
}
|
|
|
|
// NoURLsDiscoveredError indicates that no URLs could be discovered for a Gateway
|
|
// (neither external addresses nor internal backing service found)
|
|
type NoURLsDiscoveredError struct {
|
|
GatewayNamespace string
|
|
GatewayName string
|
|
}
|
|
|
|
func (e *NoURLsDiscoveredError) Error() string {
|
|
return fmt.Sprintf("no URLs discovered for Gateway %s/%s (no external addresses and no backing service found)", e.GatewayNamespace, e.GatewayName)
|
|
}
|
|
|
|
// IgnoreNoURLsDiscovered converts NoURLsDiscoveredError to nil
|
|
// This is useful when URLs may not be immediately available during provisioning
|
|
func IgnoreNoURLsDiscovered(err error) error {
|
|
if IsNoURLsDiscovered(err) {
|
|
return nil
|
|
}
|
|
return err
|
|
}
|
|
|
|
// IsNoURLsDiscovered checks if an error is of type NoURLsDiscoveredError
|
|
func IsNoURLsDiscovered(err error) bool {
|
|
var noURLsErr *NoURLsDiscoveredError
|
|
return errors.As(err, &noURLsErr)
|
|
}
|
|
|
|
// EvaluateGatewayReadiness checks the readiness status of Gateways and returns those that are not ready
|
|
// This is used to determine if routing can proceed or if we need to wait for Gateway provisioning
|
|
func EvaluateGatewayReadiness(ctx context.Context, gateways []*gwapiv1.Gateway) []*gwapiv1.Gateway {
|
|
logger := log.FromContext(ctx)
|
|
notReadyGateways := make([]*gwapiv1.Gateway, 0)
|
|
|
|
for _, gateway := range gateways {
|
|
ready := IsGatewayReady(gateway)
|
|
logger.Info("Gateway readiness evaluated", "gateway", fmt.Sprintf("%s/%s", gateway.Namespace, gateway.Name), "ready", ready)
|
|
|
|
// Collect gateways that are not ready for status reporting
|
|
if !ready {
|
|
notReadyGateways = append(notReadyGateways, gateway)
|
|
}
|
|
}
|
|
|
|
return notReadyGateways
|
|
}
|
|
|
|
// IsGatewayReady determines if a Gateway is ready based on its status conditions
|
|
// A Gateway is considered ready when it has a "Programmed" condition with status True
|
|
// The "Programmed" condition indicates that the Gateway has been configured and is operational
|
|
func IsGatewayReady(gateway *gwapiv1.Gateway) bool {
|
|
// Check for the standard Gateway API "Programmed" condition
|
|
for _, condition := range gateway.Status.Conditions {
|
|
if condition.Type == string(gwapiv1.GatewayConditionProgrammed) {
|
|
return condition.Status == metav1.ConditionTrue
|
|
}
|
|
}
|
|
|
|
// If no Programmed condition is found, Gateway is considered not ready
|
|
return false
|
|
}
|
|
|
|
// EvaluateHTTPRouteReadiness checks the readiness status of HTTPRoutes and returns those that are not ready
|
|
// This helps determine if traffic routing is functional or if there are configuration issues
|
|
func EvaluateHTTPRouteReadiness(ctx context.Context, routes []*gwapiv1.HTTPRoute) []*gwapiv1.HTTPRoute {
|
|
logger := log.FromContext(ctx)
|
|
notReadyRoutes := make([]*gwapiv1.HTTPRoute, 0)
|
|
|
|
for _, route := range routes {
|
|
ready := IsHTTPRouteReady(route)
|
|
logger.Info("HTTPRoute readiness evaluated", "route", fmt.Sprintf("%s/%s", route.Namespace, route.Name), "ready", ready)
|
|
|
|
// Collect routes that are not ready for status reporting
|
|
if !ready {
|
|
notReadyRoutes = append(notReadyRoutes, route)
|
|
}
|
|
}
|
|
|
|
return notReadyRoutes
|
|
}
|
|
|
|
// IsHTTPRouteReady determines if an HTTPRoute is ready based on its status conditions
|
|
// An HTTPRoute is ready only when ALL parent Gateways have accepted it
|
|
// This ensures that traffic can flow through all configured paths
|
|
func IsHTTPRouteReady(route *gwapiv1.HTTPRoute) bool {
|
|
if route == nil || len(route.Spec.ParentRefs) == 0 {
|
|
return false
|
|
}
|
|
|
|
// Check that every spec ParentRef has at least one corresponding status entry.
|
|
// Multiple controllers may write separate status entries for the same ParentRef,
|
|
// so we only require that at least one entry exists per spec ref.
|
|
for _, specRef := range route.Spec.ParentRefs {
|
|
if !hasMatchingParentStatus(specRef, route.Status.Parents, gwapiv1.Namespace(route.Namespace)) {
|
|
return false
|
|
}
|
|
}
|
|
|
|
return areGatewayConditionsReady(route)
|
|
}
|
|
|
|
// hasMatchingParentStatus checks whether at least one RouteParentStatus entry
|
|
// corresponds to the given spec ParentReference and was written by a gateway
|
|
// controller (i.e., has the Accepted condition set). Policy or extension
|
|
// controllers may also write status entries for the same ParentRef but without
|
|
// setting the Accepted condition, so those entries alone are not sufficient.
|
|
func hasMatchingParentStatus(specRef gwapiv1.ParentReference, parents []gwapiv1.RouteParentStatus, defaultNS gwapiv1.Namespace) bool {
|
|
for i := range parents {
|
|
if parentRefMatches(specRef, parents[i].ParentRef, defaultNS) &&
|
|
meta.FindStatusCondition(parents[i].Conditions, string(gwapiv1.RouteConditionAccepted)) != nil {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// parentRefMatches returns true when two ParentReferences identify the same parent,
|
|
// applying Gateway API defaulting rules for optional fields.
|
|
// defaultNS is used when Namespace is omitted (nil) from a ParentReference, which
|
|
// per Gateway API spec defaults to the route's own namespace.
|
|
func parentRefMatches(a, b gwapiv1.ParentReference, defaultNS gwapiv1.Namespace) bool {
|
|
return a.Name == b.Name &&
|
|
ptr.Deref(a.Group, gwapiv1.GroupName) == ptr.Deref(b.Group, gwapiv1.GroupName) &&
|
|
ptr.Deref(a.Kind, "Gateway") == ptr.Deref(b.Kind, "Gateway") &&
|
|
ptr.Deref(a.Namespace, defaultNS) == ptr.Deref(b.Namespace, defaultNS) &&
|
|
ptr.Deref(a.SectionName, "") == ptr.Deref(b.SectionName, "") &&
|
|
ptr.Deref(a.Port, 0) == ptr.Deref(b.Port, 0)
|
|
}
|
|
|
|
// areGatewayConditionsReady reports whether all gateway controller entries in the
|
|
// HTTPRoute's status have Accepted and ResolvedRefs set to True and up-to-date.
|
|
// Status entries without the Accepted condition (e.g. from policy controllers) are skipped.
|
|
// A condition is stale when its ObservedGeneration is less than the route's Generation.
|
|
func areGatewayConditionsReady(route *gwapiv1.HTTPRoute) bool {
|
|
if route == nil || len(route.Status.Parents) == 0 {
|
|
return false
|
|
}
|
|
for _, parent := range route.Status.Parents {
|
|
acceptedCond := meta.FindStatusCondition(parent.Conditions, string(gwapiv1.RouteConditionAccepted))
|
|
if acceptedCond == nil {
|
|
continue
|
|
}
|
|
stale := acceptedCond.ObservedGeneration > 0 && acceptedCond.ObservedGeneration < route.Generation
|
|
if acceptedCond.Status != metav1.ConditionTrue || stale {
|
|
return false
|
|
}
|
|
resolvedRefCond := meta.FindStatusCondition(parent.Conditions, string(gwapiv1.RouteConditionResolvedRefs))
|
|
if resolvedRefCond == nil {
|
|
return false
|
|
}
|
|
stale = resolvedRefCond.ObservedGeneration > 0 && resolvedRefCond.ObservedGeneration < route.Generation
|
|
if resolvedRefCond.Status != metav1.ConditionTrue || stale {
|
|
return false
|
|
}
|
|
}
|
|
return true
|
|
}
|
|
|
|
// findNonReadyGatewayCondition returns the first non-ready or stale condition from a
|
|
// gateway controller entry in the HTTPRoute's status, or nil if none found.
|
|
// Status entries without the Accepted condition (e.g. from policy controllers) are skipped.
|
|
func findNonReadyGatewayCondition(route *gwapiv1.HTTPRoute) *metav1.Condition {
|
|
if route == nil {
|
|
return nil
|
|
}
|
|
for _, parent := range route.Status.Parents {
|
|
acceptedCond := meta.FindStatusCondition(parent.Conditions, string(gwapiv1.RouteConditionAccepted))
|
|
if acceptedCond == nil {
|
|
continue
|
|
}
|
|
stale := acceptedCond.ObservedGeneration > 0 && acceptedCond.ObservedGeneration < route.Generation
|
|
if acceptedCond.Status != metav1.ConditionTrue || stale {
|
|
return acceptedCond
|
|
}
|
|
resolvedRefCond := meta.FindStatusCondition(parent.Conditions, string(gwapiv1.RouteConditionResolvedRefs))
|
|
if resolvedRefCond == nil {
|
|
// ResolvedRefs not yet reported — areGatewayConditionsReady treats this as not-ready,
|
|
// so surface a synthesized condition for diagnostics.
|
|
return &metav1.Condition{
|
|
Type: string(gwapiv1.RouteConditionResolvedRefs),
|
|
Status: metav1.ConditionUnknown,
|
|
Reason: "ConditionMissing",
|
|
Message: "ResolvedRefs condition not yet reported by gateway controller",
|
|
}
|
|
}
|
|
stale = resolvedRefCond.ObservedGeneration > 0 && resolvedRefCond.ObservedGeneration < route.Generation
|
|
if resolvedRefCond.Status != metav1.ConditionTrue || stale {
|
|
return resolvedRefCond
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// IsInferencePoolReady checks if an InferencePool has been accepted by all parents.
|
|
// An InferencePool is only considered ready when at least one Gateway has claimed it
|
|
// and all parent conditions report Accepted=True. If no parents have been set
|
|
// (no Gateway controller has reconciled the pool yet), it is not ready.
|
|
func IsInferencePoolReady(pool *igwapi.InferencePool) bool {
|
|
if pool == nil {
|
|
return false
|
|
}
|
|
|
|
// No Gateway has claimed this pool yet - not ready for traffic
|
|
if len(pool.Status.Parents) == 0 {
|
|
return false
|
|
}
|
|
|
|
// Check for any non-ready conditions across all parents
|
|
if cond, missing := nonReadyInferencePoolTopLevelCondition(pool); cond != nil || missing {
|
|
return false
|
|
}
|
|
|
|
return true
|
|
}
|
|
|
|
// nonReadyInferencePoolTopLevelCondition checks for any non-ready conditions in an InferencePool
|
|
// Similar to HTTPRoute validation but uses InferencePool-specific condition types
|
|
// Returns the first problematic condition or indicates missing conditions
|
|
func nonReadyInferencePoolTopLevelCondition(pool *igwapi.InferencePool) (*metav1.Condition, bool) {
|
|
if pool == nil {
|
|
return nil, true
|
|
}
|
|
|
|
for _, parent := range pool.Status.Parents {
|
|
// Look for the "Accepted" condition specific to InferencePools
|
|
cond := meta.FindStatusCondition(parent.Conditions, string(igwapi.InferencePoolConditionAccepted))
|
|
if cond == nil {
|
|
// Missing condition indicates the pool is not ready
|
|
return nil, true
|
|
}
|
|
// Check if condition is stale (based on older generation)
|
|
staleCondition := cond.ObservedGeneration > 0 && cond.ObservedGeneration < pool.Generation
|
|
if cond.Status != metav1.ConditionTrue || staleCondition {
|
|
return cond, false
|
|
}
|
|
}
|
|
|
|
return nil, false
|
|
}
|
|
|
|
// IsInferencePoolReadyForGateways checks if an InferencePool is ready, considering only
|
|
// parents that correspond to the given gateways. When gateway refs change on an
|
|
// LLMInferenceService, the gateway controller may leave stale parent entries in the pool's
|
|
// status for gateways that are no longer referenced. Without filtering, a rejected parent
|
|
// from an old gateway blocks readiness even though the current gateway accepts the pool.
|
|
//
|
|
// When gateways is empty, the pool is not ready.
|
|
func IsInferencePoolReadyForGateways(pool *igwapi.InferencePool, gateways []types.NamespacedName) bool {
|
|
if pool == nil || len(gateways) == 0 {
|
|
return false
|
|
}
|
|
|
|
relevant := filterRelevantV1Parents(pool.Status.Parents, gateways, pool.Namespace)
|
|
if len(relevant) == 0 {
|
|
return false
|
|
}
|
|
|
|
for _, gw := range gateways {
|
|
if !hasMatchingV1PoolParent(gw, relevant, pool.Namespace) {
|
|
return false
|
|
}
|
|
}
|
|
|
|
for _, parent := range relevant {
|
|
cond := meta.FindStatusCondition(parent.Conditions, string(igwapi.InferencePoolConditionAccepted))
|
|
if cond == nil {
|
|
return false
|
|
}
|
|
staleCondition := cond.ObservedGeneration > 0 && cond.ObservedGeneration < pool.Generation
|
|
if cond.Status != metav1.ConditionTrue || staleCondition {
|
|
return false
|
|
}
|
|
}
|
|
|
|
return true
|
|
}
|
|
|
|
// resolvedGatewayKeys extracts gateway name+namespace pairs from resolved gateways,
|
|
// resolving nil parentRef namespaces to the gateway object's actual namespace.
|
|
func resolvedGatewayKeys(resolved []ResolvedGateway) []types.NamespacedName {
|
|
keys := make([]types.NamespacedName, 0, len(resolved))
|
|
for _, rg := range resolved {
|
|
ns := rg.Gateway.Namespace
|
|
if rg.ParentRef.Namespace != nil {
|
|
ns = string(*rg.ParentRef.Namespace)
|
|
}
|
|
keys = append(keys, types.NamespacedName{Name: string(rg.ParentRef.Name), Namespace: ns})
|
|
}
|
|
return keys
|
|
}
|
|
|
|
func filterRelevantV1Parents(parents []igwapi.ParentStatus, gateways []types.NamespacedName, defaultNS string) []igwapi.ParentStatus {
|
|
relevant := make([]igwapi.ParentStatus, 0, len(parents))
|
|
for _, parent := range parents {
|
|
ns := string(parent.ParentRef.Namespace)
|
|
if ns == "" {
|
|
ns = defaultNS
|
|
}
|
|
key := types.NamespacedName{Name: string(parent.ParentRef.Name), Namespace: ns}
|
|
if matchesAnyGateway(key, gateways) {
|
|
relevant = append(relevant, parent)
|
|
}
|
|
}
|
|
return relevant
|
|
}
|
|
|
|
// allParentsAccepted returns true only when every parent has an Accepted condition set to True
|
|
// with a current ObservedGeneration.
|
|
func allParentsAccepted(parents []igwapi.ParentStatus, generation int64) bool {
|
|
for _, parent := range parents {
|
|
cond := meta.FindStatusCondition(parent.Conditions, string(igwapi.InferencePoolConditionAccepted))
|
|
if cond == nil {
|
|
return false
|
|
}
|
|
stale := cond.ObservedGeneration > 0 && cond.ObservedGeneration < generation
|
|
if cond.Status != metav1.ConditionTrue || stale {
|
|
return false
|
|
}
|
|
}
|
|
return true
|
|
}
|
|
|
|
func findNonReadyCondition(parents []igwapi.ParentStatus, conditionType string, generation int64) *metav1.Condition {
|
|
for _, parent := range parents {
|
|
cond := meta.FindStatusCondition(parent.Conditions, conditionType)
|
|
if cond == nil {
|
|
continue
|
|
}
|
|
stale := cond.ObservedGeneration > 0 && cond.ObservedGeneration < generation
|
|
if cond.Status != metav1.ConditionTrue || stale {
|
|
return cond
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func matchesAnyGateway(key types.NamespacedName, gateways []types.NamespacedName) bool {
|
|
for _, gw := range gateways {
|
|
if key == gw {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func hasMatchingV1PoolParent(gw types.NamespacedName, parents []igwapi.ParentStatus, defaultNS string) bool {
|
|
for _, parent := range parents {
|
|
ns := string(parent.ParentRef.Namespace)
|
|
if ns == "" {
|
|
ns = defaultNS
|
|
}
|
|
if gw.Name == string(parent.ParentRef.Name) && gw.Namespace == ns {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// IsInferencePoolV1Supported checks if an HTTPRoute has been accepted by the Gateway, and it's using v1 InferencePool.
|
|
func IsInferencePoolV1Supported(route *gwapiv1.HTTPRoute) metav1.ConditionStatus {
|
|
if isHTTPRouteUsingInferencePool(route, constants.InferencePoolV1APIGroupName) {
|
|
return isBackendSupported(route)
|
|
}
|
|
return metav1.ConditionUnknown
|
|
}
|
|
|
|
// isBackendSupported only returns false if we're absolutely sure the backend is unsupported
|
|
func isBackendSupported(route *gwapiv1.HTTPRoute) metav1.ConditionStatus {
|
|
if route == nil {
|
|
return metav1.ConditionUnknown
|
|
}
|
|
|
|
parents := route.Status.Parents
|
|
if len(parents) == 0 {
|
|
return metav1.ConditionUnknown
|
|
}
|
|
|
|
hasInvalidKind := false
|
|
for _, parent := range parents {
|
|
if !routeParentStatusMatchesSpec(route, parent) {
|
|
continue
|
|
}
|
|
|
|
cond := meta.FindStatusCondition(parent.Conditions, string(gwapiv1.RouteConditionResolvedRefs))
|
|
if cond == nil {
|
|
continue
|
|
}
|
|
if cond.Status == metav1.ConditionFalse && cond.Reason == string(gwapiv1.RouteReasonInvalidKind) {
|
|
hasInvalidKind = true
|
|
continue
|
|
}
|
|
return metav1.ConditionTrue
|
|
}
|
|
|
|
if hasInvalidKind {
|
|
return metav1.ConditionFalse
|
|
}
|
|
|
|
return metav1.ConditionUnknown
|
|
}
|
|
|
|
func routeParentStatusMatchesSpec(route *gwapiv1.HTTPRoute, parent gwapiv1.RouteParentStatus) bool {
|
|
if len(route.Spec.ParentRefs) == 0 {
|
|
return true
|
|
}
|
|
|
|
defaultNS := gwapiv1.Namespace(route.Namespace)
|
|
for _, specRef := range route.Spec.ParentRefs {
|
|
if parentRefMatches(specRef, parent.ParentRef, defaultNS) {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func isHTTPRouteUsingInferencePool(route *gwapiv1.HTTPRoute, group string) bool {
|
|
if route == nil {
|
|
return false
|
|
}
|
|
|
|
for _, r := range route.Spec.Rules {
|
|
for _, b := range r.BackendRefs {
|
|
if b.Group != nil && string(*b.Group) == group &&
|
|
b.Kind != nil && *b.Kind == "InferencePool" {
|
|
return true
|
|
}
|
|
}
|
|
}
|
|
|
|
return false
|
|
}
|