diff --git a/conformance/resources/manifests/manifests.yaml b/conformance/resources/manifests/manifests.yaml index 190a8845e..b2ca4890a 100644 --- a/conformance/resources/manifests/manifests.yaml +++ b/conformance/resources/manifests/manifests.yaml @@ -23,7 +23,7 @@ metadata: gateway-conformance: backend --- -# Namespace for simple web server backends. This is expected by +# Namespace for simple web server backends. This is expected by # the upstream conformance suite's Setup method. apiVersion: v1 kind: Namespace @@ -50,8 +50,27 @@ spec: protocol: HTTP allowedRoutes: namespaces: - from: All + from: All kinds: # Allows HTTPRoutes to attach, which can then reference InferencePools. - group: gateway.networking.k8s.io kind: HTTPRoute + +--- +# --- Conformance Secondary Gateway Definition --- +# A second generic Gateway resource for tests requiring multiple Gateways. +apiVersion: gateway.networking.k8s.io/v1 +kind: Gateway +metadata: + name: conformance-secondary-gateway + namespace: gateway-conformance-infra +spec: + gatewayClassName: "{GATEWAY_CLASS_NAME}" + listeners: + - name: http + port: 80 + protocol: HTTP + hostname: "secondary.example.com" # Distinct hostname to differentiate from conformance-gateway + allowedRoutes: + namespaces: + from: All diff --git a/conformance/tests/basic/inferencepool_accepted.go b/conformance/tests/basic/inferencepool_accepted.go index 442dd2277..d74f73829 100644 --- a/conformance/tests/basic/inferencepool_accepted.go +++ b/conformance/tests/basic/inferencepool_accepted.go @@ -41,7 +41,10 @@ var InferencePoolAccepted = suite.ConformanceTest{ ShortName: "InferencePoolAccepted", Description: "A minimal InferencePool resource should be accepted by the controller and report an Accepted condition", Manifests: []string{"tests/basic/inferencepool_accepted.yaml"}, - Features: []features.FeatureName{}, + Features: []features.FeatureName{ + features.FeatureName("SupportInferencePool"), + features.SupportGateway, + }, Test: func(t *testing.T, s *suite.ConformanceTestSuite) { // created by the associated manifest file. poolNN := types.NamespacedName{Name: "inferencepool-basic-accepted", Namespace: "gateway-conformance-app-backend"} diff --git a/conformance/tests/basic/inferencepool_resolvedrefs_condition.go b/conformance/tests/basic/inferencepool_resolvedrefs_condition.go new file mode 100644 index 000000000..86ca54263 --- /dev/null +++ b/conformance/tests/basic/inferencepool_resolvedrefs_condition.go @@ -0,0 +1,104 @@ +/* +Copyright 2025 The Kubernetes 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 basic + +import ( + "context" + "testing" + "time" + + "github.com/stretchr/testify/require" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/types" + gatewayv1 "sigs.k8s.io/gateway-api/apis/v1" + "sigs.k8s.io/gateway-api/conformance/utils/suite" + "sigs.k8s.io/gateway-api/pkg/features" + + "sigs.k8s.io/gateway-api-inference-extension/conformance/tests" + k8sutils "sigs.k8s.io/gateway-api-inference-extension/conformance/utils/kubernetes" +) + +func init() { + tests.ConformanceTests = append(tests.ConformanceTests, InferencePoolParentStatus) +} + +var InferencePoolParentStatus = suite.ConformanceTest{ + ShortName: "InferencePoolResolvedRefsCondition", + Description: "Verify that an InferencePool correctly updates its parent-specific status (e.g., Accepted condition) when referenced by HTTPRoutes attached to shared Gateways, and clears parent statuses when no longer referenced.", + Manifests: []string{"tests/basic/inferencepool_resolvedrefs_condition.yaml"}, + Features: []features.FeatureName{ + features.FeatureName("SupportInferencePool"), + features.SupportGateway, + }, + Test: func(t *testing.T, s *suite.ConformanceTestSuite) { + const ( + appBackendNamespace = "gateway-conformance-app-backend" + infraNamespace = "gateway-conformance-infra" + poolName = "multi-gateway-pool" + sharedGateway1Name = "conformance-gateway" + sharedGateway2Name = "conformance-secondary-gateway" + httpRoute1Name = "httproute-for-gw1" + httpRoute2Name = "httproute-for-gw2" + ) + + poolNN := types.NamespacedName{Name: poolName, Namespace: appBackendNamespace} + httpRoute1NN := types.NamespacedName{Name: httpRoute1Name, Namespace: appBackendNamespace} + httpRoute2NN := types.NamespacedName{Name: httpRoute2Name, Namespace: appBackendNamespace} + gateway1NN := types.NamespacedName{Name: sharedGateway1Name, Namespace: infraNamespace} + gateway2NN := types.NamespacedName{Name: sharedGateway2Name, Namespace: infraNamespace} + + k8sutils.HTTPRouteMustBeAcceptedAndResolved(t, s.Client, s.TimeoutConfig, httpRoute1NN, gateway1NN) + k8sutils.HTTPRouteMustBeAcceptedAndResolved(t, s.Client, s.TimeoutConfig, httpRoute2NN, gateway2NN) + + t.Run("InferencePool should show Accepted:True by parents when referenced by multiple HTTPRoutes", func(t *testing.T) { + k8sutils.InferencePoolMustBeAcceptedByParent(t, s.Client, poolNN) + // TODO(#865) ensure requests are correctly routed to this InferencePool. + t.Logf("InferencePool %s has parent status Accepted:True as expected with two references.", poolNN.String()) + }) + + t.Run("Delete httproute-for-gw1", func(t *testing.T) { + httproute1 := &gatewayv1.HTTPRoute{ + ObjectMeta: metav1.ObjectMeta{Name: httpRoute1NN.Name, Namespace: httpRoute1NN.Namespace}, + } + t.Logf("Deleting HTTPRoute %s", httpRoute1NN.String()) + require.NoError(t, s.Client.Delete(context.TODO(), httproute1), "failed to delete httproute-for-gw1") + time.Sleep(s.TimeoutConfig.GatewayMustHaveCondition) + }) + + t.Run("InferencePool should still show Accepted:True by parent after one HTTPRoute is deleted", func(t *testing.T) { + k8sutils.InferencePoolMustBeAcceptedByParent(t, s.Client, poolNN) + // TODO(#865) ensure requests are correctly routed to this InferencePool. + t.Logf("InferencePool %s still has parent status Accepted:True as expected with one reference remaining.", poolNN.String()) + }) + + t.Run("Delete httproute-for-gw2", func(t *testing.T) { + httproute2 := &gatewayv1.HTTPRoute{ + ObjectMeta: metav1.ObjectMeta{Name: httpRoute2NN.Name, Namespace: httpRoute2NN.Namespace}, + } + t.Logf("Deleting HTTPRoute %s", httpRoute2NN.String()) + require.NoError(t, s.Client.Delete(context.TODO(), httproute2), "failed to delete httproute-for-gw2") + }) + + t.Run("InferencePool should have no parent statuses after all HTTPRoutes are deleted", func(t *testing.T) { + t.Logf("Waiting for InferencePool %s to have no parent statuses.", poolNN.String()) + k8sutils.InferencePoolMustHaveNoParents(t, s.Client, poolNN) + t.Logf("InferencePool %s correctly shows no parent statuses, indicating it's no longer referenced.", poolNN.String()) + }) + + t.Logf("InferencePoolResolvedRefsCondition completed.") + }, +} diff --git a/conformance/tests/basic/inferencepool_resolvedrefs_condition.yaml b/conformance/tests/basic/inferencepool_resolvedrefs_condition.yaml new file mode 100644 index 000000000..008893117 --- /dev/null +++ b/conformance/tests/basic/inferencepool_resolvedrefs_condition.yaml @@ -0,0 +1,131 @@ +# conformance/tests/basic/inferencepool_resolvedrefs_condition.yaml + +# This manifest defines the initial resources for the +# inferencepool_resolvedrefs_condition.go conformance test. + +# --- Backend Deployment (using agnhost echo server) --- +# This Deployment provides Pods for the InferencePool to select. +apiVersion: apps/v1 +kind: Deployment +metadata: + name: infra-backend-deployment + namespace: gateway-conformance-app-backend + labels: + app: infra-backend +spec: + replicas: 1 + selector: + matchLabels: + app: infra-backend + template: + metadata: + labels: + app: infra-backend + spec: + containers: + - name: agnhost-echo + image: k8s.gcr.io/e2e-test-images/agnhost:2.39 + args: + - serve-hostname + - --port=8080 + ports: + - name: http + containerPort: 8080 + readinessProbe: + httpGet: + path: / + port: 8080 + initialDelaySeconds: 3 + periodSeconds: 5 + failureThreshold: 2 + +--- +# --- Backend Service --- +# Service for the infra-backend-deployment. +apiVersion: v1 +kind: Service +metadata: + name: infra-backend-svc + namespace: gateway-conformance-app-backend +spec: + selector: + app: infra-backend + ports: + - name: http + protocol: TCP + port: 8080 + targetPort: 8080 + - name: epp + port: 9002 + targetPort: 9002 + +--- +# --- InferencePool Definition --- +apiVersion: inference.networking.x-k8s.io/v1alpha2 +kind: InferencePool +metadata: + name: multi-gateway-pool # Name used in the Go test + namespace: gateway-conformance-app-backend # Defined in base manifests.yaml +spec: + # --- Selector (Required) --- + # Selects the Pods belonging to this pool. + selector: + app: "infra-backend" + # --- Target Port (Required) --- + targetPortNumber: 8080 + extensionRef: + name: infra-backend-svc + +--- +# --- HTTPRoute for Gateway 1 (conformance-gateway) --- +apiVersion: gateway.networking.k8s.io/v1 +kind: HTTPRoute +metadata: + name: httproute-for-gw1 + namespace: gateway-conformance-app-backend +spec: + parentRefs: + - group: gateway.networking.k8s.io + kind: Gateway + name: conformance-gateway + namespace: gateway-conformance-infra + sectionName: http + hostnames: + - "gw1.example.com" + rules: + - backendRefs: + - group: inference.networking.x-k8s.io + kind: InferencePool + name: multi-gateway-pool + port: 8080 + matches: + - path: + type: PathPrefix + value: /conformance-gateway-test + +--- +# --- HTTPRoute for Gateway 2 (conformance-secondary-gateway) --- +apiVersion: gateway.networking.k8s.io/v1 +kind: HTTPRoute +metadata: + name: httproute-for-gw2 + namespace: gateway-conformance-app-backend +spec: + parentRefs: + - group: gateway.networking.k8s.io + kind: Gateway + name: conformance-secondary-gateway + namespace: gateway-conformance-infra + sectionName: http + hostnames: + - "secondary.example.com" + rules: + - backendRefs: + - group: inference.networking.x-k8s.io + kind: InferencePool + name: multi-gateway-pool + port: 8080 + matches: + - path: + type: PathPrefix + value: /gateway-2-test diff --git a/conformance/utils/kubernetes/helpers.go b/conformance/utils/kubernetes/helpers.go index fbe24b577..a862cfbd7 100644 --- a/conformance/utils/kubernetes/helpers.go +++ b/conformance/utils/kubernetes/helpers.go @@ -34,8 +34,12 @@ import ( // Import the Inference Extension API types inferenceapi "sigs.k8s.io/gateway-api-inference-extension/api/v1alpha2" // Adjust if your API version is different - // Import necessary utilities from the core Gateway API conformance suite + // Import local config for Inference Extension "sigs.k8s.io/gateway-api-inference-extension/conformance/utils/config" + // Import necessary utilities from the core Gateway API conformance suite + gatewayv1 "sigs.k8s.io/gateway-api/apis/v1" + gatewayapiconfig "sigs.k8s.io/gateway-api/conformance/utils/config" + gatewayk8sutils "sigs.k8s.io/gateway-api/conformance/utils/kubernetes" ) // checkCondition is a helper function similar to findConditionInList or CheckCondition @@ -152,3 +156,103 @@ func InferencePoolMustHaveCondition(t *testing.T, c client.Client, poolNN types. } t.Log(logMsg) } + +// InferencePoolMustHaveNoParents waits for the specified InferencePool resource +// to exist and report that it has no parent references in its status. +// This typically indicates it is no longer referenced by any Gateway API resources. +func InferencePoolMustHaveNoParents(t *testing.T, c client.Client, poolNN types.NamespacedName) { + t.Helper() + + var lastObservedPool *inferenceapi.InferencePool + var lastError error + var timeoutConfig config.InferenceExtensionTimeoutConfig = config.DefaultInferenceExtensionTimeoutConfig() + + ctx := context.Background() + waitErr := wait.PollUntilContextTimeout( + ctx, + + timeoutConfig.InferencePoolMustHaveConditionInterval, + timeoutConfig.InferencePoolMustHaveConditionTimeout, + true, + func(pollCtx context.Context) (bool, error) { + pool := &inferenceapi.InferencePool{} + err := c.Get(pollCtx, poolNN, pool) + if err != nil { + if apierrors.IsNotFound(err) { + t.Logf("InferencePool %s not found. Considering this as having no parents.", poolNN.String()) + lastError = nil + return true, nil + } + t.Logf("Error fetching InferencePool %s: %v. Retrying.", poolNN.String(), err) + lastError = err + return false, nil + } + lastObservedPool = pool + lastError = nil + + if len(pool.Status.Parents) == 0 { + t.Logf("InferencePool %s successfully has no parent statuses.", poolNN.String()) + return true, nil + } + t.Logf("InferencePool %s still has %d parent statuses. Waiting...", poolNN.String(), len(pool.Status.Parents)) + return false, nil + }) + + if waitErr != nil { + debugMsg := fmt.Sprintf("Timed out waiting for InferencePool %s to have no parent statuses.", poolNN.String()) + if lastError != nil { + debugMsg += fmt.Sprintf(" Last error during fetching: %v.", lastError) + } + if lastObservedPool != nil && len(lastObservedPool.Status.Parents) > 0 { + debugMsg += fmt.Sprintf(" Last observed InferencePool still had %d parent(s):", len(lastObservedPool.Status.Parents)) + } else if lastError == nil && (lastObservedPool == nil || len(lastObservedPool.Status.Parents) == 0) { + debugMsg += " Polling completed without timeout, but an unexpected waitErr occurred." + } + require.FailNow(t, debugMsg, waitErr) + } + t.Logf("Successfully verified that InferencePool %s has no parent statuses.", poolNN.String()) +} + +// HTTPRouteMustBeAcceptedAndResolved waits for the specified HTTPRoute +// to be Accepted and have its references resolved by the specified Gateway. +// It uses the upstream Gateway API's HTTPRouteMustHaveCondition helper. +func HTTPRouteMustBeAcceptedAndResolved(t *testing.T, c client.Client, timeoutConfig gatewayapiconfig.TimeoutConfig, routeNN, gatewayNN types.NamespacedName) { + t.Helper() + + acceptedCondition := metav1.Condition{ + Type: string(gatewayv1.RouteConditionAccepted), + Status: metav1.ConditionTrue, + Reason: string(gatewayv1.RouteReasonAccepted), + } + + resolvedRefsCondition := metav1.Condition{ + Type: string(gatewayv1.RouteConditionResolvedRefs), + Status: metav1.ConditionTrue, + Reason: string(gatewayv1.RouteReasonResolvedRefs), + } + + t.Logf("Waiting for HTTPRoute %s to be Accepted by Gateway %s", routeNN.String(), gatewayNN.String()) + gatewayk8sutils.HTTPRouteMustHaveCondition(t, c, timeoutConfig, routeNN, gatewayNN, acceptedCondition) + + t.Logf("Waiting for HTTPRoute %s to have ResolvedRefs by Gateway %s", routeNN.String(), gatewayNN.String()) + gatewayk8sutils.HTTPRouteMustHaveCondition(t, c, timeoutConfig, routeNN, gatewayNN, resolvedRefsCondition) + + t.Logf("HTTPRoute %s is now Accepted and has ResolvedRefs by Gateway %s", routeNN.String(), gatewayNN.String()) +} + +// InferencePoolMustBeAcceptedByParent waits for the specified InferencePool +// to report an Accepted condition with status True and reason "Accepted" +// from at least one of its parent Gateways. +func InferencePoolMustBeAcceptedByParent(t *testing.T, c client.Client, poolNN types.NamespacedName) { + t.Helper() + + acceptedByParentCondition := metav1.Condition{ + Type: string(gatewayv1.GatewayConditionAccepted), + Status: metav1.ConditionTrue, + Reason: string(gatewayv1.GatewayReasonAccepted), // Expecting the standard "Accepted" reason + } + + t.Logf("Waiting for InferencePool %s to be Accepted by a parent Gateway (Reason: %s)", poolNN.String(), gatewayv1.GatewayReasonAccepted) + InferencePoolMustHaveCondition(t, c, poolNN, acceptedByParentCondition) + t.Logf("InferencePool %s is Accepted by a parent Gateway (Reason: %s)", poolNN.String(), gatewayv1.GatewayReasonAccepted) +}