diff --git a/.gitignore b/.gitignore index 103eb81442..22bf38775f 100644 --- a/.gitignore +++ b/.gitignore @@ -36,3 +36,6 @@ site/yarn-debug.log* site/yarn-error.log* site/static/.DS_Store site/temp + +# auto generated conformance report +inference-extension-conformance-test-report.yaml diff --git a/internal/controller/controller.go b/internal/controller/controller.go index 351e84b440..cb07a963fb 100644 --- a/internal/controller/controller.go +++ b/internal/controller/controller.go @@ -13,6 +13,8 @@ import ( "github.com/go-logr/logr" corev1 "k8s.io/api/core/v1" apiextensionsv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1" + apiextensionsclientset "k8s.io/apiextensions-apiserver/pkg/client/clientset/clientset" + apierrors "k8s.io/apimachinery/pkg/api/errors" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/runtime" utilruntime "k8s.io/apimachinery/pkg/util/runtime" @@ -137,6 +139,32 @@ func StartControllers(ctx context.Context, mgr manager.Manager, config *rest.Con return fmt.Errorf("failed to create controller for BackendSecurityPolicy: %w", err) } + // Check if InferencePool CRD exists before creating the controller. + crdClient, err := apiextensionsclientset.NewForConfig(config) + if err != nil { + return fmt.Errorf("failed to create CRD client for inference extension: %w", err) + } + const inferencePoolCRD = "inferencepools.inference.networking.x-k8s.io" + if _, crdErr := crdClient.ApiextensionsV1().CustomResourceDefinitions().Get(ctx, inferencePoolCRD, metav1.GetOptions{}); crdErr != nil { + if apierrors.IsNotFound(crdErr) { + logger.Info("InferencePool CRD not found, skipping InferencePool controller. " + + "If you need it, please install the Gateway API Inference Extension CRDs.") + } else { + return fmt.Errorf("failed to query InferencePool CRD: %w", crdErr) + } + } else { + // CRD exists, create the controller. + inferencePoolC := NewInferencePoolController(c, kubernetes.NewForConfigOrDie(config), logger. + WithName("inference-pool")) + if err = TypedControllerBuilderForCRD(mgr, &gwaiev1a2.InferencePool{}). + Watches(&gwapiv1.Gateway{}, inferencePoolC.gatewayEventHandler()). + Watches(&aigv1a1.AIGatewayRoute{}, inferencePoolC.aiGatewayRouteEventHandler()). + Watches(&gwapiv1.HTTPRoute{}, inferencePoolC.httpRouteEventHandler()). + Complete(inferencePoolC); err != nil { + return fmt.Errorf("failed to create controller for InferencePool: %w", err) + } + } + secretC := NewSecretController(c, kubernetes.NewForConfigOrDie(config), logger. WithName("secret"), backendSecurityPolicyEventChan) // Do not use TypedControllerBuilderForCRD for secret, as changing a secret content doesn't change the generation. diff --git a/internal/controller/inference_pool.go b/internal/controller/inference_pool.go new file mode 100644 index 0000000000..763d12b206 --- /dev/null +++ b/internal/controller/inference_pool.go @@ -0,0 +1,411 @@ +// Copyright Envoy AI Gateway Authors +// SPDX-License-Identifier: Apache-2.0 +// The full text of the Apache license is available in the LICENSE file at +// the root of the repo. + +package controller + +import ( + "context" + "fmt" + "strings" + + "github.com/go-logr/logr" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/client-go/kubernetes" + ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/handler" + "sigs.k8s.io/controller-runtime/pkg/reconcile" + gwaiev1a2 "sigs.k8s.io/gateway-api-inference-extension/api/v1alpha2" + gwapiv1 "sigs.k8s.io/gateway-api/apis/v1" + + aigv1a1 "github.com/envoyproxy/ai-gateway/api/v1alpha1" +) + +// InferencePoolController implements [reconcile.TypedReconciler] for [gwaiev1a2.InferencePool]. +// +// This handles the InferencePool resource and updates its status based on associated Gateways. +// +// Exported for testing purposes. +type InferencePoolController struct { + client client.Client + kube kubernetes.Interface + logger logr.Logger +} + +// NewInferencePoolController creates a new reconcile.TypedReconciler for gwaiev1a2.InferencePool. +func NewInferencePoolController( + client client.Client, kube kubernetes.Interface, logger logr.Logger, +) *InferencePoolController { + return &InferencePoolController{ + client: client, + kube: kube, + logger: logger, + } +} + +// Reconcile implements the [reconcile.TypedReconciler] for [gwaiev1a2.InferencePool]. +func (c *InferencePoolController) Reconcile(ctx context.Context, req reconcile.Request) (reconcile.Result, error) { + var inferencePool gwaiev1a2.InferencePool + if err := c.client.Get(ctx, req.NamespacedName, &inferencePool); err != nil { + if client.IgnoreNotFound(err) == nil { + c.logger.Info("Deleting InferencePool", + "namespace", req.Namespace, "name", req.Name) + return ctrl.Result{}, nil + } + return ctrl.Result{}, err + } + + c.logger.Info("Reconciling InferencePool", "namespace", req.Namespace, "name", req.Name) + if err := c.syncInferencePool(ctx, &inferencePool); err != nil { + c.logger.Error(err, "failed to sync InferencePool") + c.updateInferencePoolStatus(ctx, &inferencePool, "NotAccepted", err.Error()) + return ctrl.Result{}, err + } + c.updateInferencePoolStatus(ctx, &inferencePool, "Accepted", "InferencePool reconciled successfully") + return ctrl.Result{}, nil +} + +// syncInferencePool is the main logic for reconciling the InferencePool resource. +// This is decoupled from the Reconcile method to centralize the error handling and status updates. +func (c *InferencePoolController) syncInferencePool(ctx context.Context, inferencePool *gwaiev1a2.InferencePool) error { + // Check if the ExtensionReference service exists. + if err := c.validateExtensionReference(ctx, inferencePool); err != nil { + return err + } + + referencedGateways, err := c.getReferencedGateways(ctx, inferencePool) + if err != nil { + return err + } + + c.logger.Info("Found referenced Gateways", "count", len(referencedGateways), "inferencePool", inferencePool.Name) + return nil +} + +// routeReferencesInferencePool checks if an AIGatewayRoute references the given InferencePool. +func (c *InferencePoolController) routeReferencesInferencePool(route *aigv1a1.AIGatewayRoute, inferencePoolName string) bool { + for _, rule := range route.Spec.Rules { + for _, backendRef := range rule.BackendRefs { + if backendRef.IsInferencePool() && backendRef.Name == inferencePoolName { + return true + } + } + } + return false +} + +// getReferencedGateways returns all Gateways that reference the given InferencePool. +func (c *InferencePoolController) getReferencedGateways(ctx context.Context, inferencePool *gwaiev1a2.InferencePool) (map[string]*gwapiv1.Gateway, error) { + // Find all Gateways across all namespaces. + var gateways gwapiv1.GatewayList + if err := c.client.List(ctx, &gateways); err != nil { + return nil, fmt.Errorf("failed to list Gateways: %w", err) + } + + referencedGateways := make(map[string]*gwapiv1.Gateway) + + // Check each Gateway to see if it references this InferencePool through routes. + for i := range gateways.Items { + gw := &gateways.Items[i] + if c.gatewayReferencesInferencePool(ctx, gw, inferencePool.Name, inferencePool.Namespace) { + gatewayKey := fmt.Sprintf("%s/%s", gw.Namespace, gw.Name) + referencedGateways[gatewayKey] = gw + } + } + + return referencedGateways, nil +} + +// validateExtensionReference checks if the ExtensionReference service exists. +func (c *InferencePoolController) validateExtensionReference(ctx context.Context, inferencePool *gwaiev1a2.InferencePool) error { + // Check if ExtensionRef is specified. + if inferencePool.Spec.ExtensionRef == nil { + return nil // No extension reference to validate. + } + + // Get the service name from ExtensionReference. + serviceName := inferencePool.Spec.ExtensionRef.Name + if serviceName == "" { + return fmt.Errorf("ExtensionReference name is empty") + } + + // Check if the service exists. + var service corev1.Service + if err := c.client.Get(ctx, client.ObjectKey{ + Name: string(serviceName), + Namespace: inferencePool.Namespace, + }, &service); err != nil { + if client.IgnoreNotFound(err) == nil { + // Service not found - this is the error case we want to handle. + return fmt.Errorf("ExtensionReference service %s not found in namespace %s", serviceName, inferencePool.Namespace) + } + // Other error occurred. + return fmt.Errorf("failed to get ExtensionReference service %s: %w", serviceName, err) + } + + // Service exists - validation passed. + return nil +} + +// gatewayReferencesInferencePool checks if a Gateway references the given InferencePool through any routes. +func (c *InferencePoolController) gatewayReferencesInferencePool(ctx context.Context, gateway *gwapiv1.Gateway, inferencePoolName string, inferencePoolNamespace string) bool { + // Check AIGatewayRoutes in the same namespace as the InferencePool that reference this Gateway. + var aiGatewayRoutes aigv1a1.AIGatewayRouteList + if err := c.client.List(ctx, &aiGatewayRoutes, client.InNamespace(inferencePoolNamespace)); err != nil { + c.logger.Error(err, "failed to list AIGatewayRoutes", "gateway", gateway.Name, "namespace", inferencePoolNamespace) + return false + } + + for i := range aiGatewayRoutes.Items { + route := &aiGatewayRoutes.Items[i] + // Check if this route references the Gateway. + if c.routeReferencesGateway(route.Spec.ParentRefs, gateway.Name, gateway.Namespace, route.Namespace) { + // Check if this route references the InferencePool. + if c.routeReferencesInferencePool(route, inferencePoolName) { + return true + } + } + } + + // Check HTTPRoutes in the same namespace as the InferencePool that reference this Gateway. + var httpRoutes gwapiv1.HTTPRouteList + if err := c.client.List(ctx, &httpRoutes, client.InNamespace(inferencePoolNamespace)); err != nil { + c.logger.Error(err, "failed to list HTTPRoutes", "gateway", gateway.Name, "namespace", inferencePoolNamespace) + return false + } + + for i := range httpRoutes.Items { + route := &httpRoutes.Items[i] + // Check if this route references the Gateway. + if c.routeReferencesGateway(route.Spec.ParentRefs, gateway.Name, gateway.Namespace, route.Namespace) { + // Check if this route references the InferencePool. + if c.httpRouteReferencesInferencePool(route, inferencePoolName) { + return true + } + } + } + + return false +} + +// routeReferencesGateway checks if a route references the given Gateway. +func (c *InferencePoolController) routeReferencesGateway(parentRefs []gwapiv1.ParentReference, gatewayName string, gatewayNamespace string, routeNamespace string) bool { + for _, parentRef := range parentRefs { + // Check if the name matches. + if string(parentRef.Name) != gatewayName { + continue + } + + // Check namespace - if not specified in parentRef, it defaults to the route's namespace. + if parentRef.Namespace != nil { + if string(*parentRef.Namespace) == gatewayNamespace { + return true + } + } else { + // If namespace is not specified, it means same namespace as the route. + // Check if the route's namespace matches the gateway's namespace. + if routeNamespace == gatewayNamespace { + return true + } + } + } + return false +} + +// httpRouteReferencesInferencePool checks if an HTTPRoute references the given InferencePool. +func (c *InferencePoolController) httpRouteReferencesInferencePool(route *gwapiv1.HTTPRoute, inferencePoolName string) bool { + for _, rule := range route.Spec.Rules { + for _, backendRef := range rule.BackendRefs { + if backendRef.Group != nil && string(*backendRef.Group) == "inference.networking.x-k8s.io" && + backendRef.Kind != nil && string(*backendRef.Kind) == "InferencePool" && + string(backendRef.Name) == inferencePoolName { + return true + } + } + } + return false +} + +// updateInferencePoolStatus updates the status of the InferencePool. +func (c *InferencePoolController) updateInferencePoolStatus(ctx context.Context, inferencePool *gwaiev1a2.InferencePool, conditionType string, message string) { + // Check if this is an ExtensionReference validation error. + isExtensionRefError := conditionType == "NotAccepted" && + (strings.Contains(message, "ExtensionReference service") && strings.Contains(message, "not found")) + // Get the referenced Gateways from syncInferencePool logic. + referencedGateways, err := c.getReferencedGateways(ctx, inferencePool) + if err != nil { + c.logger.Error(err, "failed to get referenced Gateways for status update") + return + } + + // Build Parents status. + var parents []gwaiev1a2.PoolStatus + for _, gw := range referencedGateways { + // Set Gateway group and kind according to Gateway API defaults. + gatewayGroup := "gateway.networking.k8s.io" + gatewayKind := "Gateway" + + parentRef := gwaiev1a2.ParentGatewayReference{ + Group: (*gwaiev1a2.Group)(&gatewayGroup), + Kind: (*gwaiev1a2.Kind)(&gatewayKind), + Name: gwaiev1a2.ObjectName(gw.Name), + Namespace: (*gwaiev1a2.Namespace)(&gw.Namespace), + } + + var conditions []metav1.Condition + + // Add the main condition (Accepted/NotAccepted). + condition := buildAcceptedCondition(inferencePool.Generation, "ai-gateway-controller", conditionType, message) + conditions = append(conditions, condition) + + // Add ResolvedRefs condition based on validation results. + if isExtensionRefError { + resolvedRefsCondition := buildResolvedRefsCondition(inferencePool.Generation, "ai-gateway-controller", false, "ResolvedRefs", message) + conditions = append(conditions, resolvedRefsCondition) + } else { + // Add successful ResolvedRefs condition. + resolvedRefsCondition := buildResolvedRefsCondition(inferencePool.Generation, "ai-gateway-controller", true, "ResolvedRefs", "All references resolved successfully") + conditions = append(conditions, resolvedRefsCondition) + } + + parents = append(parents, gwaiev1a2.PoolStatus{ + GatewayRef: parentRef, + Conditions: conditions, + }) + } + + // If no Gateways reference this InferencePool, clear all parents. + // This correctly reflects that the InferencePool is not currently referenced by any Gateway. + + inferencePool.Status.Parents = parents + if err := c.client.Status().Update(ctx, inferencePool); err != nil { + c.logger.Error(err, "failed to update InferencePool status") + } +} + +// buildAcceptedCondition builds a condition for the InferencePool status. +func buildAcceptedCondition(gen int64, controllerName string, conditionType string, message string) metav1.Condition { + status := metav1.ConditionTrue + reason := "Accepted" + if conditionType == "NotAccepted" { + status = metav1.ConditionFalse + reason = "NotAccepted" + conditionType = "Accepted" + } + + return metav1.Condition{ + Type: conditionType, + Status: status, + Reason: reason, + Message: fmt.Sprintf("InferencePool has been %s by controller %s: %s", reason, controllerName, message), + ObservedGeneration: gen, + LastTransitionTime: metav1.Now(), + } +} + +// gatewayEventHandler returns an event handler for Gateway resources. +func (c *InferencePoolController) gatewayEventHandler() handler.EventHandler { + return handler.EnqueueRequestsFromMapFunc(func(ctx context.Context, obj client.Object) []reconcile.Request { + gateway, ok := obj.(*gwapiv1.Gateway) + if !ok { + return nil + } + + // Find all InferencePools in the same namespace that might be affected by this Gateway. + var inferencePools gwaiev1a2.InferencePoolList + if err := c.client.List(ctx, &inferencePools, client.InNamespace(gateway.Namespace)); err != nil { + c.logger.Error(err, "failed to list InferencePools for Gateway event", "gateway", gateway.Name) + return nil + } + + var requests []reconcile.Request + for _, pool := range inferencePools.Items { + // Check if this Gateway references the InferencePool. + if c.gatewayReferencesInferencePool(ctx, gateway, pool.Name, pool.Namespace) { + requests = append(requests, reconcile.Request{ + NamespacedName: client.ObjectKey{ + Name: pool.Name, + Namespace: pool.Namespace, + }, + }) + } + } + + return requests + }) +} + +// aiGatewayRouteEventHandler returns an event handler for AIGatewayRoute resources. +func (c *InferencePoolController) aiGatewayRouteEventHandler() handler.EventHandler { + return handler.EnqueueRequestsFromMapFunc(func(_ context.Context, obj client.Object) []reconcile.Request { + route, ok := obj.(*aigv1a1.AIGatewayRoute) + if !ok { + return nil + } + + // Find all InferencePools referenced by this AIGatewayRoute. + var requests []reconcile.Request + for _, rule := range route.Spec.Rules { + for _, backendRef := range rule.BackendRefs { + if backendRef.IsInferencePool() { + requests = append(requests, reconcile.Request{ + NamespacedName: client.ObjectKey{ + Name: backendRef.Name, + Namespace: route.Namespace, + }, + }) + } + } + } + + return requests + }) +} + +// httpRouteEventHandler returns an event handler for HTTPRoute resources. +func (c *InferencePoolController) httpRouteEventHandler() handler.EventHandler { + return handler.EnqueueRequestsFromMapFunc(func(_ context.Context, obj client.Object) []reconcile.Request { + route, ok := obj.(*gwapiv1.HTTPRoute) + if !ok { + return nil + } + + // Find all InferencePools referenced by this HTTPRoute. + var requests []reconcile.Request + for _, rule := range route.Spec.Rules { + for _, backendRef := range rule.BackendRefs { + if backendRef.Group != nil && string(*backendRef.Group) == "inference.networking.x-k8s.io" && + backendRef.Kind != nil && string(*backendRef.Kind) == "InferencePool" { + requests = append(requests, reconcile.Request{ + NamespacedName: client.ObjectKey{ + Name: string(backendRef.Name), + Namespace: route.Namespace, + }, + }) + } + } + } + + return requests + }) +} + +// buildResolvedRefsCondition builds a ResolvedRefs condition for the InferencePool status. +func buildResolvedRefsCondition(gen int64, controllerName string, resolved bool, reason string, message string) metav1.Condition { + status := metav1.ConditionTrue + if !resolved { + status = metav1.ConditionFalse + } + + return metav1.Condition{ + Type: "ResolvedRefs", + Status: status, + Reason: reason, + Message: fmt.Sprintf("Reference resolution by controller %s: %s", controllerName, message), + ObservedGeneration: gen, + LastTransitionTime: metav1.Now(), + } +} diff --git a/internal/controller/inference_pool_test.go b/internal/controller/inference_pool_test.go new file mode 100644 index 0000000000..a295d17f2c --- /dev/null +++ b/internal/controller/inference_pool_test.go @@ -0,0 +1,1768 @@ +// Copyright Envoy AI Gateway Authors +// SPDX-License-Identifier: Apache-2.0 +// The full text of the Apache license is available in the LICENSE file at +// the root of the repo. + +package controller + +import ( + "context" + "testing" + + "github.com/stretchr/testify/require" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + kubefake "k8s.io/client-go/kubernetes/fake" + "k8s.io/utils/ptr" + ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/client/fake" + gwaiev1a2 "sigs.k8s.io/gateway-api-inference-extension/api/v1alpha2" + gwapiv1 "sigs.k8s.io/gateway-api/apis/v1" + + aigv1a1 "github.com/envoyproxy/ai-gateway/api/v1alpha1" +) + +func requireNewFakeClientWithIndexesAndInferencePool(t *testing.T) client.Client { + builder := fake.NewClientBuilder().WithScheme(Scheme). + WithStatusSubresource(&aigv1a1.AIGatewayRoute{}). + WithStatusSubresource(&aigv1a1.AIServiceBackend{}). + WithStatusSubresource(&aigv1a1.BackendSecurityPolicy{}). + WithStatusSubresource(&gwaiev1a2.InferencePool{}) + err := ApplyIndexing(t.Context(), func(_ context.Context, obj client.Object, field string, extractValue client.IndexerFunc) error { + builder = builder.WithIndex(obj, field, extractValue) + return nil + }) + require.NoError(t, err) + return builder.Build() +} + +func TestInferencePoolController_ExtensionReferenceValidation(t *testing.T) { + fakeClient := requireNewFakeClientWithIndexesAndInferencePool(t) + c := NewInferencePoolController(fakeClient, kubefake.NewSimpleClientset(), ctrl.Log) + + // Create an InferencePool with ExtensionReference pointing to a non-existent service. + inferencePool := &gwaiev1a2.InferencePool{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-inference-pool", + Namespace: "default", + }, + Spec: gwaiev1a2.InferencePoolSpec{ + Selector: map[gwaiev1a2.LabelKey]gwaiev1a2.LabelValue{ + "app": "test-app", + }, + TargetPortNumber: 8080, + EndpointPickerConfig: gwaiev1a2.EndpointPickerConfig{ + ExtensionRef: &gwaiev1a2.Extension{ + ExtensionReference: gwaiev1a2.ExtensionReference{ + Name: "non-existent-service", + }, + }, + }, + }, + } + require.NoError(t, fakeClient.Create(context.Background(), inferencePool)) + + // Reconcile the InferencePool. + result, err := c.Reconcile(context.Background(), ctrl.Request{ + NamespacedName: client.ObjectKey{ + Name: "test-inference-pool", + Namespace: "default", + }, + }) + require.Error(t, err, "Expected error due to non-existent ExtensionReference service") + require.Contains(t, err.Error(), "ExtensionReference service non-existent-service not found") + require.Equal(t, ctrl.Result{}, result) + + // Check that the InferencePool status was updated with ResolvedRefs condition. + var updatedInferencePool gwaiev1a2.InferencePool + require.NoError(t, fakeClient.Get(context.Background(), client.ObjectKey{ + Name: "test-inference-pool", + Namespace: "default", + }, &updatedInferencePool)) + + // Since there are no Gateways referencing this InferencePool, the status should be empty. + // But the error should have been handled and the status updated appropriately. + require.Empty(t, updatedInferencePool.Status.Parents, "InferencePool should have no parent status when not referenced by any Gateway") +} + +func TestInferencePoolController_ExtensionReferenceValidationSuccess(t *testing.T) { + fakeClient := requireNewFakeClientWithIndexesAndInferencePool(t) + c := NewInferencePoolController(fakeClient, kubefake.NewSimpleClientset(), ctrl.Log) + + // Create the service that the InferencePool will reference. + service := &corev1.Service{ + ObjectMeta: metav1.ObjectMeta{ + Name: "existing-service", + Namespace: "default", + }, + Spec: corev1.ServiceSpec{ + Ports: []corev1.ServicePort{ + { + Port: 9002, + }, + }, + }, + } + require.NoError(t, fakeClient.Create(context.Background(), service)) + + // Create an InferencePool with ExtensionReference pointing to the existing service. + inferencePool := &gwaiev1a2.InferencePool{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-inference-pool", + Namespace: "default", + }, + Spec: gwaiev1a2.InferencePoolSpec{ + Selector: map[gwaiev1a2.LabelKey]gwaiev1a2.LabelValue{ + "app": "test-app", + }, + TargetPortNumber: 8080, + EndpointPickerConfig: gwaiev1a2.EndpointPickerConfig{ + ExtensionRef: &gwaiev1a2.Extension{ + ExtensionReference: gwaiev1a2.ExtensionReference{ + Name: "existing-service", + }, + }, + }, + }, + } + require.NoError(t, fakeClient.Create(context.Background(), inferencePool)) + + // Reconcile the InferencePool. + result, err := c.Reconcile(context.Background(), ctrl.Request{ + NamespacedName: client.ObjectKey{ + Name: "test-inference-pool", + Namespace: "default", + }, + }) + require.NoError(t, err, "Expected no error when ExtensionReference service exists") + require.Equal(t, ctrl.Result{}, result) + + // Check that the InferencePool status was updated successfully. + var updatedInferencePool gwaiev1a2.InferencePool + require.NoError(t, fakeClient.Get(context.Background(), client.ObjectKey{ + Name: "test-inference-pool", + Namespace: "default", + }, &updatedInferencePool)) + + // Since there are no Gateways referencing this InferencePool, the status should be empty. + require.Empty(t, updatedInferencePool.Status.Parents, "InferencePool should have no parent status when not referenced by any Gateway") +} + +func TestInferencePoolController_Reconcile(t *testing.T) { + fakeClient := requireNewFakeClientWithIndexesAndInferencePool(t) + c := NewInferencePoolController(fakeClient, kubefake.NewSimpleClientset(), ctrl.Log) + + // Create the service that the InferencePool will reference. + service := &corev1.Service{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-epp", + Namespace: "default", + }, + Spec: corev1.ServiceSpec{ + Ports: []corev1.ServicePort{ + { + Port: 9002, + }, + }, + }, + } + require.NoError(t, fakeClient.Create(context.Background(), service)) + + // Create a Gateway. + gateway := &gwapiv1.Gateway{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-gateway", + Namespace: "default", + }, + Spec: gwapiv1.GatewaySpec{ + GatewayClassName: "test-class", + }, + } + require.NoError(t, fakeClient.Create(context.Background(), gateway)) + + // Create an AIGatewayRoute that references an InferencePool. + aiGatewayRoute := &aigv1a1.AIGatewayRoute{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-route", + Namespace: "default", + }, + Spec: aigv1a1.AIGatewayRouteSpec{ + ParentRefs: []gwapiv1.ParentReference{ + { + Name: "test-gateway", + }, + }, + Rules: []aigv1a1.AIGatewayRouteRule{ + { + BackendRefs: []aigv1a1.AIGatewayRouteRuleBackendRef{ + { + Name: "test-inference-pool", + Group: ptr.To("inference.networking.x-k8s.io"), + Kind: ptr.To("InferencePool"), + Weight: ptr.To(int32(100)), + }, + }, + }, + }, + }, + } + require.NoError(t, fakeClient.Create(context.Background(), aiGatewayRoute)) + + // Create an InferencePool. + inferencePool := &gwaiev1a2.InferencePool{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-inference-pool", + Namespace: "default", + }, + Spec: gwaiev1a2.InferencePoolSpec{ + Selector: map[gwaiev1a2.LabelKey]gwaiev1a2.LabelValue{ + "app": "test-app", + }, + TargetPortNumber: 8080, + EndpointPickerConfig: gwaiev1a2.EndpointPickerConfig{ + ExtensionRef: &gwaiev1a2.Extension{ + ExtensionReference: gwaiev1a2.ExtensionReference{ + Name: "test-epp", + }, + }, + }, + }, + } + require.NoError(t, fakeClient.Create(context.Background(), inferencePool)) + + // Reconcile the InferencePool. + result, err := c.Reconcile(context.Background(), ctrl.Request{ + NamespacedName: client.ObjectKey{ + Name: "test-inference-pool", + Namespace: "default", + }, + }) + require.NoError(t, err) + require.Equal(t, ctrl.Result{}, result) + + // Check that the InferencePool status was updated. + var updatedInferencePool gwaiev1a2.InferencePool + require.NoError(t, fakeClient.Get(context.Background(), client.ObjectKey{ + Name: "test-inference-pool", + Namespace: "default", + }, &updatedInferencePool)) + + // Verify that the status contains the expected parent Gateway. + require.Len(t, updatedInferencePool.Status.Parents, 1) + parent := updatedInferencePool.Status.Parents[0] + + require.Equal(t, "gateway.networking.k8s.io", string(*parent.GatewayRef.Group)) + require.Equal(t, "Gateway", string(*parent.GatewayRef.Kind)) + require.Equal(t, "test-gateway", string(parent.GatewayRef.Name)) + require.Equal(t, "default", string(*parent.GatewayRef.Namespace)) + + // Verify that the conditions are set correctly. + require.Len(t, parent.Conditions, 2, "Should have both Accepted and ResolvedRefs conditions") + + // Find and verify the Accepted condition. + var acceptedCondition *metav1.Condition + var resolvedRefsCondition *metav1.Condition + for i := range parent.Conditions { + condition := &parent.Conditions[i] + if condition.Type == "Accepted" { + acceptedCondition = condition + } else if condition.Type == "ResolvedRefs" { + resolvedRefsCondition = condition + } + } + + require.NotNil(t, acceptedCondition, "Should have Accepted condition") + require.Equal(t, metav1.ConditionTrue, acceptedCondition.Status) + require.Equal(t, "Accepted", acceptedCondition.Reason) + + require.NotNil(t, resolvedRefsCondition, "Should have ResolvedRefs condition") + require.Equal(t, metav1.ConditionTrue, resolvedRefsCondition.Status) + require.Equal(t, "ResolvedRefs", resolvedRefsCondition.Reason) +} + +func TestInferencePoolController_NoReferencingGateways(t *testing.T) { + fakeClient := requireNewFakeClientWithIndexesAndInferencePool(t) + c := NewInferencePoolController(fakeClient, kubefake.NewSimpleClientset(), ctrl.Log) + + // Create the service that the InferencePool will reference. + service := &corev1.Service{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-epp", + Namespace: "default", + }, + Spec: corev1.ServiceSpec{ + Ports: []corev1.ServicePort{ + { + Port: 9002, + }, + }, + }, + } + require.NoError(t, fakeClient.Create(context.Background(), service)) + + // Create an InferencePool without any referencing AIGatewayRoutes. + inferencePool := &gwaiev1a2.InferencePool{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-inference-pool", + Namespace: "default", + }, + Spec: gwaiev1a2.InferencePoolSpec{ + Selector: map[gwaiev1a2.LabelKey]gwaiev1a2.LabelValue{ + "app": "test-app", + }, + TargetPortNumber: 8080, + EndpointPickerConfig: gwaiev1a2.EndpointPickerConfig{ + ExtensionRef: &gwaiev1a2.Extension{ + ExtensionReference: gwaiev1a2.ExtensionReference{ + Name: "test-epp", + }, + }, + }, + }, + } + require.NoError(t, fakeClient.Create(context.Background(), inferencePool)) + + // Reconcile the InferencePool. + result, err := c.Reconcile(context.Background(), ctrl.Request{ + NamespacedName: client.ObjectKey{ + Name: "test-inference-pool", + Namespace: "default", + }, + }) + require.NoError(t, err) + require.Equal(t, ctrl.Result{}, result) + + // Check that the InferencePool status was updated. + var updatedInferencePool gwaiev1a2.InferencePool + require.NoError(t, fakeClient.Get(context.Background(), client.ObjectKey{ + Name: "test-inference-pool", + Namespace: "default", + }, &updatedInferencePool)) + + // Verify that the status has no parents since no Gateway references this InferencePool. + require.Empty(t, updatedInferencePool.Status.Parents, "InferencePool should have no parent status when not referenced by any Gateway") +} + +func TestBuildAcceptedCondition(t *testing.T) { + condition := buildAcceptedCondition(1, "test-controller", "Accepted", "test message") + + require.Equal(t, "Accepted", condition.Type) + require.Equal(t, metav1.ConditionTrue, condition.Status) + require.Equal(t, "Accepted", condition.Reason) + require.Contains(t, condition.Message, "InferencePool has been Accepted by controller test-controller: test message") + require.Equal(t, int64(1), condition.ObservedGeneration) + + // Test NotAccepted condition. + condition = buildAcceptedCondition(2, "test-controller", "NotAccepted", "error message") + + require.Equal(t, "Accepted", condition.Type) + require.Equal(t, metav1.ConditionFalse, condition.Status) + require.Equal(t, "NotAccepted", condition.Reason) + require.Contains(t, condition.Message, "InferencePool has been NotAccepted by controller test-controller: error message") + require.Equal(t, int64(2), condition.ObservedGeneration) + + // Test other condition types that should result in True status and keep the original type. + condition = buildAcceptedCondition(3, "test-controller", "SomeOtherType", "other message") + + require.Equal(t, "SomeOtherType", condition.Type) // Type is preserved for non-"NotAccepted" types. + require.Equal(t, metav1.ConditionTrue, condition.Status) // Status is True for non-"NotAccepted" types. + require.Equal(t, "Accepted", condition.Reason) // Reason is "Accepted" for non-"NotAccepted" types. + require.Contains(t, condition.Message, "InferencePool has been Accepted by controller test-controller: other message") + require.Equal(t, int64(3), condition.ObservedGeneration) +} + +func TestBuildResolvedRefsCondition(t *testing.T) { + // Test successful ResolvedRefs condition. + condition := buildResolvedRefsCondition(1, "test-controller", true, "ResolvedRefs", "all references resolved") + + require.Equal(t, "ResolvedRefs", condition.Type) + require.Equal(t, metav1.ConditionTrue, condition.Status) + require.Equal(t, "ResolvedRefs", condition.Reason) + require.Contains(t, condition.Message, "Reference resolution by controller test-controller: all references resolved") + require.Equal(t, int64(1), condition.ObservedGeneration) + + // Test failed ResolvedRefs condition. + condition = buildResolvedRefsCondition(2, "test-controller", false, "BackendNotFound", "service not found") + + require.Equal(t, "ResolvedRefs", condition.Type) + require.Equal(t, metav1.ConditionFalse, condition.Status) + require.Equal(t, "BackendNotFound", condition.Reason) + require.Contains(t, condition.Message, "Reference resolution by controller test-controller: service not found") + require.Equal(t, int64(2), condition.ObservedGeneration) +} + +func TestInferencePoolController_HTTPRouteReferencesInferencePool(t *testing.T) { + fakeClient := requireNewFakeClientWithIndexesAndInferencePool(t) + c := NewInferencePoolController(fakeClient, kubefake.NewSimpleClientset(), ctrl.Log) + + // Test HTTPRoute that references InferencePool. + httpRoute := &gwapiv1.HTTPRoute{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-http-route", + Namespace: "default", + }, + Spec: gwapiv1.HTTPRouteSpec{ + Rules: []gwapiv1.HTTPRouteRule{ + { + BackendRefs: []gwapiv1.HTTPBackendRef{ + { + BackendRef: gwapiv1.BackendRef{ + BackendObjectReference: gwapiv1.BackendObjectReference{ + Group: ptr.To(gwapiv1.Group("inference.networking.x-k8s.io")), + Kind: ptr.To(gwapiv1.Kind("InferencePool")), + Name: "test-inference-pool", + }, + }, + }, + }, + }, + }, + }, + } + + // Test positive case. + result := c.httpRouteReferencesInferencePool(httpRoute, "test-inference-pool") + require.True(t, result, "Should return true when HTTPRoute references the InferencePool") + + // Test negative case - different name. + result = c.httpRouteReferencesInferencePool(httpRoute, "different-pool") + require.False(t, result, "Should return false when HTTPRoute doesn't reference the InferencePool") + + // Test HTTPRoute without InferencePool backend. + httpRouteNoInferencePool := &gwapiv1.HTTPRoute{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-http-route-no-pool", + Namespace: "default", + }, + Spec: gwapiv1.HTTPRouteSpec{ + Rules: []gwapiv1.HTTPRouteRule{ + { + BackendRefs: []gwapiv1.HTTPBackendRef{ + { + BackendRef: gwapiv1.BackendRef{ + BackendObjectReference: gwapiv1.BackendObjectReference{ + Name: "regular-service", + }, + }, + }, + }, + }, + }, + }, + } + + result = c.httpRouteReferencesInferencePool(httpRouteNoInferencePool, "test-inference-pool") + require.False(t, result, "Should return false when HTTPRoute doesn't have InferencePool backend") +} + +func TestInferencePoolController_RouteReferencesGateway(t *testing.T) { + fakeClient := requireNewFakeClientWithIndexesAndInferencePool(t) + c := NewInferencePoolController(fakeClient, kubefake.NewSimpleClientset(), ctrl.Log) + + // Test with matching gateway name and namespace. + parentRefs := []gwapiv1.ParentReference{ + { + Name: "test-gateway", + Namespace: ptr.To(gwapiv1.Namespace("test-namespace")), + }, + } + + result := c.routeReferencesGateway(parentRefs, "test-gateway", "test-namespace", "test-namespace") + require.True(t, result, "Should return true when route references the gateway with matching namespace") + + // Test with matching gateway name but different namespace. + result = c.routeReferencesGateway(parentRefs, "test-gateway", "different-namespace", "test-namespace") + require.False(t, result, "Should return false when route references the gateway with different namespace") + + // Test with different gateway name. + result = c.routeReferencesGateway(parentRefs, "different-gateway", "test-namespace", "test-namespace") + require.False(t, result, "Should return false when route references different gateway") + + // Test with nil namespace (should match any namespace). + parentRefsNoNamespace := []gwapiv1.ParentReference{ + { + Name: "test-gateway", + }, + } + + result = c.routeReferencesGateway(parentRefsNoNamespace, "test-gateway", "any-namespace", "any-namespace") + require.True(t, result, "Should return true when route references gateway without namespace specified") + + // Test with empty parent refs. + result = c.routeReferencesGateway([]gwapiv1.ParentReference{}, "test-gateway", "test-namespace", "test-namespace") + require.False(t, result, "Should return false when no parent refs") +} + +func TestInferencePoolController_GatewayReferencesInferencePool(t *testing.T) { + fakeClient := requireNewFakeClientWithIndexesAndInferencePool(t) + c := NewInferencePoolController(fakeClient, kubefake.NewSimpleClientset(), ctrl.Log) + + // Create a Gateway. + gateway := &gwapiv1.Gateway{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-gateway", + Namespace: "default", + }, + Spec: gwapiv1.GatewaySpec{ + GatewayClassName: "test-class", + }, + } + require.NoError(t, fakeClient.Create(context.Background(), gateway)) + + // Create an AIGatewayRoute that references the Gateway and InferencePool. + aiGatewayRoute := &aigv1a1.AIGatewayRoute{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-route", + Namespace: "test-namespace", + }, + Spec: aigv1a1.AIGatewayRouteSpec{ + ParentRefs: []gwapiv1.ParentReference{ + { + Name: "test-gateway", + Namespace: ptr.To(gwapiv1.Namespace("default")), + }, + }, + Rules: []aigv1a1.AIGatewayRouteRule{ + { + BackendRefs: []aigv1a1.AIGatewayRouteRuleBackendRef{ + { + Name: "test-inference-pool", + Group: ptr.To("inference.networking.x-k8s.io"), + Kind: ptr.To("InferencePool"), + }, + }, + }, + }, + }, + } + require.NoError(t, fakeClient.Create(context.Background(), aiGatewayRoute)) + + // Test positive case - Gateway references InferencePool through AIGatewayRoute. + result := c.gatewayReferencesInferencePool(context.Background(), gateway, "test-inference-pool", "test-namespace") + require.True(t, result, "Should return true when Gateway references InferencePool through AIGatewayRoute") + + // Test negative case - different InferencePool name. + result = c.gatewayReferencesInferencePool(context.Background(), gateway, "different-pool", "test-namespace") + require.False(t, result, "Should return false when Gateway doesn't reference the specified InferencePool") + + // Test negative case - different namespace. + result = c.gatewayReferencesInferencePool(context.Background(), gateway, "test-inference-pool", "different-namespace") + require.False(t, result, "Should return false when InferencePool is in different namespace") +} + +func TestInferencePoolController_GatewayEventHandler(t *testing.T) { + fakeClient := requireNewFakeClientWithIndexesAndInferencePool(t) + c := NewInferencePoolController(fakeClient, kubefake.NewSimpleClientset(), ctrl.Log) + + // Create an InferencePool. + inferencePool := &gwaiev1a2.InferencePool{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-inference-pool", + Namespace: "default", + }, + Spec: gwaiev1a2.InferencePoolSpec{ + Selector: map[gwaiev1a2.LabelKey]gwaiev1a2.LabelValue{ + "app": "test-app", + }, + TargetPortNumber: 8080, + }, + } + require.NoError(t, fakeClient.Create(context.Background(), inferencePool)) + + // Create an AIGatewayRoute that references the InferencePool. + aiGatewayRoute := &aigv1a1.AIGatewayRoute{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-route", + Namespace: "default", + }, + Spec: aigv1a1.AIGatewayRouteSpec{ + ParentRefs: []gwapiv1.ParentReference{ + { + Name: "test-gateway", + }, + }, + Rules: []aigv1a1.AIGatewayRouteRule{ + { + BackendRefs: []aigv1a1.AIGatewayRouteRuleBackendRef{ + { + Name: "test-inference-pool", + Group: ptr.To("inference.networking.x-k8s.io"), + Kind: ptr.To("InferencePool"), + }, + }, + }, + }, + }, + } + require.NoError(t, fakeClient.Create(context.Background(), aiGatewayRoute)) + + // Create a Gateway. + gateway := &gwapiv1.Gateway{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-gateway", + Namespace: "default", + }, + Spec: gwapiv1.GatewaySpec{ + GatewayClassName: "test-class", + }, + } + + // Test gateway event handler by directly calling the map function. + // We need to extract the map function from the EnqueueRequestsFromMapFunc handler. + handler := c.gatewayEventHandler() + + // Since we can't directly access the map function, we'll test the logic indirectly + // by verifying that the gateway references the inference pool. + result := c.gatewayReferencesInferencePool(context.Background(), gateway, "test-inference-pool", "default") + require.True(t, result, "Gateway should reference the InferencePool") + + // Verify the handler is not nil. + require.NotNil(t, handler, "Gateway event handler should not be nil") +} + +func TestInferencePoolController_RouteEventHandler(t *testing.T) { + fakeClient := requireNewFakeClientWithIndexesAndInferencePool(t) + c := NewInferencePoolController(fakeClient, kubefake.NewSimpleClientset(), ctrl.Log) + + // Create an AIGatewayRoute that references an InferencePool. + aiGatewayRoute := &aigv1a1.AIGatewayRoute{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-route", + Namespace: "default", + }, + Spec: aigv1a1.AIGatewayRouteSpec{ + Rules: []aigv1a1.AIGatewayRouteRule{ + { + BackendRefs: []aigv1a1.AIGatewayRouteRuleBackendRef{ + { + Name: "test-inference-pool", + Group: ptr.To("inference.networking.x-k8s.io"), + Kind: ptr.To("InferencePool"), + }, + }, + }, + }, + }, + } + + // Test route event handler. + handler := c.aiGatewayRouteEventHandler() + require.NotNil(t, handler, "Route event handler should not be nil") + + // Test that the route references the InferencePool. + result := c.routeReferencesInferencePool(aiGatewayRoute, "test-inference-pool") + require.True(t, result, "AIGatewayRoute should reference the InferencePool") + + // Test negative case. + result = c.routeReferencesInferencePool(aiGatewayRoute, "different-pool") + require.False(t, result, "AIGatewayRoute should not reference different InferencePool") +} + +func TestInferencePoolController_HTTPRouteEventHandler(t *testing.T) { + fakeClient := requireNewFakeClientWithIndexesAndInferencePool(t) + c := NewInferencePoolController(fakeClient, kubefake.NewSimpleClientset(), ctrl.Log) + + // Create an HTTPRoute that references an InferencePool. + httpRoute := &gwapiv1.HTTPRoute{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-http-route", + Namespace: "default", + }, + Spec: gwapiv1.HTTPRouteSpec{ + Rules: []gwapiv1.HTTPRouteRule{ + { + BackendRefs: []gwapiv1.HTTPBackendRef{ + { + BackendRef: gwapiv1.BackendRef{ + BackendObjectReference: gwapiv1.BackendObjectReference{ + Group: ptr.To(gwapiv1.Group("inference.networking.x-k8s.io")), + Kind: ptr.To(gwapiv1.Kind("InferencePool")), + Name: "test-inference-pool", + }, + }, + }, + }, + }, + }, + }, + } + + // Test HTTP route event handler. + handler := c.httpRouteEventHandler() + require.NotNil(t, handler, "HTTP route event handler should not be nil") + + // Test that the HTTP route references the InferencePool. + result := c.httpRouteReferencesInferencePool(httpRoute, "test-inference-pool") + require.True(t, result, "HTTPRoute should reference the InferencePool") + + // Test negative case. + result = c.httpRouteReferencesInferencePool(httpRoute, "different-pool") + require.False(t, result, "HTTPRoute should not reference different InferencePool") +} + +func TestInferencePoolController_EdgeCases(t *testing.T) { + fakeClient := requireNewFakeClientWithIndexesAndInferencePool(t) + c := NewInferencePoolController(fakeClient, kubefake.NewSimpleClientset(), ctrl.Log) + + // Test reconcile with non-existent InferencePool. + result, err := c.Reconcile(context.Background(), ctrl.Request{ + NamespacedName: client.ObjectKey{ + Name: "non-existent-pool", + Namespace: "default", + }, + }) + require.NoError(t, err, "Should not error when InferencePool doesn't exist") + require.Equal(t, ctrl.Result{}, result) + + // Test InferencePool without ExtensionRef. + inferencePoolNoExtRef := &gwaiev1a2.InferencePool{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-inference-pool-no-ext", + Namespace: "default", + }, + Spec: gwaiev1a2.InferencePoolSpec{ + Selector: map[gwaiev1a2.LabelKey]gwaiev1a2.LabelValue{ + "app": "test-app", + }, + TargetPortNumber: 8080, + // No ExtensionRef. + }, + } + require.NoError(t, fakeClient.Create(context.Background(), inferencePoolNoExtRef)) + + result, err = c.Reconcile(context.Background(), ctrl.Request{ + NamespacedName: client.ObjectKey{ + Name: "test-inference-pool-no-ext", + Namespace: "default", + }, + }) + require.NoError(t, err, "Should not error when InferencePool has no ExtensionRef") + require.Equal(t, ctrl.Result{}, result) + + // Test InferencePool with empty ExtensionRef name. + inferencePoolEmptyExtRef := &gwaiev1a2.InferencePool{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-inference-pool-empty-ext", + Namespace: "default", + }, + Spec: gwaiev1a2.InferencePoolSpec{ + Selector: map[gwaiev1a2.LabelKey]gwaiev1a2.LabelValue{ + "app": "test-app", + }, + TargetPortNumber: 8080, + EndpointPickerConfig: gwaiev1a2.EndpointPickerConfig{ + ExtensionRef: &gwaiev1a2.Extension{ + ExtensionReference: gwaiev1a2.ExtensionReference{ + Name: "", // Empty name. + }, + }, + }, + }, + } + require.NoError(t, fakeClient.Create(context.Background(), inferencePoolEmptyExtRef)) + + result, err = c.Reconcile(context.Background(), ctrl.Request{ + NamespacedName: client.ObjectKey{ + Name: "test-inference-pool-empty-ext", + Namespace: "default", + }, + }) + require.Error(t, err, "Should error when ExtensionRef name is empty") + require.Contains(t, err.Error(), "ExtensionReference name is empty") + require.Equal(t, ctrl.Result{}, result) +} + +func TestInferencePoolController_CrossNamespaceReferences(t *testing.T) { + fakeClient := requireNewFakeClientWithIndexesAndInferencePool(t) + c := NewInferencePoolController(fakeClient, kubefake.NewSimpleClientset(), ctrl.Log) + + // Create a Gateway in a different namespace. + gateway := &gwapiv1.Gateway{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-gateway", + Namespace: "gateway-namespace", + }, + Spec: gwapiv1.GatewaySpec{ + GatewayClassName: "test-class", + }, + } + require.NoError(t, fakeClient.Create(context.Background(), gateway)) + + // Create an AIGatewayRoute in the InferencePool namespace that references the Gateway in a different namespace. + aiGatewayRoute := &aigv1a1.AIGatewayRoute{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-route", + Namespace: "default", + }, + Spec: aigv1a1.AIGatewayRouteSpec{ + ParentRefs: []gwapiv1.ParentReference{ + { + Name: "test-gateway", + Namespace: ptr.To(gwapiv1.Namespace("gateway-namespace")), + }, + }, + Rules: []aigv1a1.AIGatewayRouteRule{ + { + BackendRefs: []aigv1a1.AIGatewayRouteRuleBackendRef{ + { + Name: "test-inference-pool", + Group: ptr.To("inference.networking.x-k8s.io"), + Kind: ptr.To("InferencePool"), + }, + }, + }, + }, + }, + } + require.NoError(t, fakeClient.Create(context.Background(), aiGatewayRoute)) + + // Create the service that the InferencePool will reference. + service := &corev1.Service{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-epp", + Namespace: "default", + }, + Spec: corev1.ServiceSpec{ + Ports: []corev1.ServicePort{ + { + Port: 9002, + }, + }, + }, + } + require.NoError(t, fakeClient.Create(context.Background(), service)) + + // Create an InferencePool. + inferencePool := &gwaiev1a2.InferencePool{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-inference-pool", + Namespace: "default", + }, + Spec: gwaiev1a2.InferencePoolSpec{ + Selector: map[gwaiev1a2.LabelKey]gwaiev1a2.LabelValue{ + "app": "test-app", + }, + TargetPortNumber: 8080, + EndpointPickerConfig: gwaiev1a2.EndpointPickerConfig{ + ExtensionRef: &gwaiev1a2.Extension{ + ExtensionReference: gwaiev1a2.ExtensionReference{ + Name: "test-epp", + }, + }, + }, + }, + } + require.NoError(t, fakeClient.Create(context.Background(), inferencePool)) + + // Reconcile the InferencePool. + result, err := c.Reconcile(context.Background(), ctrl.Request{ + NamespacedName: client.ObjectKey{ + Name: "test-inference-pool", + Namespace: "default", + }, + }) + require.NoError(t, err) + require.Equal(t, ctrl.Result{}, result) + + // Check that the InferencePool status was updated with the cross-namespace Gateway. + var updatedInferencePool gwaiev1a2.InferencePool + require.NoError(t, fakeClient.Get(context.Background(), client.ObjectKey{ + Name: "test-inference-pool", + Namespace: "default", + }, &updatedInferencePool)) + + // Verify that the status contains the expected parent Gateway from different namespace. + require.Len(t, updatedInferencePool.Status.Parents, 1) + parent := updatedInferencePool.Status.Parents[0] + + require.Equal(t, "gateway.networking.k8s.io", string(*parent.GatewayRef.Group)) + require.Equal(t, "Gateway", string(*parent.GatewayRef.Kind)) + require.Equal(t, "test-gateway", string(parent.GatewayRef.Name)) + require.Equal(t, "gateway-namespace", string(*parent.GatewayRef.Namespace)) +} + +func TestInferencePoolController_UpdateInferencePoolStatus(t *testing.T) { + fakeClient := requireNewFakeClientWithIndexesAndInferencePool(t) + c := NewInferencePoolController(fakeClient, kubefake.NewSimpleClientset(), ctrl.Log) + + // Create a Gateway. + gateway := &gwapiv1.Gateway{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-gateway", + Namespace: "default", + }, + Spec: gwapiv1.GatewaySpec{ + GatewayClassName: "test-class", + }, + } + require.NoError(t, fakeClient.Create(context.Background(), gateway)) + + // Create an AIGatewayRoute that references an InferencePool. + aiGatewayRoute := &aigv1a1.AIGatewayRoute{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-route", + Namespace: "default", + }, + Spec: aigv1a1.AIGatewayRouteSpec{ + ParentRefs: []gwapiv1.ParentReference{ + { + Name: "test-gateway", + }, + }, + Rules: []aigv1a1.AIGatewayRouteRule{ + { + BackendRefs: []aigv1a1.AIGatewayRouteRuleBackendRef{ + { + Name: "test-inference-pool", + Group: ptr.To("inference.networking.x-k8s.io"), + Kind: ptr.To("InferencePool"), + }, + }, + }, + }, + }, + } + require.NoError(t, fakeClient.Create(context.Background(), aiGatewayRoute)) + + // Create an InferencePool. + inferencePool := &gwaiev1a2.InferencePool{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-inference-pool", + Namespace: "default", + Generation: 5, // Set a specific generation for testing. + }, + Spec: gwaiev1a2.InferencePoolSpec{ + Selector: map[gwaiev1a2.LabelKey]gwaiev1a2.LabelValue{ + "app": "test-app", + }, + TargetPortNumber: 8080, + }, + } + require.NoError(t, fakeClient.Create(context.Background(), inferencePool)) + + // Test updateInferencePoolStatus with NotAccepted condition. + c.updateInferencePoolStatus(context.Background(), inferencePool, "NotAccepted", "test error message") + + // Check that the status was updated. + var updatedInferencePool gwaiev1a2.InferencePool + require.NoError(t, fakeClient.Get(context.Background(), client.ObjectKey{ + Name: "test-inference-pool", + Namespace: "default", + }, &updatedInferencePool)) + + // Verify that the status contains the expected parent Gateway with error condition. + require.Len(t, updatedInferencePool.Status.Parents, 1) + parent := updatedInferencePool.Status.Parents[0] + + require.Equal(t, "test-gateway", string(parent.GatewayRef.Name)) + require.Len(t, parent.Conditions, 2, "Should have both Accepted and ResolvedRefs conditions") + + // Find the conditions. + var acceptedCondition, resolvedRefsCondition *metav1.Condition + for i := range parent.Conditions { + condition := &parent.Conditions[i] + if condition.Type == "Accepted" { + acceptedCondition = condition + } else if condition.Type == "ResolvedRefs" { + resolvedRefsCondition = condition + } + } + + require.NotNil(t, acceptedCondition, "Should have Accepted condition") + require.Equal(t, metav1.ConditionFalse, acceptedCondition.Status) + require.Equal(t, "NotAccepted", acceptedCondition.Reason) + require.Equal(t, int64(5), acceptedCondition.ObservedGeneration) + + require.NotNil(t, resolvedRefsCondition, "Should have ResolvedRefs condition") + require.Equal(t, metav1.ConditionTrue, resolvedRefsCondition.Status) + require.Equal(t, "ResolvedRefs", resolvedRefsCondition.Reason) +} + +func TestInferencePoolController_GetReferencedGateways_ErrorHandling(t *testing.T) { + fakeClient := requireNewFakeClientWithIndexesAndInferencePool(t) + c := NewInferencePoolController(fakeClient, kubefake.NewSimpleClientset(), ctrl.Log) + + // Create an InferencePool. + inferencePool := &gwaiev1a2.InferencePool{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-inference-pool", + Namespace: "default", + }, + Spec: gwaiev1a2.InferencePoolSpec{ + Selector: map[gwaiev1a2.LabelKey]gwaiev1a2.LabelValue{ + "app": "test-app", + }, + TargetPortNumber: 8080, + }, + } + + // Test getReferencedGateways with various scenarios. + gateways, err := c.getReferencedGateways(context.Background(), inferencePool) + require.NoError(t, err) + require.Empty(t, gateways, "Should return empty list when no routes reference the InferencePool") + + // Create an AIGatewayRoute that references the InferencePool but no Gateway. + aiGatewayRoute := &aigv1a1.AIGatewayRoute{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-route-no-gateway", + Namespace: "default", + }, + Spec: aigv1a1.AIGatewayRouteSpec{ + Rules: []aigv1a1.AIGatewayRouteRule{ + { + BackendRefs: []aigv1a1.AIGatewayRouteRuleBackendRef{ + { + Name: "test-inference-pool", + Group: ptr.To("inference.networking.x-k8s.io"), + Kind: ptr.To("InferencePool"), + }, + }, + }, + }, + }, + } + require.NoError(t, fakeClient.Create(context.Background(), aiGatewayRoute)) + + gateways, err = c.getReferencedGateways(context.Background(), inferencePool) + require.NoError(t, err) + require.Empty(t, gateways, "Should return empty list when route has no parent refs") +} + +func TestInferencePoolController_GatewayReferencesInferencePool_HTTPRoute(t *testing.T) { + fakeClient := requireNewFakeClientWithIndexesAndInferencePool(t) + c := NewInferencePoolController(fakeClient, kubefake.NewSimpleClientset(), ctrl.Log) + + // Create a Gateway. + gateway := &gwapiv1.Gateway{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-gateway", + Namespace: "default", + }, + Spec: gwapiv1.GatewaySpec{ + GatewayClassName: "test-class", + }, + } + require.NoError(t, fakeClient.Create(context.Background(), gateway)) + + // Create an HTTPRoute that references the Gateway and InferencePool. + httpRoute := &gwapiv1.HTTPRoute{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-http-route", + Namespace: "test-namespace", + }, + Spec: gwapiv1.HTTPRouteSpec{ + CommonRouteSpec: gwapiv1.CommonRouteSpec{ + ParentRefs: []gwapiv1.ParentReference{ + { + Name: "test-gateway", + Namespace: ptr.To(gwapiv1.Namespace("default")), + }, + }, + }, + Rules: []gwapiv1.HTTPRouteRule{ + { + BackendRefs: []gwapiv1.HTTPBackendRef{ + { + BackendRef: gwapiv1.BackendRef{ + BackendObjectReference: gwapiv1.BackendObjectReference{ + Group: ptr.To(gwapiv1.Group("inference.networking.x-k8s.io")), + Kind: ptr.To(gwapiv1.Kind("InferencePool")), + Name: "test-inference-pool", + }, + }, + }, + }, + }, + }, + }, + } + require.NoError(t, fakeClient.Create(context.Background(), httpRoute)) + + // Test positive case - Gateway references InferencePool through HTTPRoute. + result := c.gatewayReferencesInferencePool(context.Background(), gateway, "test-inference-pool", "test-namespace") + require.True(t, result, "Should return true when Gateway references InferencePool through HTTPRoute") + + // Test negative case - different InferencePool name. + result = c.gatewayReferencesInferencePool(context.Background(), gateway, "different-pool", "test-namespace") + require.False(t, result, "Should return false when Gateway doesn't reference the specified InferencePool") + + // Test negative case - different namespace. + result = c.gatewayReferencesInferencePool(context.Background(), gateway, "test-inference-pool", "different-namespace") + require.False(t, result, "Should return false when InferencePool is in different namespace") +} + +func TestInferencePoolController_ValidateExtensionReference_EdgeCases(t *testing.T) { + fakeClient := requireNewFakeClientWithIndexesAndInferencePool(t) + c := NewInferencePoolController(fakeClient, kubefake.NewSimpleClientset(), ctrl.Log) + + // Test with nil ExtensionRef. + inferencePoolNilExt := &gwaiev1a2.InferencePool{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-inference-pool-nil-ext", + Namespace: "default", + }, + Spec: gwaiev1a2.InferencePoolSpec{ + Selector: map[gwaiev1a2.LabelKey]gwaiev1a2.LabelValue{ + "app": "test-app", + }, + TargetPortNumber: 8080, + // No EndpointPickerConfig. + }, + } + + err := c.validateExtensionReference(context.Background(), inferencePoolNilExt) + require.NoError(t, err, "Should not error when ExtensionRef is nil") + + // Test with ExtensionRef but nil ExtensionRef field. + inferencePoolNilExtRef := &gwaiev1a2.InferencePool{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-inference-pool-nil-extref", + Namespace: "default", + }, + Spec: gwaiev1a2.InferencePoolSpec{ + Selector: map[gwaiev1a2.LabelKey]gwaiev1a2.LabelValue{ + "app": "test-app", + }, + TargetPortNumber: 8080, + EndpointPickerConfig: gwaiev1a2.EndpointPickerConfig{ + // ExtensionRef is nil. + }, + }, + } + + err = c.validateExtensionReference(context.Background(), inferencePoolNilExtRef) + require.NoError(t, err, "Should not error when ExtensionRef field is nil") + + // Test with service in different namespace (should fail). + serviceOtherNS := &corev1.Service{ + ObjectMeta: metav1.ObjectMeta{ + Name: "service-other-ns", + Namespace: "other-namespace", + }, + Spec: corev1.ServiceSpec{ + Ports: []corev1.ServicePort{ + { + Port: 9002, + }, + }, + }, + } + require.NoError(t, fakeClient.Create(context.Background(), serviceOtherNS)) + + inferencePoolOtherNS := &gwaiev1a2.InferencePool{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-inference-pool-other-ns", + Namespace: "default", // InferencePool in default namespace. + }, + Spec: gwaiev1a2.InferencePoolSpec{ + Selector: map[gwaiev1a2.LabelKey]gwaiev1a2.LabelValue{ + "app": "test-app", + }, + TargetPortNumber: 8080, + EndpointPickerConfig: gwaiev1a2.EndpointPickerConfig{ + ExtensionRef: &gwaiev1a2.Extension{ + ExtensionReference: gwaiev1a2.ExtensionReference{ + Name: "service-other-ns", // Service in other-namespace. + }, + }, + }, + }, + } + + err = c.validateExtensionReference(context.Background(), inferencePoolOtherNS) + require.Error(t, err, "Should error when ExtensionReference service is in different namespace") + require.Contains(t, err.Error(), "ExtensionReference service service-other-ns not found in namespace default") +} + +func TestInferencePoolController_Reconcile_ErrorHandling(t *testing.T) { + fakeClient := requireNewFakeClientWithIndexesAndInferencePool(t) + c := NewInferencePoolController(fakeClient, kubefake.NewSimpleClientset(), ctrl.Log) + + // Test reconcile with InferencePool that has empty ExtensionRef name. + inferencePoolEmptyName := &gwaiev1a2.InferencePool{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-inference-pool-empty-name", + Namespace: "default", + }, + Spec: gwaiev1a2.InferencePoolSpec{ + Selector: map[gwaiev1a2.LabelKey]gwaiev1a2.LabelValue{ + "app": "test-app", + }, + TargetPortNumber: 8080, + EndpointPickerConfig: gwaiev1a2.EndpointPickerConfig{ + ExtensionRef: &gwaiev1a2.Extension{ + ExtensionReference: gwaiev1a2.ExtensionReference{ + Name: "", // Empty name. + }, + }, + }, + }, + } + require.NoError(t, fakeClient.Create(context.Background(), inferencePoolEmptyName)) + + // This should trigger the error path in Reconcile. + result, err := c.Reconcile(context.Background(), ctrl.Request{ + NamespacedName: client.ObjectKey{ + Name: "test-inference-pool-empty-name", + Namespace: "default", + }, + }) + require.Error(t, err, "Should error when ExtensionRef name is empty") + require.Contains(t, err.Error(), "ExtensionReference name is empty") + require.Equal(t, ctrl.Result{}, result) + + // Test reconcile with InferencePool that has non-existent ExtensionRef service. + inferencePoolNonExistentService := &gwaiev1a2.InferencePool{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-inference-pool-non-existent", + Namespace: "default", + }, + Spec: gwaiev1a2.InferencePoolSpec{ + Selector: map[gwaiev1a2.LabelKey]gwaiev1a2.LabelValue{ + "app": "test-app", + }, + TargetPortNumber: 8080, + EndpointPickerConfig: gwaiev1a2.EndpointPickerConfig{ + ExtensionRef: &gwaiev1a2.Extension{ + ExtensionReference: gwaiev1a2.ExtensionReference{ + Name: "non-existent-service", + }, + }, + }, + }, + } + require.NoError(t, fakeClient.Create(context.Background(), inferencePoolNonExistentService)) + + // This should trigger the error path in Reconcile. + result, err = c.Reconcile(context.Background(), ctrl.Request{ + NamespacedName: client.ObjectKey{ + Name: "test-inference-pool-non-existent", + Namespace: "default", + }, + }) + require.Error(t, err, "Should error when ExtensionRef service doesn't exist") + require.Contains(t, err.Error(), "ExtensionReference service non-existent-service not found") + require.Equal(t, ctrl.Result{}, result) +} + +func TestInferencePoolController_SyncInferencePool_EdgeCases(t *testing.T) { + fakeClient := requireNewFakeClientWithIndexesAndInferencePool(t) + c := NewInferencePoolController(fakeClient, kubefake.NewSimpleClientset(), ctrl.Log) + + // Test syncInferencePool with InferencePool that has no referenced gateways. + inferencePoolNoGateways := &gwaiev1a2.InferencePool{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-inference-pool-no-gateways", + Namespace: "default", + }, + Spec: gwaiev1a2.InferencePoolSpec{ + Selector: map[gwaiev1a2.LabelKey]gwaiev1a2.LabelValue{ + "app": "test-app", + }, + TargetPortNumber: 8080, + }, + } + require.NoError(t, fakeClient.Create(context.Background(), inferencePoolNoGateways)) + + // Create the service that the InferencePool will reference. + service := &corev1.Service{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-epp-no-gateways", + Namespace: "default", + }, + Spec: corev1.ServiceSpec{ + Ports: []corev1.ServicePort{ + { + Port: 9002, + }, + }, + }, + } + require.NoError(t, fakeClient.Create(context.Background(), service)) + + inferencePoolNoGateways.Spec.EndpointPickerConfig = gwaiev1a2.EndpointPickerConfig{ + ExtensionRef: &gwaiev1a2.Extension{ + ExtensionReference: gwaiev1a2.ExtensionReference{ + Name: "test-epp-no-gateways", + }, + }, + } + require.NoError(t, fakeClient.Update(context.Background(), inferencePoolNoGateways)) + + // Reconcile should succeed even when no gateways reference the InferencePool. + result, err := c.Reconcile(context.Background(), ctrl.Request{ + NamespacedName: client.ObjectKey{ + Name: "test-inference-pool-no-gateways", + Namespace: "default", + }, + }) + require.NoError(t, err) + require.Equal(t, ctrl.Result{}, result) + + // Check that the InferencePool status is empty (no parents). + var updatedInferencePool gwaiev1a2.InferencePool + require.NoError(t, fakeClient.Get(context.Background(), client.ObjectKey{ + Name: "test-inference-pool-no-gateways", + Namespace: "default", + }, &updatedInferencePool)) + + require.Empty(t, updatedInferencePool.Status.Parents, "Should have no parent statuses when no gateways reference the InferencePool") +} + +func TestInferencePoolController_GetReferencedGateways_ComplexScenarios(t *testing.T) { + fakeClient := requireNewFakeClientWithIndexesAndInferencePool(t) + c := NewInferencePoolController(fakeClient, kubefake.NewSimpleClientset(), ctrl.Log) + + // Create an InferencePool. + inferencePool := &gwaiev1a2.InferencePool{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-inference-pool-complex", + Namespace: "default", + }, + Spec: gwaiev1a2.InferencePoolSpec{ + Selector: map[gwaiev1a2.LabelKey]gwaiev1a2.LabelValue{ + "app": "test-app", + }, + TargetPortNumber: 8080, + }, + } + + // Create multiple Gateways. + gateway1 := &gwapiv1.Gateway{ + ObjectMeta: metav1.ObjectMeta{ + Name: "gateway-1", + Namespace: "default", + }, + Spec: gwapiv1.GatewaySpec{ + GatewayClassName: "test-class", + }, + } + require.NoError(t, fakeClient.Create(context.Background(), gateway1)) + + gateway2 := &gwapiv1.Gateway{ + ObjectMeta: metav1.ObjectMeta{ + Name: "gateway-2", + Namespace: "other-namespace", + }, + Spec: gwapiv1.GatewaySpec{ + GatewayClassName: "test-class", + }, + } + require.NoError(t, fakeClient.Create(context.Background(), gateway2)) + + // Create AIGatewayRoutes that reference different gateways. + aiGatewayRoute1 := &aigv1a1.AIGatewayRoute{ + ObjectMeta: metav1.ObjectMeta{ + Name: "route-1", + Namespace: "default", + }, + Spec: aigv1a1.AIGatewayRouteSpec{ + ParentRefs: []gwapiv1.ParentReference{ + { + Name: "gateway-1", + }, + }, + Rules: []aigv1a1.AIGatewayRouteRule{ + { + BackendRefs: []aigv1a1.AIGatewayRouteRuleBackendRef{ + { + Name: "test-inference-pool-complex", + Group: ptr.To("inference.networking.x-k8s.io"), + Kind: ptr.To("InferencePool"), + }, + }, + }, + }, + }, + } + require.NoError(t, fakeClient.Create(context.Background(), aiGatewayRoute1)) + + aiGatewayRoute2 := &aigv1a1.AIGatewayRoute{ + ObjectMeta: metav1.ObjectMeta{ + Name: "route-2", + Namespace: "default", + }, + Spec: aigv1a1.AIGatewayRouteSpec{ + ParentRefs: []gwapiv1.ParentReference{ + { + Name: "gateway-2", + Namespace: ptr.To(gwapiv1.Namespace("other-namespace")), + }, + }, + Rules: []aigv1a1.AIGatewayRouteRule{ + { + BackendRefs: []aigv1a1.AIGatewayRouteRuleBackendRef{ + { + Name: "test-inference-pool-complex", + Group: ptr.To("inference.networking.x-k8s.io"), + Kind: ptr.To("InferencePool"), + }, + }, + }, + }, + }, + } + require.NoError(t, fakeClient.Create(context.Background(), aiGatewayRoute2)) + + // Create HTTPRoute that also references the InferencePool. + httpRoute := &gwapiv1.HTTPRoute{ + ObjectMeta: metav1.ObjectMeta{ + Name: "http-route-complex", + Namespace: "default", + }, + Spec: gwapiv1.HTTPRouteSpec{ + CommonRouteSpec: gwapiv1.CommonRouteSpec{ + ParentRefs: []gwapiv1.ParentReference{ + { + Name: "gateway-1", + }, + }, + }, + Rules: []gwapiv1.HTTPRouteRule{ + { + BackendRefs: []gwapiv1.HTTPBackendRef{ + { + BackendRef: gwapiv1.BackendRef{ + BackendObjectReference: gwapiv1.BackendObjectReference{ + Group: ptr.To(gwapiv1.Group("inference.networking.x-k8s.io")), + Kind: ptr.To(gwapiv1.Kind("InferencePool")), + Name: "test-inference-pool-complex", + }, + }, + }, + }, + }, + }, + }, + } + require.NoError(t, fakeClient.Create(context.Background(), httpRoute)) + + // Test getReferencedGateways should return both gateways. + gateways, err := c.getReferencedGateways(context.Background(), inferencePool) + require.NoError(t, err) + require.Len(t, gateways, 2, "Should return both gateways that reference the InferencePool") + + // Verify the gateways are the expected ones. + gatewayNames := make(map[string]string) + for _, gw := range gateways { + gatewayNames[gw.Name] = gw.Namespace + } + require.Contains(t, gatewayNames, "gateway-1") + require.Equal(t, "default", gatewayNames["gateway-1"]) + require.Contains(t, gatewayNames, "gateway-2") + require.Equal(t, "other-namespace", gatewayNames["gateway-2"]) +} + +func TestInferencePoolController_UpdateInferencePoolStatus_MultipleGateways(t *testing.T) { + fakeClient := requireNewFakeClientWithIndexesAndInferencePool(t) + c := NewInferencePoolController(fakeClient, kubefake.NewSimpleClientset(), ctrl.Log) + + // Create multiple Gateways. + gateway1 := &gwapiv1.Gateway{ + ObjectMeta: metav1.ObjectMeta{ + Name: "gateway-1", + Namespace: "default", + }, + Spec: gwapiv1.GatewaySpec{ + GatewayClassName: "test-class", + }, + } + require.NoError(t, fakeClient.Create(context.Background(), gateway1)) + + gateway2 := &gwapiv1.Gateway{ + ObjectMeta: metav1.ObjectMeta{ + Name: "gateway-2", + Namespace: "default", + }, + Spec: gwapiv1.GatewaySpec{ + GatewayClassName: "test-class", + }, + } + require.NoError(t, fakeClient.Create(context.Background(), gateway2)) + + // Create AIGatewayRoutes that reference different gateways. + aiGatewayRoute1 := &aigv1a1.AIGatewayRoute{ + ObjectMeta: metav1.ObjectMeta{ + Name: "route-1", + Namespace: "default", + }, + Spec: aigv1a1.AIGatewayRouteSpec{ + ParentRefs: []gwapiv1.ParentReference{ + { + Name: "gateway-1", + }, + }, + Rules: []aigv1a1.AIGatewayRouteRule{ + { + BackendRefs: []aigv1a1.AIGatewayRouteRuleBackendRef{ + { + Name: "test-inference-pool-multi", + Group: ptr.To("inference.networking.x-k8s.io"), + Kind: ptr.To("InferencePool"), + }, + }, + }, + }, + }, + } + require.NoError(t, fakeClient.Create(context.Background(), aiGatewayRoute1)) + + aiGatewayRoute2 := &aigv1a1.AIGatewayRoute{ + ObjectMeta: metav1.ObjectMeta{ + Name: "route-2", + Namespace: "default", + }, + Spec: aigv1a1.AIGatewayRouteSpec{ + ParentRefs: []gwapiv1.ParentReference{ + { + Name: "gateway-2", + }, + }, + Rules: []aigv1a1.AIGatewayRouteRule{ + { + BackendRefs: []aigv1a1.AIGatewayRouteRuleBackendRef{ + { + Name: "test-inference-pool-multi", + Group: ptr.To("inference.networking.x-k8s.io"), + Kind: ptr.To("InferencePool"), + }, + }, + }, + }, + }, + } + require.NoError(t, fakeClient.Create(context.Background(), aiGatewayRoute2)) + + // Create an InferencePool. + inferencePool := &gwaiev1a2.InferencePool{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-inference-pool-multi", + Namespace: "default", + Generation: 10, // Set a specific generation for testing. + }, + Spec: gwaiev1a2.InferencePoolSpec{ + Selector: map[gwaiev1a2.LabelKey]gwaiev1a2.LabelValue{ + "app": "test-app", + }, + TargetPortNumber: 8080, + }, + } + require.NoError(t, fakeClient.Create(context.Background(), inferencePool)) + + // Test updateInferencePoolStatus with Accepted condition for multiple gateways. + c.updateInferencePoolStatus(context.Background(), inferencePool, "Accepted", "all references resolved") + + // Check that the status was updated for both gateways. + var updatedInferencePool gwaiev1a2.InferencePool + require.NoError(t, fakeClient.Get(context.Background(), client.ObjectKey{ + Name: "test-inference-pool-multi", + Namespace: "default", + }, &updatedInferencePool)) + + // Verify that the status contains both parent Gateways. + require.Len(t, updatedInferencePool.Status.Parents, 2, "Should have status for both gateways") + + // Verify both gateways have the correct conditions. + for _, parent := range updatedInferencePool.Status.Parents { + require.Len(t, parent.Conditions, 2, "Should have both Accepted and ResolvedRefs conditions") + + // Find the conditions. + var acceptedCondition, resolvedRefsCondition *metav1.Condition + for i := range parent.Conditions { + condition := &parent.Conditions[i] + if condition.Type == "Accepted" { + acceptedCondition = condition + } else if condition.Type == "ResolvedRefs" { + resolvedRefsCondition = condition + } + } + + require.NotNil(t, acceptedCondition, "Should have Accepted condition") + require.Equal(t, metav1.ConditionTrue, acceptedCondition.Status) + require.Equal(t, "Accepted", acceptedCondition.Reason) + require.Equal(t, int64(10), acceptedCondition.ObservedGeneration) + + require.NotNil(t, resolvedRefsCondition, "Should have ResolvedRefs condition") + require.Equal(t, metav1.ConditionTrue, resolvedRefsCondition.Status) + require.Equal(t, "ResolvedRefs", resolvedRefsCondition.Reason) + } +} + +func TestInferencePoolController_GatewayReferencesInferencePool_NoRoutes(t *testing.T) { + fakeClient := requireNewFakeClientWithIndexesAndInferencePool(t) + c := NewInferencePoolController(fakeClient, kubefake.NewSimpleClientset(), ctrl.Log) + + // Create a Gateway. + gateway := &gwapiv1.Gateway{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-gateway-no-routes", + Namespace: "default", + }, + Spec: gwapiv1.GatewaySpec{ + GatewayClassName: "test-class", + }, + } + require.NoError(t, fakeClient.Create(context.Background(), gateway)) + + // Test gatewayReferencesInferencePool when there are no routes at all. + result := c.gatewayReferencesInferencePool(context.Background(), gateway, "test-inference-pool", "default") + require.False(t, result, "Should return false when there are no routes") + + // Test gatewayReferencesInferencePool when there are routes but they don't reference the gateway. + aiGatewayRouteNoRef := &aigv1a1.AIGatewayRoute{ + ObjectMeta: metav1.ObjectMeta{ + Name: "route-no-ref", + Namespace: "default", + }, + Spec: aigv1a1.AIGatewayRouteSpec{ + ParentRefs: []gwapiv1.ParentReference{ + { + Name: "different-gateway", // Different gateway. + }, + }, + Rules: []aigv1a1.AIGatewayRouteRule{ + { + BackendRefs: []aigv1a1.AIGatewayRouteRuleBackendRef{ + { + Name: "test-inference-pool", + Group: ptr.To("inference.networking.x-k8s.io"), + Kind: ptr.To("InferencePool"), + }, + }, + }, + }, + }, + } + require.NoError(t, fakeClient.Create(context.Background(), aiGatewayRouteNoRef)) + + result = c.gatewayReferencesInferencePool(context.Background(), gateway, "test-inference-pool", "default") + require.False(t, result, "Should return false when routes don't reference the gateway") + + // Test gatewayReferencesInferencePool when routes reference the gateway but not the InferencePool. + aiGatewayRouteNoPool := &aigv1a1.AIGatewayRoute{ + ObjectMeta: metav1.ObjectMeta{ + Name: "route-no-pool", + Namespace: "default", + }, + Spec: aigv1a1.AIGatewayRouteSpec{ + ParentRefs: []gwapiv1.ParentReference{ + { + Name: "test-gateway-no-routes", // Correct gateway. + }, + }, + Rules: []aigv1a1.AIGatewayRouteRule{ + { + BackendRefs: []aigv1a1.AIGatewayRouteRuleBackendRef{ + { + Name: "different-pool", // Different pool. + }, + }, + }, + }, + }, + } + require.NoError(t, fakeClient.Create(context.Background(), aiGatewayRouteNoPool)) + + result = c.gatewayReferencesInferencePool(context.Background(), gateway, "test-inference-pool", "default") + require.False(t, result, "Should return false when routes don't reference the InferencePool") +} + +func TestInferencePoolController_UpdateInferencePoolStatus_ExtensionRefError(t *testing.T) { + fakeClient := requireNewFakeClientWithIndexesAndInferencePool(t) + c := NewInferencePoolController(fakeClient, kubefake.NewSimpleClientset(), ctrl.Log) + + // Create a Gateway. + gateway := &gwapiv1.Gateway{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-gateway-ext-error", + Namespace: "default", + }, + Spec: gwapiv1.GatewaySpec{ + GatewayClassName: "test-class", + }, + } + require.NoError(t, fakeClient.Create(context.Background(), gateway)) + + // Create an AIGatewayRoute that references the InferencePool. + aiGatewayRoute := &aigv1a1.AIGatewayRoute{ + ObjectMeta: metav1.ObjectMeta{ + Name: "route-ext-error", + Namespace: "default", + }, + Spec: aigv1a1.AIGatewayRouteSpec{ + ParentRefs: []gwapiv1.ParentReference{ + { + Name: "test-gateway-ext-error", + }, + }, + Rules: []aigv1a1.AIGatewayRouteRule{ + { + BackendRefs: []aigv1a1.AIGatewayRouteRuleBackendRef{ + { + Name: "test-inference-pool-ext-error", + Group: ptr.To("inference.networking.x-k8s.io"), + Kind: ptr.To("InferencePool"), + }, + }, + }, + }, + }, + } + require.NoError(t, fakeClient.Create(context.Background(), aiGatewayRoute)) + + // Create an InferencePool. + inferencePool := &gwaiev1a2.InferencePool{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-inference-pool-ext-error", + Namespace: "default", + Generation: 15, // Set a specific generation for testing. + }, + Spec: gwaiev1a2.InferencePoolSpec{ + Selector: map[gwaiev1a2.LabelKey]gwaiev1a2.LabelValue{ + "app": "test-app", + }, + TargetPortNumber: 8080, + }, + } + require.NoError(t, fakeClient.Create(context.Background(), inferencePool)) + + // Test updateInferencePoolStatus with ExtensionReference error message. + extRefErrorMessage := "ExtensionReference service non-existent-service not found in namespace default" + c.updateInferencePoolStatus(context.Background(), inferencePool, "NotAccepted", extRefErrorMessage) + + // Check that the status was updated with ExtensionReference error. + var updatedInferencePool gwaiev1a2.InferencePool + require.NoError(t, fakeClient.Get(context.Background(), client.ObjectKey{ + Name: "test-inference-pool-ext-error", + Namespace: "default", + }, &updatedInferencePool)) + + // Verify that the status contains the expected parent Gateway with ExtensionReference error. + require.Len(t, updatedInferencePool.Status.Parents, 1) + parent := updatedInferencePool.Status.Parents[0] + + require.Equal(t, "test-gateway-ext-error", string(parent.GatewayRef.Name)) + require.Len(t, parent.Conditions, 2, "Should have both Accepted and ResolvedRefs conditions") + + // Find the conditions. + var acceptedCondition, resolvedRefsCondition *metav1.Condition + for i := range parent.Conditions { + condition := &parent.Conditions[i] + if condition.Type == "Accepted" { + acceptedCondition = condition + } else if condition.Type == "ResolvedRefs" { + resolvedRefsCondition = condition + } + } + + require.NotNil(t, acceptedCondition, "Should have Accepted condition") + require.Equal(t, metav1.ConditionFalse, acceptedCondition.Status) + require.Equal(t, "NotAccepted", acceptedCondition.Reason) + require.Equal(t, int64(15), acceptedCondition.ObservedGeneration) + + require.NotNil(t, resolvedRefsCondition, "Should have ResolvedRefs condition") + require.Equal(t, metav1.ConditionFalse, resolvedRefsCondition.Status, "ResolvedRefs should be False for ExtensionReference error") + require.Equal(t, "ResolvedRefs", resolvedRefsCondition.Reason) + require.Contains(t, resolvedRefsCondition.Message, extRefErrorMessage) +} diff --git a/manifests/charts/ai-gateway-helm/templates/serviceaccount.yaml b/manifests/charts/ai-gateway-helm/templates/serviceaccount.yaml index 6bda78da09..4d62d63483 100644 --- a/manifests/charts/ai-gateway-helm/templates/serviceaccount.yaml +++ b/manifests/charts/ai-gateway-helm/templates/serviceaccount.yaml @@ -23,6 +23,7 @@ metadata: rules: - apiGroups: [""] resources: + - services - secrets - pods # TODO: this can be limited to EG system namespace, not the cluster level. verbs: @@ -67,6 +68,13 @@ rules: - list - create - update + - apiGroups: + - apiextensions.k8s.io + resources: + - customresourcedefinitions + verbs: + - get + - list - apiGroups: - "" resources: diff --git a/tests/e2e/conformance_test.go b/tests/e2e/conformance_test.go new file mode 100644 index 0000000000..e340e358f1 --- /dev/null +++ b/tests/e2e/conformance_test.go @@ -0,0 +1,47 @@ +// Copyright Envoy AI Gateway Authors +// SPDX-License-Identifier: Apache-2.0 +// The full text of the Apache license is available in the LICENSE file at +// the root of the repo. + +package e2e + +import ( + "testing" + "time" + + "github.com/stretchr/testify/require" + gie "sigs.k8s.io/gateway-api-inference-extension/conformance" + v1 "sigs.k8s.io/gateway-api/conformance/apis/v1" + "sigs.k8s.io/gateway-api/conformance/utils/config" +) + +func TestGatewayAPIInferenceExtension(t *testing.T) { + const manifest = "testdata/inference-extension-conformance.yaml" + require.NoError(t, kubectlApplyManifest(t.Context(), manifest)) + + options := gie.DefaultOptions(t) + options.ReportOutputPath = "./inference-extension-conformance-test-report.yaml" + options.Debug = false + options.CleanupBaseResources = true + options.Implementation = v1.Implementation{ + Organization: "EnvoyProxy", + Project: "Envoy AI Gateway", + URL: "https://github.com/envoyproxy/ai-gateway", + Contact: []string{"@envoy-ai-gateway/maintainers"}, + Version: "latest", + } + options.ConformanceProfiles.Insert(gie.GatewayLayerProfileName) + defaultTimeoutConfig := config.DefaultTimeoutConfig() + defaultTimeoutConfig.HTTPRouteMustHaveCondition = 10 * time.Second + defaultTimeoutConfig.HTTPRouteMustNotHaveParents = 10 * time.Second + defaultTimeoutConfig.GatewayMustHaveCondition = 10 * time.Second + config.SetupTimeoutConfig(&defaultTimeoutConfig) + options.TimeoutConfig = defaultTimeoutConfig + options.GatewayClassName = "inference-pool" + // enable EPPUnAvaliableFailOpen after https://github.com/kubernetes-sigs/gateway-api-inference-extension/pull/1265 merged. + options.SkipTests = []string{ + "EppUnAvailableFailOpen", + } + + gie.RunConformanceWithOptions(t, options) +} diff --git a/tests/e2e/inference_pool_test.go b/tests/e2e/inference_pool_test.go index 61b61c9f5b..476b04878c 100644 --- a/tests/e2e/inference_pool_test.go +++ b/tests/e2e/inference_pool_test.go @@ -7,14 +7,18 @@ package e2e import ( "context" + "encoding/json" "fmt" "io" "net/http" + "os/exec" "strings" "testing" "time" "github.com/stretchr/testify/require" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + gwaiev1a2 "sigs.k8s.io/gateway-api-inference-extension/api/v1alpha2" ) // TestInferencePoolIntegration tests the InferencePool integration with AI Gateway. @@ -30,6 +34,22 @@ func TestInferencePoolIntegration(t *testing.T) { egSelector := "gateway.envoyproxy.io/owning-gateway-name=inference-pool-with-aigwroute" requireWaitForGatewayPodReady(t, egSelector) + // Verify InferencePool status is correctly set for the Gateway. + t.Run("verify_inference_pool_status", func(t *testing.T) { + // Verify that the mistral InferencePool has correct status for the Gateway. + requireInferencePoolStatusValid(t, "default", "mistral", "inference-pool-with-aigwroute") + + // Verify that the vllm-llama3-8b-instruct InferencePool has correct status for the Gateway. + // Note: This InferencePool is referenced in the AIGatewayRoute but may not exist in base.yaml. + // We'll check if it exists first. + status, err := getInferencePoolStatus(t.Context(), "default", "vllm-llama3-8b-instruct") + if err == nil && status != nil { + requireInferencePoolStatusValid(t, "default", "vllm-llama3-8b-instruct", "inference-pool-with-aigwroute") + } else { + t.Logf("InferencePool vllm-llama3-8b-instruct not found, skipping status validation: %v", err) + } + }) + // Test connectivity to inferencePool + header match + inference pods with valid metrics, should return 200. t.Run("endpointpicker_with_aigwroute_matched_header", func(t *testing.T) { testInferenceGatewayConnectivityByModel(t, egSelector, "meta-llama/Llama-3.1-8B-Instruct", map[string]string{"Authorization": "sk-abcdefghijklmnopqrstuvwxyz"}, http.StatusOK) @@ -83,6 +103,13 @@ func TestInferencePoolIntegration(t *testing.T) { egSelector = "gateway.envoyproxy.io/owning-gateway-name=inference-pool-with-httproute" requireWaitForPodReady(t, egSelector) + // Verify InferencePool status is correctly set for the HTTPRoute Gateway. + t.Run("verify_inference_pool_status_httproute", func(t *testing.T) { + // For HTTPRoute, the referenced InferencePool is "vllm-llama3-8b-instruct". + // The HTTPRoute Gateway name should be "inference-pool-with-httproute". + requireInferencePoolStatusValid(t, "default", "vllm-llama3-8b-instruct", "inference-pool-with-httproute") + }) + // Test connectivity to inferencePool + inference pods with valid metrics. t.Run("endpointpicker_with_httproute_valid_pod_metrics", func(t *testing.T) { testInferenceGatewayConnectivityByModel(t, egSelector, "meta-llama/Llama-3.1-8B-Instruct", nil, http.StatusOK) @@ -145,3 +172,106 @@ func testInferenceGatewayConnectivity(t *testing.T, egSelector, body string, add return true }, 2*time.Minute, 5*time.Second, "Gateway should return expected status code", expectedStatusCode) } + +// getInferencePoolStatus retrieves the status of an InferencePool resource. +func getInferencePoolStatus(ctx context.Context, namespace, name string) (*gwaiev1a2.InferencePoolStatus, error) { + cmd := exec.CommandContext(ctx, "kubectl", "get", "inferencepool", name, "-n", namespace, "-o", "json") + out, err := cmd.Output() + if err != nil { + return nil, fmt.Errorf("failed to get InferencePool %s/%s: %w", namespace, name, err) + } + + var inferencePool gwaiev1a2.InferencePool + if err := json.Unmarshal(out, &inferencePool); err != nil { + return nil, fmt.Errorf("failed to unmarshal InferencePool: %w", err) + } + + return &inferencePool.Status, nil +} + +// requireInferencePoolStatusValid validates that the InferencePool status is correctly set. +func requireInferencePoolStatusValid(t *testing.T, namespace, inferencePoolName, expectedGatewayName string) { + require.Eventually(t, func() bool { + status, err := getInferencePoolStatus(t.Context(), namespace, inferencePoolName) + if err != nil { + t.Logf("Failed to get InferencePool status: %v", err) + return false + } + + // Check that we have at least one parent status. + if len(status.Parents) == 0 { + t.Logf("InferencePool %s has no parent status", inferencePoolName) + return false + } + + // Find the parent status for the expected Gateway. + var foundParent *gwaiev1a2.PoolStatus + for i := range status.Parents { + parent := &status.Parents[i] + if string(parent.GatewayRef.Name) == expectedGatewayName { + foundParent = parent + break + } + } + + if foundParent == nil { + t.Logf("InferencePool %s does not have parent status for Gateway %s", inferencePoolName, expectedGatewayName) + return false + } + + // Validate the GatewayRef fields. + if foundParent.GatewayRef.Group == nil || string(*foundParent.GatewayRef.Group) != "gateway.networking.k8s.io" { + t.Logf("InferencePool %s parent GatewayRef has incorrect group: %v", inferencePoolName, foundParent.GatewayRef.Group) + return false + } + + if foundParent.GatewayRef.Kind == nil || string(*foundParent.GatewayRef.Kind) != "Gateway" { + t.Logf("InferencePool %s parent GatewayRef has incorrect kind: %v", inferencePoolName, foundParent.GatewayRef.Kind) + return false + } + + if string(foundParent.GatewayRef.Name) != expectedGatewayName { + t.Logf("InferencePool %s parent GatewayRef has incorrect name: %s (expected %s)", inferencePoolName, foundParent.GatewayRef.Name, expectedGatewayName) + return false + } + + if foundParent.GatewayRef.Namespace == nil || string(*foundParent.GatewayRef.Namespace) != namespace { + t.Logf("InferencePool %s parent GatewayRef has incorrect namespace: %v (expected %s)", inferencePoolName, foundParent.GatewayRef.Namespace, namespace) + return false + } + + // Validate the conditions. + if len(foundParent.Conditions) == 0 { + t.Logf("InferencePool %s parent has no conditions", inferencePoolName) + return false + } + + // Find the "Accepted" condition. + var acceptedCondition *metav1.Condition + for i := range foundParent.Conditions { + condition := &foundParent.Conditions[i] + if condition.Type == "Accepted" { + acceptedCondition = condition + break + } + } + + if acceptedCondition == nil { + t.Logf("InferencePool %s parent does not have 'Accepted' condition", inferencePoolName) + return false + } + + if acceptedCondition.Status != metav1.ConditionTrue { + t.Logf("InferencePool %s 'Accepted' condition status is not True: %s", inferencePoolName, acceptedCondition.Status) + return false + } + + if acceptedCondition.Reason != "Accepted" { + t.Logf("InferencePool %s 'Accepted' condition reason is not 'Accepted': %s", inferencePoolName, acceptedCondition.Reason) + return false + } + + t.Logf("InferencePool %s status validation passed: Gateway=%s, Condition=%s", inferencePoolName, expectedGatewayName, acceptedCondition.Status) + return true + }, 2*time.Minute, 5*time.Second, "InferencePool status should be correctly set") +} diff --git a/tests/e2e/testdata/inference-extension-conformance.yaml b/tests/e2e/testdata/inference-extension-conformance.yaml new file mode 100644 index 0000000000..0ff3eb1639 --- /dev/null +++ b/tests/e2e/testdata/inference-extension-conformance.yaml @@ -0,0 +1,11 @@ +# Copyright Envoy AI Gateway Authors +# SPDX-License-Identifier: Apache-2.0 +# The full text of the Apache license is available in the LICENSE file at +# the root of the repo. + +apiVersion: gateway.networking.k8s.io/v1 +kind: GatewayClass +metadata: + name: inference-pool +spec: + controllerName: gateway.envoyproxy.io/gatewayclass-controller