Files

882 lines
32 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"
"slices"
"strings"
apierrors "k8s.io/apimachinery/pkg/api/errors"
"k8s.io/apimachinery/pkg/api/meta"
"knative.dev/pkg/apis"
duckv1 "knative.dev/pkg/apis/duck/v1"
"knative.dev/pkg/network"
"k8s.io/apimachinery/pkg/types"
"k8s.io/utils/ptr"
"k8s.io/apimachinery/pkg/api/equality"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"knative.dev/pkg/kmeta"
igwapi "sigs.k8s.io/gateway-api-inference-extension/api/v1"
gwapiv1 "sigs.k8s.io/gateway-api/apis/v1"
igwapiv1alpha2 "github.com/kserve/kserve/pkg/apis/gie/v1alpha2pool"
"sigs.k8s.io/controller-runtime/pkg/client"
"sigs.k8s.io/controller-runtime/pkg/log"
"github.com/kserve/kserve/pkg/apis/serving/v1alpha2"
"github.com/kserve/kserve/pkg/constants"
"github.com/kserve/kserve/pkg/utils"
)
// AnnotationInferencePoolMigrated records when the HTTPRoute has migrated to v1 InferencePool.
// Once set to "v1", traffic will never fall back to v1alpha2 even during transient failures.
const AnnotationInferencePoolMigrated = "serving.kserve.io/inference-pool-migrated"
const AnnotationModelBasedRoutingEnabled = "serving.kserve.io/model-based-routing-enabled"
// ErrPreconditionNotMet is a sentinel error returned by ensureGatewayPreconditions
// when a non-transient precondition is not met (e.g. a required CRD is missing).
// The caller should mark status but not propagate the error to avoid infinite requeue.
var ErrPreconditionNotMet = errors.New("precondition not met")
// reconcileRouter handles the networking and routing components for the LLM service
// This includes schedulers, HTTP routes, and various validation checks
func (r *LLMISVCReconciler) reconcileRouter(ctx context.Context, llmSvc *v1alpha2.LLMInferenceService, cfg *Config) error {
logger := log.FromContext(ctx).WithName("reconcileRouter")
ctx = log.IntoContext(ctx, logger)
logger.Info("Reconciling Router")
// Ensure readiness is determined even if errors occur
defer llmSvc.DetermineRouterReadiness()
// Ensure platform-specific preconditions are met before proceeding.
// A non-transient precondition failure (e.g. missing CRD) marks status and stops
// reconciliation without requeuing — the condition won't resolve by retrying.
if err := r.ensureGatewayPreconditions(ctx, llmSvc); err != nil {
if errors.Is(err, ErrPreconditionNotMet) {
llmSvc.MarkHTTPRoutesNotReady("GatewayPreconditionNotMet", err.Error())
return nil
}
llmSvc.MarkHTTPRoutesNotReady("HTTPRouteReconcileError", err.Error())
return fmt.Errorf("failed to ensure gateway preconditions: %w", err)
}
// Validate that referenced resources exist before proceeding
if err := r.validateRouterReferences(ctx, llmSvc); err != nil {
return err
}
// Reconcile the scheduler component that manages inference pools
if err := r.reconcileScheduler(ctx, llmSvc); err != nil {
llmSvc.MarkSchedulerWorkloadNotReady("SchedulerReconcileError", "Failed to reconcile scheduler: %v", err.Error())
return fmt.Errorf("failed to reconcile scheduler: %w", err)
}
// Reconcile HTTP routes for traffic routing
// We do not support Gateway's spec, when creating HTTPRoutes either the default gateway or those provided
// as refs are attached to reconciled routes
resolvedGWs, err := r.reconcileHTTPRoutes(ctx, llmSvc, cfg)
if err != nil {
llmSvc.MarkHTTPRoutesNotReady("HTTPRouteReconcileError", "Failed to reconcile HTTPRoute: %v", err.Error())
return fmt.Errorf("failed to reconcile HTTP routes: %w", err)
}
// Reconcile platform-specific networking resources
if err := r.reconcileRouterPlatformNetworking(ctx, llmSvc); err != nil {
llmSvc.MarkHTTPRoutesNotReady("PlatformNetworkingReconcileError", "Failed to reconcile platform networking: %v", err.Error())
return fmt.Errorf("failed to reconcile router platform networking: %w", err)
}
// Evaluate the subconditions to determine overall router health
if err := r.EvaluateInferencePoolConditions(ctx, llmSvc, resolvedGWs); err != nil {
return fmt.Errorf("failed to evaluate Inference Pool conditions: %w", err)
}
if err := r.EvaluateGatewayConditions(ctx, llmSvc, resolvedGWs); err != nil {
return fmt.Errorf("failed to evaluate gateway conditions: %w", err)
}
if err := r.EvaluateHTTPRouteConditions(ctx, llmSvc, cfg); err != nil {
return fmt.Errorf("failed to evaluate HTTPRoute conditions: %w", err)
}
return nil
}
// reconcileHTTPRoutes manages HTTPRoute resources for traffic routing.
// It handles both custom routes (via refs) and generated routes (via spec).
// Returns the resolved gateways discovered during URL assembly so the caller
// can pass them to EvaluateGatewayConditions without re-fetching.
func (r *LLMISVCReconciler) reconcileHTTPRoutes(ctx context.Context, llmSvc *v1alpha2.LLMInferenceService, cfg *Config) ([]ResolvedGateway, error) {
logger := log.FromContext(ctx)
logger.Info("Reconciling HTTPRoute")
expectedHTTPRoute := r.expectedHTTPRoute(ctx, llmSvc, cfg)
// Clean up if router or routes are not configured
if utils.GetForceStopRuntime(llmSvc) || llmSvc.Spec.Router == nil || llmSvc.Spec.Router.Route == nil {
if _, err := r.updateRoutingStatus(ctx, llmSvc); err != nil {
return nil, err
}
return nil, Delete(ctx, r, llmSvc, expectedHTTPRoute)
}
// Collect any explicitly referenced HTTPRoutes
referencedRoutes, err := r.collectReferencedRoutes(ctx, llmSvc)
if err != nil {
return nil, fmt.Errorf("failed to collect referenced routes: %w", err)
}
route := llmSvc.Spec.Router.Route
// If using custom routes via refs, delete our own
if route.HTTP.HasRefs() {
if err := Delete(ctx, r, llmSvc, expectedHTTPRoute); err != nil {
return nil, err
}
}
if route.HTTP.HasSpec() {
if err := Reconcile(ctx, r, llmSvc, &gwapiv1.HTTPRoute{}, expectedHTTPRoute, semanticHTTPRouteIsEqual); err != nil {
return nil, fmt.Errorf("failed to reconcile HTTPRoute %s/%s: %w", expectedHTTPRoute.GetNamespace(), expectedHTTPRoute.GetName(), err)
}
referencedRoutes = append(referencedRoutes, expectedHTTPRoute)
}
return r.updateRoutingStatus(ctx, llmSvc, referencedRoutes...)
}
// collectReferencedRoutes gathers all HTTPRoutes referenced by the service
// This is used for status updates and condition evaluation
func (r *LLMISVCReconciler) collectReferencedRoutes(ctx context.Context, llmSvc *v1alpha2.LLMInferenceService) ([]*gwapiv1.HTTPRoute, error) {
if llmSvc.Spec.Router == nil || llmSvc.Spec.Router.Route == nil || !llmSvc.Spec.Router.Route.HTTP.HasRefs() {
return nil, nil
}
referencedRoutes := make([]*gwapiv1.HTTPRoute, 0, len(llmSvc.Spec.Router.Route.HTTP.Refs))
// Fetch each referenced route
for _, routeRef := range llmSvc.Spec.Router.Route.HTTP.Refs {
route := &gwapiv1.HTTPRoute{}
if err := r.Get(ctx, types.NamespacedName{Namespace: llmSvc.GetNamespace(), Name: routeRef.Name}, route); err != nil {
if apierrors.IsNotFound(err) {
// Skip missing routes - validation is handled separately
continue
}
return referencedRoutes, fmt.Errorf("failed to get HTTPRoute %s/%s: %w", llmSvc.GetName(), routeRef.Name, err)
}
referencedRoutes = append(referencedRoutes, route)
}
return referencedRoutes, nil
}
// expectedHTTPRoute creates the HTTPRoute specification for this service
// This route is created when the service specifies inline routing configuration
func (r *LLMISVCReconciler) expectedHTTPRoute(ctx context.Context, llmSvc *v1alpha2.LLMInferenceService, cfg *Config) *gwapiv1.HTTPRoute {
httpRoute := &gwapiv1.HTTPRoute{
ObjectMeta: metav1.ObjectMeta{
Name: kmeta.ChildName(llmSvc.GetName(), "-kserve-route"),
Namespace: llmSvc.GetNamespace(),
OwnerReferences: []metav1.OwnerReference{
*metav1.NewControllerRef(llmSvc, v1alpha2.LLMInferenceServiceGVK),
},
Labels: RouterLabels(llmSvc),
},
}
if llmSvc.Spec.Router != nil && llmSvc.Spec.Router.Route != nil && llmSvc.Spec.Router.Route.HTTP.HasSpec() {
httpRoute.Spec = *llmSvc.Spec.Router.Route.HTTP.Spec.DeepCopy()
if r.isModelBasedRoutingEnabled(ctx, llmSvc, cfg) {
if llmSvc.Spec.Model.LoRA != nil {
expandLoRAAdapterMatches(
httpRoute.Spec.Rules,
llmSvc.Namespace,
llmSvc.Spec.Model.LoRA.Adapters,
cfg.ModelBasedRoutingHeaderName,
)
}
} else {
httpRoute.Spec.Rules = stripModelBasedRoutingRules(
httpRoute.Spec.Rules,
cfg.ModelBasedRoutingHeaderName,
)
}
}
// Migration logic: check if we should switch from v1alpha2 to v1 InferencePool
// Only applies to managed routes with a scheduler (not using external pool refs)
if llmSvc.Spec.Router == nil || llmSvc.Spec.Router.Scheduler == nil ||
llmSvc.Spec.Router.Scheduler.Pool.HasRef() {
return httpRoute
}
logger := log.FromContext(ctx).WithValues("migration", "InferencePool")
curr := &gwapiv1.HTTPRoute{}
routeExists := r.Get(ctx, client.ObjectKeyFromObject(httpRoute), curr) == nil
const v1MigrationValue = "v1"
var isMigrated bool
var infPoolV1Alpha2Support, infPoolV1Support metav1.ConditionStatus
if routeExists {
migrationValue, hasMigrationAnnotation := curr.Annotations[AnnotationInferencePoolMigrated]
isMigrated = hasMigrationAnnotation && migrationValue == v1MigrationValue
infPoolV1Alpha2Support = IsInferencePoolV1Alpha2Supported(curr)
infPoolV1Support = IsInferencePoolV1Supported(curr)
}
// Switch to v1 if:
// - Gateway accepted v1 (route using v1 and ResolvedRefs=True), OR
// - Gateway rejected v1alpha2 (route using v1alpha2 and ResolvedRefs=False/InvalidKind), OR
// - Already migrated (annotation exists - one-way lock)
if isMigrated || infPoolV1Support == metav1.ConditionTrue || infPoolV1Alpha2Support == metav1.ConditionFalse {
// Switch backendRef to v1 API group
for i := range httpRoute.Spec.Rules {
for j := range httpRoute.Spec.Rules[i].BackendRefs {
if isDefaultBackendRef(llmSvc, httpRoute.Spec.Rules[i].BackendRefs[j].BackendRef) {
httpRoute.Spec.Rules[i].BackendRefs[j].Group = ptr.To(gwapiv1.Group(constants.InferencePoolV1APIGroupName))
}
}
}
// Persist migration annotation
if httpRoute.Annotations == nil {
httpRoute.Annotations = make(map[string]string, 1)
}
httpRoute.Annotations[AnnotationInferencePoolMigrated] = v1MigrationValue
logger.Info("Using InferencePool v1 API for HTTPRoute",
"isMigrated", isMigrated,
"infPoolV1Support", infPoolV1Support,
"infPoolV1Alpha2Support", infPoolV1Alpha2Support,
"httproute.curr.spec", curr.Spec,
"httproute.curr.status", curr.Status,
"httproute.expected.spec", httpRoute.Spec,
)
} else {
// Not migrated yet, use v1alpha2
for i := range httpRoute.Spec.Rules {
for j := range httpRoute.Spec.Rules[i].BackendRefs {
if isDefaultBackendRef(llmSvc, httpRoute.Spec.Rules[i].BackendRefs[j].BackendRef) {
httpRoute.Spec.Rules[i].BackendRefs[j].Group = ptr.To(gwapiv1.Group(constants.InferencePoolV1Alpha2APIGroupName))
}
}
}
logger.Info("Using InferencePool v1alpha2 API for HTTPRoute",
"isMigrated", isMigrated,
"infPoolV1Support", infPoolV1Support,
"infPoolV1Alpha2Support", infPoolV1Alpha2Support,
"httproute.curr.spec", curr.Spec,
"httproute.curr.status", curr.Status,
"httproute.expected.spec", httpRoute.Spec,
)
}
return httpRoute
}
func (r *LLMISVCReconciler) updateRoutingStatus(ctx context.Context, llmSvc *v1alpha2.LLMInferenceService, routes ...*gwapiv1.HTTPRoute) ([]ResolvedGateway, error) {
logger := log.FromContext(ctx)
if utils.GetForceStopRuntime(llmSvc) {
llmSvc.Status.Router = nil
llmSvc.Status.Address = nil //nolint:staticcheck // retained for schema compatibility
llmSvc.Status.Addresses = nil
llmSvc.MarkHTTPRoutesNotReady("Stopped", "Service is stopped")
return nil, nil
}
if llmSvc.Spec.Router == nil || llmSvc.Spec.Router.Route == nil {
llmSvc.Status.Router = nil
urlFn := apis.HTTPS
statusCfg, err := r.loadConfig(ctx)
if err != nil {
return nil, fmt.Errorf("failed to load config: %w", err)
}
if !statusCfg.EnableTLS {
urlFn = apis.HTTP
}
llmSvc.Status.Addresses = []v1alpha2.SourcedAddress{{
Addressable: duckv1.Addressable{
URL: urlFn(network.GetServiceHostname(
kmeta.ChildName(llmSvc.GetName(), "-kserve-workload-svc"),
llmSvc.GetNamespace(),
)),
},
}}
return nil, nil
}
cfg, err := r.loadConfig(ctx)
if err != nil {
return nil, fmt.Errorf("failed to load config: %w", err)
}
var discovered []DiscoveredURL
var allResolved []ResolvedGateway
for _, route := range routes {
resolvedGWs, err := DiscoverGateways(ctx, r.Client, route)
if err != nil {
return nil, fmt.Errorf("failed to discover gateways for route %s/%s: %w", route.GetNamespace(), route.GetName(), err)
}
allResolved = append(allResolved, resolvedGWs...)
urls, err := DiscoverURLs(ctx, r.Client, resolvedGWs, route, *cfg)
if IgnoreNoURLsDiscovered(err) != nil {
return nil, fmt.Errorf("failed to discover URL for route %s/%s: %w", route.GetNamespace(), route.GetName(), err)
}
discovered = append(discovered, urls...)
}
if len(routes) > 0 {
llmSvc.Status.Router = &v1alpha2.RouterStatus{
Gateways: BuildObservedGateways(routes),
}
}
additional, err := r.discoverAdditionalURLs(ctx, discovered)
if err != nil {
return nil, fmt.Errorf("failed to discover additional URLs: %w", err)
}
discovered = append(discovered, additional...)
slices.SortStableFunc(discovered, func(a, b DiscoveredURL) int {
return cmp.Compare(a.URL.String(), b.URL.String())
})
externalURLs := FilterExternalURLs(discovered)
if len(externalURLs) == 0 {
logger.Info("no external URL discovered, using internal address")
if len(discovered) > 0 {
llmSvc.Status.URL = preferPathBasedURL(discovered, llmSvc.Namespace, llmSvc.Name, cfg.UrlScheme)
} else {
llmSvc.Status.URL = nil
}
} else {
llmSvc.Status.URL = preferPathBasedURL(externalURLs, llmSvc.Namespace, llmSvc.Name, "")
}
llmSvc.Status.Addresses = make([]v1alpha2.SourcedAddress, 0, len(discovered))
for _, d := range discovered {
llmSvc.Status.Addresses = append(llmSvc.Status.Addresses, SourcedAddress(ctx, d, llmSvc))
}
// Deduplicate resolved gateways for downstream condition evaluation.
seen := make(map[string]struct{})
deduped := allResolved[:0]
for _, gw := range allResolved {
key := gw.Gateway.Namespace + "/" + gw.Gateway.Name
if _, ok := seen[key]; !ok {
seen[key] = struct{}{}
deduped = append(deduped, gw)
}
}
return deduped, nil
}
// preferPathBasedURL selects the first URL whose path starts with
// /{namespace}/{name}, preferring the one matching preferredScheme
// when multiple candidates exist.
func preferPathBasedURL(urls []DiscoveredURL, namespace, name, preferredScheme string) *apis.URL {
prefix := "/" + namespace + "/" + name
var firstPathMatch *apis.URL
for _, u := range urls {
if strings.HasPrefix(u.URL.Path, prefix) {
if preferredScheme != "" && u.URL.Scheme == preferredScheme {
return u.URL
}
if firstPathMatch == nil {
firstPathMatch = u.URL
}
}
}
if firstPathMatch != nil {
return firstPathMatch
}
return urls[0].URL
}
func RouterLabels(llmSvc *v1alpha2.LLMInferenceService) map[string]string {
return map[string]string{
constants.KubernetesComponentLabelKey: constants.LLMComponentRouter,
constants.KubernetesAppNameLabelKey: llmSvc.GetName(),
constants.KubernetesPartOfLabelKey: constants.LLMInferenceServicePartOfValue,
}
}
func semanticHTTPRouteIsEqual(e *gwapiv1.HTTPRoute, c *gwapiv1.HTTPRoute) bool {
return equality.Semantic.DeepDerivative(e.Spec, c.Spec) &&
equality.Semantic.DeepDerivative(e.Labels, c.Labels) &&
equality.Semantic.DeepDerivative(e.Annotations, c.Annotations)
}
// EvaluateGatewayConditions evaluates the readiness of all Gateways referenced by the LLMInferenceService
// and updates the GatewaysReady condition accordingly. The resolved slice is provided by
// updateRoutingStatus so that gateways are not re-fetched from the API server.
func (r *LLMISVCReconciler) EvaluateGatewayConditions(ctx context.Context, llmSvc *v1alpha2.LLMInferenceService, resolved []ResolvedGateway) error {
logger := log.FromContext(ctx).WithName("evaluateGatewayConditions")
if utils.GetForceStopRuntime(llmSvc) {
llmSvc.MarkGatewaysNotReady("Stopped", "Service is stopped")
return nil
}
// If no router or gateway configuration, mark as ready to clear any previous stopped state
if llmSvc.Spec.Router == nil || !llmSvc.Spec.Router.Gateway.HasRefs() {
logger.Info("No Gateway references found, skipping Gateway condition evaluation")
llmSvc.MarkGatewaysReadyUnset()
return nil
}
gateways := make([]*gwapiv1.Gateway, 0, len(resolved))
for _, resolvedGateway := range resolved {
gateways = append(gateways, resolvedGateway.Gateway)
}
notReadyGateways := EvaluateGatewayReadiness(ctx, gateways)
if len(notReadyGateways) > 0 {
gatewayNames := make([]string, len(notReadyGateways))
for i, gw := range notReadyGateways {
gatewayNames[i] = fmt.Sprintf("%s/%s", gw.Namespace, gw.Name)
}
llmSvc.MarkGatewaysNotReady("GatewaysNotReady", "The following Gateways are not ready: %v", gatewayNames)
logger.V(2).Info("Some referenced Gateways are not ready", "gateways", notReadyGateways)
return nil
}
llmSvc.MarkGatewaysReady()
logger.Info("All referenced Gateways are ready")
return nil
}
// CollectReferencedGateways retrieves all Gateway objects referenced in the LLMInferenceService spec
func (r *LLMISVCReconciler) CollectReferencedGateways(ctx context.Context, llmSvc *v1alpha2.LLMInferenceService) ([]*gwapiv1.Gateway, error) {
if llmSvc.Spec.Router == nil || !llmSvc.Spec.Router.Gateway.HasRefs() {
return nil, nil
}
// Use a map to ensure gateways are not repeated (keyed by namespace/name)
gatewayMap := make(map[string]*gwapiv1.Gateway)
routes, err := r.collectReferencedRoutes(ctx, llmSvc)
if err != nil {
return nil, fmt.Errorf("failed to collect referenced routes: %w", err)
}
for _, route := range routes {
discoveredGateways, err := DiscoverGateways(ctx, r.Client, route)
if err != nil {
return nil, fmt.Errorf("failed to discover gateways: %w", err)
}
for _, gateway := range discoveredGateways {
key := gateway.Gateway.Namespace + "/" + gateway.Gateway.Name
gatewayMap[key] = gateway.Gateway
}
}
for _, ref := range llmSvc.Spec.Router.Gateway.Refs {
gateway := &gwapiv1.Gateway{}
gatewayKey := types.NamespacedName{
Name: string(ref.Name),
Namespace: string(ref.Namespace),
}
// If namespace is not specified, use the same namespace as the LLMInferenceService
if gatewayKey.Namespace == "" {
gatewayKey.Namespace = llmSvc.GetNamespace()
}
err := r.Get(ctx, gatewayKey, gateway)
if err != nil {
return nil, fmt.Errorf("failed to get Gateway %s: %w", gatewayKey, err)
}
key := gateway.Namespace + "/" + gateway.Name
gatewayMap[key] = gateway
}
// Convert map values to slice
gateways := make([]*gwapiv1.Gateway, 0, len(gatewayMap))
for _, gw := range gatewayMap {
gateways = append(gateways, gw)
}
return gateways, nil
}
// EvaluateHTTPRouteConditions evaluates the readiness of all HTTPRoutes referenced by the LLMInferenceService
// and updates the HTTPRoutesReady condition accordingly. Also detects Gateway rejection of v1alpha2 backendRefs.
func (r *LLMISVCReconciler) EvaluateHTTPRouteConditions(ctx context.Context, llmSvc *v1alpha2.LLMInferenceService, cfg *Config) error {
logger := log.FromContext(ctx).WithName("evaluateHTTPRouteConditions")
if utils.GetForceStopRuntime(llmSvc) {
llmSvc.MarkHTTPRoutesNotReady("Stopped", "Service is stopped")
return nil
}
// If no router or route configuration, mark HTTPRoutes as ready (no routes to evaluate)
if llmSvc.Spec.Router == nil || llmSvc.Spec.Router.Route == nil || llmSvc.Spec.Router.Route.HTTP == nil {
logger.Info("No HTTPRoute configuration found, clearing HTTPRoutesReady condition")
llmSvc.MarkHTTPRoutesReadyUnset()
return nil
}
// Collect all HTTPRoutes (both referenced and managed)
var allRoutes []*gwapiv1.HTTPRoute
// Get referenced routes
referencedRoutes, err := r.collectReferencedRoutes(ctx, llmSvc)
if err != nil {
llmSvc.MarkHTTPRoutesNotReady("HTTPRouteFetchError", "Failed to fetch referenced HTTPRoutes: %v", err.Error())
return fmt.Errorf("failed to fetch referenced HTTPRoutes: %w", err)
}
allRoutes = append(allRoutes, referencedRoutes...)
// Get managed route if it exists
if llmSvc.Spec.Router.Route.HTTP.HasSpec() {
expectedHTTPRoute := r.expectedHTTPRoute(ctx, llmSvc, cfg)
// Try to get the actual managed route from the cluster
managedRoute := &gwapiv1.HTTPRoute{}
if err := r.Get(ctx, types.NamespacedName{
Namespace: expectedHTTPRoute.Namespace,
Name: expectedHTTPRoute.Name,
}, managedRoute); err == nil {
allRoutes = append(allRoutes, managedRoute)
}
}
// If no routes found, mark as ready (nothing to evaluate)
if len(allRoutes) == 0 {
llmSvc.MarkHTTPRoutesReady()
logger.Info("No HTTPRoutes found, marking HTTPRoutesReady as true")
return nil
}
notReadyRoutes := EvaluateHTTPRouteReadiness(ctx, allRoutes)
if len(notReadyRoutes) > 0 {
nonReadyRouteMessages := make([]string, len(notReadyRoutes))
for i, route := range notReadyRoutes {
topLevelCondition := findNonReadyGatewayCondition(route)
if topLevelCondition != nil {
nonReadyRouteMessages[i] = fmt.Sprintf("%s/%s: %#v (reason %q, message %q)", route.Namespace, route.Name, topLevelCondition.Status, topLevelCondition.Reason, topLevelCondition.Message)
} else {
nonReadyRouteMessages[i] = fmt.Sprintf("%s/%s: %#v", route.Namespace, route.Name, route.Status)
}
}
llmSvc.MarkHTTPRoutesNotReady("HTTPRoutesNotReady", "The following HTTPRoutes are not ready: %v", nonReadyRouteMessages)
logger.V(2).Info("Some HTTPRoutes are not ready", "routes", notReadyRoutes)
return nil
}
llmSvc.MarkHTTPRoutesReady()
logger.V(2).Info("All HTTPRoutes are ready", "routes", allRoutes)
return nil
}
// EvaluateInferencePoolConditions evaluates the readiness of all Inference Pools in the LLMInferenceService
// and updates the InferencePoolReady condition accordingly.
// During the v1alpha2 to v1 migration, it checks both pool versions for managed pools
// and considers the pool ready if at least one is ready.
func (r *LLMISVCReconciler) EvaluateInferencePoolConditions(ctx context.Context, llmSvc *v1alpha2.LLMInferenceService, resolvedGWs []ResolvedGateway) error {
logger := log.FromContext(ctx).WithName("EvaluateInferencePoolConditions")
if utils.GetForceStopRuntime(llmSvc) {
llmSvc.MarkInferencePoolNotReady("Stopped", "Service is stopped")
return nil
}
// If no router or scheduler configuration, mark Inference Pools as ready (no Inference Pools to evaluate)
if llmSvc.Spec.Router == nil || llmSvc.Spec.Router.Scheduler == nil {
logger.V(2).Info("Scheduler is disabled, clearing InferencePoolReady condition")
llmSvc.MarkInferencePoolReadyUnset()
return nil
}
// Resolve gateway identities once for pool parent matching.
gatewayKeys := resolvedGatewayKeys(resolvedGWs)
// For referenced pools (external), only check that pool
if llmSvc.Spec.Router.Scheduler.Pool != nil && llmSvc.Spec.Router.Scheduler.Pool.Ref != nil && llmSvc.Spec.Router.Scheduler.Pool.Ref.Name != "" {
poolRef := llmSvc.Spec.Router.Scheduler.Pool.Ref
curr := &igwapi.InferencePool{}
err := r.Get(ctx, types.NamespacedName{Namespace: llmSvc.Namespace, Name: poolRef.Name}, curr)
if err != nil {
err := fmt.Errorf("failed to fetch referenced Inference Pool %s/%s: %w", llmSvc.Namespace, poolRef.Name, err)
llmSvc.MarkInferencePoolNotReady("InferencePoolFetchError", err.Error())
return err
}
setRoutingPoolStatus(llmSvc,
gwapiv1.ObjectReference{
Group: gwapiv1.Group("inference.networking.k8s.io"),
Kind: "InferencePool",
Name: gwapiv1.ObjectName(poolRef.Name),
},
gwapiv1.ObjectReference{
Group: "",
Kind: "Service",
Name: gwapiv1.ObjectName(llmSvc.Spec.Router.EPPServiceName(llmSvc)),
},
)
return r.evaluateInferencePoolCondition(ctx, llmSvc, curr, gatewayKeys)
}
// For managed pools, check both v1 and v1alpha2 pools during migration.
// Both pool versions are created by the scheduler reconciler - they live in different
// API groups and can coexist. During migration, the gateway may only accept one version.
expected := r.expectedSchedulerInferencePool(ctx, llmSvc)
poolName := expected.Name
poolNamespace := expected.Namespace
// Check v1 pool - scoped to only the gateways currently in use. Without this,
// stale parent entries from previously-referenced gateways can block readiness.
v1Pool := &igwapi.InferencePool{}
v1Err := r.Get(ctx, types.NamespacedName{Namespace: poolNamespace, Name: poolName}, v1Pool)
v1Ready := v1Err == nil && IsInferencePoolReadyForGateways(v1Pool, gatewayKeys)
// Check v1alpha2 pool - treat CRD-not-installed as "version unavailable" (not an error)
v1alpha2Pool := &igwapiv1alpha2.InferencePool{}
v1alpha2Err := r.Get(ctx, types.NamespacedName{Namespace: poolNamespace, Name: poolName}, v1alpha2Pool)
v1alpha2Ready := v1alpha2Err == nil && IsInferencePoolV1Alpha2ReadyForGateways(v1alpha2Pool, gatewayKeys)
// Record the pool and EPP service refs in status - the pool name is deterministic
// regardless of whether the pool is ready or even exists yet.
setRoutingPoolStatus(llmSvc,
gwapiv1.ObjectReference{
Group: gwapiv1.Group("inference.networking.k8s.io"),
Kind: "InferencePool",
Name: gwapiv1.ObjectName(poolName),
},
gwapiv1.ObjectReference{
Group: "",
Kind: "Service",
Name: gwapiv1.ObjectName(llmSvc.Spec.Router.EPPServiceName(llmSvc)),
},
)
logger.V(2).Info("Checking InferencePool readiness",
"pool", poolNamespace+"/"+poolName,
"v1Ready", v1Ready,
"v1Err", v1Err,
"v1alpha2Ready", v1alpha2Ready,
"v1alpha2Err", v1alpha2Err,
)
// If at least one is ready, mark as ready
if v1Ready || v1alpha2Ready {
llmSvc.MarkInferencePoolReady()
logger.V(2).Info("Inference Pool is ready", "v1Ready", v1Ready, "v1alpha2Ready", v1alpha2Ready)
return nil
}
// Neither is ready - report status from the one that exists (prefer v1 since it's the target)
if v1Err == nil {
return r.evaluateInferencePoolCondition(ctx, llmSvc, v1Pool, gatewayKeys)
}
if v1alpha2Err == nil {
relevant := filterRelevantV1Alpha2Parents(v1alpha2Pool.Status.Parents, gatewayKeys, v1alpha2Pool.Namespace)
if len(relevant) == 0 {
llmSvc.MarkInferencePoolNotReady("WaitingForGateway",
"Inference Pool %s/%s exists but no Gateway controller has accepted it yet", poolNamespace, poolName)
return nil
}
for _, gw := range gatewayKeys {
if !hasMatchingV1Alpha2PoolParent(gw, relevant, v1alpha2Pool.Namespace) {
llmSvc.MarkInferencePoolNotReady("WaitingForGateway",
"Inference Pool %s/%s is not yet accepted by gateway %s/%s",
poolNamespace, poolName, gw.Namespace, gw.Name)
return nil
}
}
if cond := findNonReadyV1Alpha2Condition(relevant, string(igwapiv1alpha2.InferencePoolConditionAccepted), v1alpha2Pool.Generation); cond != nil {
llmSvc.MarkInferencePoolNotReady("InferencePoolNotReady", fmt.Sprintf(
"%s/%s: %v=%#v (reason %q, message %q)",
poolNamespace,
poolName,
cond.Type,
cond.Status,
cond.Reason,
cond.Message,
))
} else {
llmSvc.MarkInferencePoolNotReady("InferencePoolNotReady", fmt.Sprintf("The inference pool %s/%s is not ready", poolNamespace, poolName))
}
return nil
}
// Both failed to fetch - distinguish between "not found" and transient errors
if (apierrors.IsNotFound(v1Err) || meta.IsNoMatchError(v1Err)) &&
(apierrors.IsNotFound(v1alpha2Err) || meta.IsNoMatchError(v1alpha2Err)) {
llmSvc.MarkInferencePoolNotReady("InferencePoolFetchError",
fmt.Sprintf("Inference Pool %s/%s not found (checked v1 and v1alpha2)", poolNamespace, poolName))
return nil
}
// At least one was a transient error - return it so the controller requeues
err := fmt.Errorf("failed to fetch Inference Pool %s/%s: v1: %w, v1alpha2: %w", poolNamespace, poolName, v1Err, v1alpha2Err)
llmSvc.MarkInferencePoolNotReady("InferencePoolFetchError", err.Error())
return err
}
// evaluateInferencePoolCondition evaluates a single v1 InferencePool and updates the condition.
// Only pool parents matching the given gateway keys are considered.
func (r *LLMISVCReconciler) evaluateInferencePoolCondition(ctx context.Context, llmSvc *v1alpha2.LLMInferenceService, curr *igwapi.InferencePool, gateways []types.NamespacedName) error {
relevant := filterRelevantV1Parents(curr.Status.Parents, gateways, curr.Namespace)
if len(relevant) == 0 {
llmSvc.MarkInferencePoolNotReady("WaitingForGateway",
"Inference Pool %s/%s exists but no Gateway controller has accepted it yet", curr.Namespace, curr.Name)
return nil
}
for _, gw := range gateways {
if !hasMatchingV1PoolParent(gw, relevant, curr.Namespace) {
llmSvc.MarkInferencePoolNotReady("WaitingForGateway",
"Inference Pool %s/%s is not yet accepted by gateway %s/%s",
curr.Namespace, curr.Name, gw.Namespace, gw.Name)
return nil
}
}
if !allParentsAccepted(relevant, curr.Generation) {
if cond := findNonReadyCondition(relevant, string(igwapi.InferencePoolConditionAccepted), curr.Generation); cond != nil {
llmSvc.MarkInferencePoolNotReady("InferencePoolNotReady", fmt.Sprintf(
"%s/%s: %v=%#v (reason %q, message %q)",
curr.Namespace,
curr.Name,
cond.Type,
cond.Status,
cond.Reason,
cond.Message,
))
} else {
llmSvc.MarkInferencePoolNotReady("InferencePoolNotReady",
fmt.Sprintf("The inference pool %s/%s is not ready", curr.Namespace, curr.Name))
}
return nil
}
llmSvc.MarkInferencePoolReady()
log.FromContext(ctx).V(2).Info("Inference Pool is ready", "pool", curr)
return nil
}
// BuildObservedGateways constructs the RouterStatus.Gateways slice from HTTPRoutes.
// Gateway references are derived from each route's spec.parentRefs.
// Gateways are deduplicated by namespace/name; listeners and HTTPRoutes
// are aggregated per unique gateway and sorted for stable DeepEqual.
func BuildObservedGateways(routes []*gwapiv1.HTTPRoute) []v1alpha2.ObservedGateway {
if len(routes) == 0 {
return nil
}
type accumulator struct {
gwName string
gwNS string
listeners []gwapiv1.SectionName
routes []gwapiv1.ObjectReference
}
acc := map[string]*accumulator{}
for _, route := range routes {
ref := gwapiv1.ObjectReference{
Group: gwapiv1.Group(gwapiv1.GroupName),
Kind: "HTTPRoute",
Name: gwapiv1.ObjectName(route.Name),
Namespace: ptr.To(gwapiv1.Namespace(route.Namespace)),
}
for _, parentRef := range route.Spec.ParentRefs {
gwNS := string(ptr.Deref(parentRef.Namespace, gwapiv1.Namespace(route.Namespace)))
gwName := string(parentRef.Name)
key := gwNS + "/" + gwName
if _, ok := acc[key]; !ok {
acc[key] = &accumulator{gwName: gwName, gwNS: gwNS}
}
a := acc[key]
if parentRef.SectionName != nil {
a.listeners = append(a.listeners, *parentRef.SectionName)
}
alreadyAdded := false
for _, existing := range a.routes {
if existing.Name == ref.Name {
alreadyAdded = true
break
}
}
if !alreadyAdded {
a.routes = append(a.routes, ref)
}
}
}
keys := make([]string, 0, len(acc))
for k := range acc {
keys = append(keys, k)
}
slices.Sort(keys)
result := make([]v1alpha2.ObservedGateway, 0, len(acc))
for _, key := range keys {
a := acc[key]
var listeners []gwapiv1.SectionName
if len(a.listeners) > 0 {
seen := map[gwapiv1.SectionName]struct{}{}
for _, l := range a.listeners {
if _, ok := seen[l]; !ok {
seen[l] = struct{}{}
listeners = append(listeners, l)
}
}
slices.SortFunc(listeners, cmp.Compare)
}
slices.SortFunc(a.routes, func(x, y gwapiv1.ObjectReference) int {
return cmp.Compare(string(x.Name), string(y.Name))
})
result = append(result, v1alpha2.ObservedGateway{
ObjectReference: gwapiv1.ObjectReference{
Group: gwapiv1.Group(gwapiv1.GroupName),
Kind: "Gateway",
Name: gwapiv1.ObjectName(a.gwName),
Namespace: ptr.To(gwapiv1.Namespace(a.gwNS)),
},
Listeners: listeners,
HTTPRoutes: a.routes,
})
}
return result
}