mirror of
https://github.com/hanzo-ml/kserve.git
synced 2026-10-30 20:53:49 +00:00
664 lines
26 KiB
Go
664 lines
26 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"
|
|
|
|
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"
|
|
igwapiv1alpha2 "sigs.k8s.io/gateway-api-inference-extension/apix/v1alpha2"
|
|
gwapiv1 "sigs.k8s.io/gateway-api/apis/v1"
|
|
|
|
"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"
|
|
|
|
// 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) 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
|
|
if err := r.reconcileHTTPRoutes(ctx, llmSvc); 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); err != nil {
|
|
return fmt.Errorf("failed to evaluate Inference Pool conditions: %w", err)
|
|
}
|
|
|
|
if err := r.EvaluateGatewayConditions(ctx, llmSvc); err != nil {
|
|
return fmt.Errorf("failed to evaluate gateway conditions: %w", err)
|
|
}
|
|
|
|
if err := r.EvaluateHTTPRouteConditions(ctx, llmSvc); 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)
|
|
func (r *LLMISVCReconciler) reconcileHTTPRoutes(ctx context.Context, llmSvc *v1alpha2.LLMInferenceService) error {
|
|
logger := log.FromContext(ctx)
|
|
logger.Info("Reconciling HTTPRoute")
|
|
|
|
expectedHTTPRoute := r.expectedHTTPRoute(ctx, llmSvc)
|
|
|
|
// Clean up if router or routes are not configured
|
|
if utils.GetForceStopRuntime(llmSvc) || llmSvc.Spec.Router == nil || llmSvc.Spec.Router.Route == nil {
|
|
_ = r.updateRoutingStatus(ctx, llmSvc)
|
|
return Delete(ctx, r, llmSvc, expectedHTTPRoute)
|
|
}
|
|
|
|
// Collect any explicitly referenced HTTPRoutes
|
|
referencedRoutes, err := r.collectReferencedRoutes(ctx, llmSvc)
|
|
if err != nil {
|
|
return 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 err
|
|
}
|
|
}
|
|
|
|
if route.HTTP.HasSpec() {
|
|
if err := Reconcile(ctx, r, llmSvc, &gwapiv1.HTTPRoute{}, expectedHTTPRoute, semanticHTTPRouteIsEqual); err != nil {
|
|
return 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) *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.Spec != nil {
|
|
httpRoute.Spec = *llmSvc.Spec.Router.Route.HTTP.Spec.DeepCopy()
|
|
}
|
|
|
|
// 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) error {
|
|
logger := log.FromContext(ctx)
|
|
|
|
if utils.GetForceStopRuntime(llmSvc) {
|
|
llmSvc.Status.Addresses = nil
|
|
llmSvc.Status.Address = nil
|
|
llmSvc.MarkHTTPRoutesNotReady("Stopped", "Service is stopped")
|
|
return nil
|
|
}
|
|
|
|
if llmSvc.Spec.Router == nil || llmSvc.Spec.Router.Route == nil {
|
|
llmSvc.Status.Addresses = []duckv1.Addressable{{
|
|
URL: apis.HTTPS(network.GetServiceHostname(
|
|
kmeta.ChildName(llmSvc.GetName(), "-kserve-workload-svc"),
|
|
llmSvc.GetNamespace(),
|
|
)),
|
|
}}
|
|
return nil
|
|
}
|
|
|
|
// TODO: LoadConfig fetches the configmap from the API server on every
|
|
// reconciliation. Consider caching with an informer-based watch to reduce
|
|
// API server pressure under high reconciliation load.
|
|
cfg, err := LoadConfig(ctx, r.Clientset)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to load config: %w", err)
|
|
}
|
|
|
|
var urls []*apis.URL
|
|
for _, route := range routes {
|
|
discoverURL, err := DiscoverURLs(ctx, r.Client, route, cfg.UrlScheme)
|
|
if IgnoreNoURLsDiscovered(err) != nil {
|
|
return fmt.Errorf("failed to discover URL for route %s/%s: %w", route.GetNamespace(), route.GetName(), err)
|
|
}
|
|
if discoverURL != nil {
|
|
urls = append(urls, discoverURL...)
|
|
}
|
|
}
|
|
|
|
slices.SortStableFunc(urls, func(a, b *apis.URL) int {
|
|
return cmp.Compare(a.String(), b.String())
|
|
})
|
|
|
|
externalURLs := FilterExternalURLs(urls)
|
|
if len(externalURLs) == 0 {
|
|
logger.Info("no public URL discovered")
|
|
if len(urls) > 0 {
|
|
// Promote first address to top-level status.URL as some "cluster external" addresses are technically within
|
|
// "virtual private networks" and we cannot detect that from just IPs. Even if it's a cluster-local URL, the
|
|
// status URL is just for discovery and easy access, and it's not a problem to have here while we prioritize
|
|
// external addresses.
|
|
llmSvc.Status.URL = urls[0]
|
|
} else {
|
|
llmSvc.Status.URL = nil
|
|
}
|
|
} else {
|
|
llmSvc.Status.URL = externalURLs[0]
|
|
}
|
|
|
|
llmSvc.Status.Addresses = make([]duckv1.Addressable, 0, len(urls))
|
|
for _, url := range urls {
|
|
addressType := AddressTypeName(url)
|
|
llmSvc.Status.Addresses = append(llmSvc.Status.Addresses, duckv1.Addressable{
|
|
Name: &addressType,
|
|
URL: url,
|
|
})
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
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
|
|
func (r *LLMISVCReconciler) EvaluateGatewayConditions(ctx context.Context, llmSvc *v1alpha2.LLMInferenceService) 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, err := r.CollectReferencedGateways(ctx, llmSvc)
|
|
if err != nil {
|
|
llmSvc.MarkGatewaysNotReady("GatewayFetchError", "Failed to fetch referenced Gateways: %v", err.Error())
|
|
return fmt.Errorf("failed to fetch referenced gateways: %w", err)
|
|
}
|
|
|
|
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) 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)
|
|
// 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) 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
|
|
}
|
|
|
|
// 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
|
|
}
|
|
return r.evaluateSingleInferencePoolCondition(ctx, llmSvc, curr)
|
|
}
|
|
|
|
// 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
|
|
v1Pool := &igwapi.InferencePool{}
|
|
v1Err := r.Get(ctx, types.NamespacedName{Namespace: poolNamespace, Name: poolName}, v1Pool)
|
|
v1Ready := v1Err == nil && IsInferencePoolReady(v1Pool)
|
|
|
|
// 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 && IsInferencePoolV1Alpha2Ready(v1alpha2Pool)
|
|
|
|
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.evaluateSingleInferencePoolCondition(ctx, llmSvc, v1Pool)
|
|
}
|
|
|
|
if v1alpha2Err == nil {
|
|
if len(v1alpha2Pool.Status.Parents) == 0 {
|
|
llmSvc.MarkInferencePoolNotReady("WaitingForGateway",
|
|
"Inference Pool %s/%s exists but no Gateway controller has accepted it yet", poolNamespace, poolName)
|
|
return nil
|
|
}
|
|
topLevelCondition, _ := nonReadyInferencePoolV1Alpha2TopLevelCondition(v1alpha2Pool)
|
|
if topLevelCondition != nil {
|
|
llmSvc.MarkInferencePoolNotReady("InferencePoolNotReady", fmt.Sprintf(
|
|
"%s/%s: %v=%#v (reason %q, message %q)",
|
|
poolNamespace,
|
|
poolName,
|
|
topLevelCondition.Type,
|
|
topLevelCondition.Status,
|
|
topLevelCondition.Reason,
|
|
topLevelCondition.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
|
|
}
|
|
|
|
// evaluateSingleInferencePoolCondition evaluates a single v1 InferencePool and updates the condition.
|
|
func (r *LLMISVCReconciler) evaluateSingleInferencePoolCondition(ctx context.Context, llmSvc *v1alpha2.LLMInferenceService, curr *igwapi.InferencePool) error {
|
|
if !IsInferencePoolReady(curr) {
|
|
if len(curr.Status.Parents) == 0 {
|
|
// Pool exists but no Gateway controller has claimed it yet.
|
|
// The Owns() watch will trigger re-reconciliation when the Gateway updates the pool's status.
|
|
llmSvc.MarkInferencePoolNotReady("WaitingForGateway",
|
|
"Inference Pool %s/%s exists but no Gateway controller has accepted it yet", curr.Namespace, curr.Name)
|
|
return nil
|
|
}
|
|
topLevelCondition, _ := nonReadyInferencePoolTopLevelCondition(curr)
|
|
if topLevelCondition != nil {
|
|
llmSvc.MarkInferencePoolNotReady("InferencePoolNotReady", fmt.Sprintf(
|
|
"%s/%s: %v=%#v (reason %q, message %q)",
|
|
curr.Namespace,
|
|
curr.Name,
|
|
topLevelCondition.Type,
|
|
topLevelCondition.Status,
|
|
topLevelCondition.Reason,
|
|
topLevelCondition.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
|
|
}
|