mirror of
https://github.com/hanzo-ml/kserve.git
synced 2026-10-30 20:53:49 +00:00
882 lines
32 KiB
Go
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
|
|
}
|