mirror of
https://github.com/hanzo-ml/kserve.git
synced 2026-10-30 20:53:49 +00:00
752 lines
28 KiB
Go
752 lines
28 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"
|
|
igwapiv1alpha2 "sigs.k8s.io/gateway-api-inference-extension/apix/v1alpha2"
|
|
gwapiv1 "sigs.k8s.io/gateway-api/apis/v1"
|
|
|
|
"github.com/kserve/kserve/pkg/constants"
|
|
)
|
|
|
|
var wildcardHostname = constants.GetEnvOrDefault("GATEWAY_API_WILDCARD_HOSTNAME", "inference")
|
|
|
|
// resolvedGateway contains a Gateway and its associated GatewayClass
|
|
// This provides all the information needed to understand gateway capabilities
|
|
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
|
|
}
|
|
|
|
// DiscoverGatewayServiceHost attempts to find the cluster-local hostname
|
|
// for the Service backing a Gateway.
|
|
// Returns empty string if no backing service is found (not an error).
|
|
func DiscoverGatewayServiceHost(ctx context.Context, c client.Client, gateway *gwapiv1.Gateway) (string, 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 "", 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]
|
|
host := network.GetServiceHostname(svc.Name, svc.Namespace)
|
|
logger.V(1).Info("Discovered gateway service via label", "gateway", gateway.Name, "service", svc.Name, "host", host)
|
|
return host, 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 {
|
|
host := network.GetServiceHostname(svc.Name, svc.Namespace)
|
|
logger.V(1).Info("Discovered gateway service via name match", "gateway", gateway.Name, "service", svc.Name, "host", host)
|
|
return host, nil
|
|
}
|
|
if !apierrors.IsNotFound(err) {
|
|
return "", fmt.Errorf("failed to get service %s/%s: %w", gateway.Namespace, gateway.Name, err)
|
|
}
|
|
|
|
// No backing service found - not an error - we are guessing here
|
|
logger.V(1).Info("No backing service found for gateway", "gateway", fmt.Sprintf("%s/%s", gateway.Namespace, gateway.Name))
|
|
return "", 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, route *gwapiv1.HTTPRoute, preferredUrlScheme string) ([]*apis.URL, error) {
|
|
var urls []*apis.URL
|
|
|
|
gateways, err := DiscoverGateways(ctx, c, route)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to discover gateways: %w", err)
|
|
}
|
|
|
|
for _, g := range gateways {
|
|
listeners, err := selectListeners(g.gateway, g.parentRef.SectionName, preferredUrlScheme)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to select listeners for gateway %s/%s: %w", g.gateway.Namespace, g.gateway.Name, err)
|
|
}
|
|
|
|
path := extractRoutePath(route)
|
|
addresses := g.gateway.Status.Addresses
|
|
|
|
// 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)
|
|
}
|
|
urls = append(urls, gatewayURLs...)
|
|
}
|
|
}
|
|
|
|
// Discover internal URL from Gateway backing service
|
|
internalHost, err := DiscoverGatewayServiceHost(ctx, c, g.gateway)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to discover gateway service host for %s/%s: %w", g.gateway.Namespace, g.gateway.Name, err)
|
|
}
|
|
if internalHost != "" {
|
|
// Use preferred (first) listener's scheme and port for the internal URL.
|
|
// Internal services typically expose a single protocol, so generating one
|
|
// URL (matching the preferred scheme) is sufficient. External URLs are
|
|
// generated for all listeners because clients may need either protocol.
|
|
listener := listeners[0]
|
|
internalURLs, 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: %w", g.gateway.Namespace, g.gateway.Name, err)
|
|
}
|
|
urls = append(urls, internalURLs...)
|
|
}
|
|
}
|
|
|
|
// 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
|
|
}
|
|
|
|
// extractRoutePath selects the base URL path from an HTTPRoute's rules.
|
|
// It assumes paths form a hierarchy (e.g. /ns/name, /ns/name/v1/completions)
|
|
// where the shallowest path is the logical base URL to advertise in status.
|
|
// Paths from rules with a Service backend take priority over others (e.g. InferencePool).
|
|
// Among candidates, the path with the fewest "/" segments is chosen, with
|
|
// string length as a tiebreaker.
|
|
// Note: this only handles PathPrefix matches correctly; Exact and RegularExpression
|
|
// match types are not distinguished.
|
|
func extractRoutePath(route *gwapiv1.HTTPRoute) string {
|
|
var servicePaths, otherPaths []string
|
|
for _, rule := range route.Spec.Rules {
|
|
svc := hasServiceBackend(rule)
|
|
for _, match := range rule.Matches {
|
|
if match.Path == nil {
|
|
continue
|
|
}
|
|
p := ptr.Deref(match.Path.Value, "/")
|
|
if svc {
|
|
servicePaths = append(servicePaths, p)
|
|
} else {
|
|
otherPaths = append(otherPaths, p)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TODO how do we deal with regexp
|
|
// TODO how do we intelligently handle multiple rules
|
|
paths := servicePaths
|
|
if len(paths) == 0 {
|
|
paths = 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)
|
|
})
|
|
}
|
|
|
|
// 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
|
|
}
|
|
|
|
// 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
|
|
}
|
|
|
|
// IsInferencePoolV1Alpha2Ready checks if a v1alpha2 InferencePool has been accepted by all parents.
|
|
// This mirrors IsInferencePoolReady but for the v1alpha2 InferencePool type, which has a different
|
|
// status structure (PoolStatus instead of ParentStatus).
|
|
func IsInferencePoolV1Alpha2Ready(pool *igwapiv1alpha2.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
|
|
}
|
|
|
|
if cond, missing := nonReadyInferencePoolV1Alpha2TopLevelCondition(pool); cond != nil || missing {
|
|
return false
|
|
}
|
|
|
|
return true
|
|
}
|
|
|
|
// nonReadyInferencePoolV1Alpha2TopLevelCondition checks for any non-ready conditions in a v1alpha2 InferencePool.
|
|
// Returns the first problematic condition or indicates missing conditions.
|
|
func nonReadyInferencePoolV1Alpha2TopLevelCondition(pool *igwapiv1alpha2.InferencePool) (*metav1.Condition, bool) {
|
|
if pool == nil {
|
|
return nil, true
|
|
}
|
|
|
|
for _, parent := range pool.Status.Parents {
|
|
cond := meta.FindStatusCondition(parent.Conditions, string(igwapiv1alpha2.InferencePoolConditionAccepted))
|
|
if cond == nil {
|
|
return nil, true
|
|
}
|
|
staleCondition := cond.ObservedGeneration > 0 && cond.ObservedGeneration < pool.Generation
|
|
if cond.Status != metav1.ConditionTrue || staleCondition {
|
|
return cond, false
|
|
}
|
|
}
|
|
|
|
return nil, false
|
|
}
|
|
|
|
// IsInferencePoolV1Alpha2Supported checks if an HTTPRoute has been accepted by the Gateway, and it's using v1alpha2
|
|
// InferencePool.
|
|
func IsInferencePoolV1Alpha2Supported(route *gwapiv1.HTTPRoute) metav1.ConditionStatus {
|
|
if isHTTPRouteUsingInferencePool(route, constants.InferencePoolV1Alpha2APIGroupName) {
|
|
return isBackendSupported(route)
|
|
}
|
|
return metav1.ConditionUnknown
|
|
}
|
|
|
|
// 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
|
|
}
|
|
|
|
// Check the first parent's status (TODO: filter to our specific gateway)
|
|
if len(route.Status.Parents) > 0 {
|
|
parent := route.Status.Parents[0]
|
|
cond := meta.FindStatusCondition(parent.Conditions, string(gwapiv1.RouteConditionResolvedRefs))
|
|
if cond == nil {
|
|
return metav1.ConditionUnknown
|
|
}
|
|
if cond.Status == metav1.ConditionFalse && cond.Reason == string(gwapiv1.RouteReasonInvalidKind) {
|
|
return metav1.ConditionFalse
|
|
}
|
|
return metav1.ConditionTrue
|
|
}
|
|
|
|
return metav1.ConditionUnknown
|
|
}
|
|
|
|
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
|
|
}
|