Skip to content

Commit a0cfd24

Browse files
authored
feat(conformance): Add EPP conformance test for Gateway routing (#961)
* Add gateway_following_epp_routing test. * One working version. * Okay version of GatwayFollowingEPPRouting conformance test. * fix typos and formats. * upgrader gateway-api versino to use the updated conformance testutils and small refactor. * use AllowCRDsMismatch to bypass. * format. * refactor more. * wire up the flag. * Refine test cases. * Refine log error info. * small timeout twek. * use common resource. * back to depend on gateway-api 1.30. * update go.sum. * format. * resolve minor comments. * remove seen logic. * trailing new line.
1 parent 506295a commit a0cfd24

File tree

7 files changed

+532
-3
lines changed

7 files changed

+532
-3
lines changed

conformance/conformance.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,6 @@ import (
4545
// Import necessary types and utilities from the core Gateway API conformance suite.
4646
gatewayv1 "sigs.k8s.io/gateway-api/apis/v1" // Import core Gateway API types
4747
confapis "sigs.k8s.io/gateway-api/conformance/apis/v1" // Report struct definition
48-
confconfig "sigs.k8s.io/gateway-api/conformance/utils/config"
4948
confflags "sigs.k8s.io/gateway-api/conformance/utils/flags"
5049
apikubernetes "sigs.k8s.io/gateway-api/conformance/utils/kubernetes"
5150
confsuite "sigs.k8s.io/gateway-api/conformance/utils/suite"
@@ -167,7 +166,7 @@ func DefaultOptions(t *testing.T) confsuite.ConformanceOptions {
167166
Debug: *confflags.ShowDebug,
168167
CleanupBaseResources: *confflags.CleanupBaseResources,
169168
SupportedFeatures: sets.New[features.FeatureName](),
170-
TimeoutConfig: confconfig.DefaultTimeoutConfig(),
169+
TimeoutConfig: inferenceconfig.DefaultInferenceExtensionTimeoutConfig().TimeoutConfig,
171170
SkipTests: skipTests,
172171
ExemptFeatures: exemptFeatures,
173172
RunTest: *confflags.RunTest,
@@ -177,6 +176,7 @@ func DefaultOptions(t *testing.T) confsuite.ConformanceOptions {
177176
ManifestFS: []fs.FS{&Manifests},
178177
ReportOutputPath: *confflags.ReportOutput,
179178
SkipProvisionalTests: *confflags.SkipProvisionalTests,
179+
AllowCRDsMismatch: *confflags.AllowCRDsMismatch,
180180
NamespaceLabels: namespaceLabels,
181181
NamespaceAnnotations: namespaceAnnotations,
182182
// TODO: Add the inference extension specific fields to ConformanceOptions struct if needed,

conformance/resources/manifests/manifests.yaml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,7 @@ metadata:
6666
labels:
6767
app: primary-inference-model-server
6868
spec:
69+
replicas: 3
6970
selector:
7071
matchLabels:
7172
app: primary-inference-model-server
Lines changed: 194 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,194 @@
1+
/*
2+
Copyright 2025 The Kubernetes Authors.
3+
4+
Licensed under the Apache License, Version 2.0 (the "License");
5+
you may not use this file except in compliance with the License.
6+
You may obtain a copy of the License at
7+
8+
http://www.apache.org/licenses/LICENSE-2.0
9+
10+
Unless required by applicable law or agreed to in writing, software
11+
distributed under the License is distributed on an "AS IS" BASIS,
12+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
See the License for the specific language governing permissions and
14+
limitations under the License.
15+
*/
16+
17+
package basic
18+
19+
import (
20+
"fmt"
21+
"net/http"
22+
"slices"
23+
"strings"
24+
"testing"
25+
26+
"github.com/stretchr/testify/require"
27+
"golang.org/x/sync/errgroup"
28+
"k8s.io/apimachinery/pkg/types"
29+
"sigs.k8s.io/gateway-api/conformance/utils/suite"
30+
"sigs.k8s.io/gateway-api/pkg/features"
31+
32+
"sigs.k8s.io/gateway-api-inference-extension/conformance/tests"
33+
k8sutils "sigs.k8s.io/gateway-api-inference-extension/conformance/utils/kubernetes"
34+
"sigs.k8s.io/gateway-api-inference-extension/conformance/utils/traffic"
35+
trafficutils "sigs.k8s.io/gateway-api-inference-extension/conformance/utils/traffic"
36+
gwhttp "sigs.k8s.io/gateway-api/conformance/utils/http"
37+
)
38+
39+
func init() {
40+
// Register the GatewayFollowingEPPRouting test case with the conformance suite.
41+
// This ensures it will be discovered and run by the test runner.
42+
tests.ConformanceTests = append(tests.ConformanceTests, GatewayFollowingEPPRouting)
43+
}
44+
45+
// GatewayFollowingEPPRouting defines the test case for verifying gateway should send traffic to an endpoint in the list returned by EPP.
46+
var GatewayFollowingEPPRouting = suite.ConformanceTest{
47+
ShortName: "GatewayFollowingEPPRouting",
48+
Description: "Inference gateway should send traffic to an endpoint in the list returned by EPP",
49+
Manifests: []string{"tests/basic/gateway_following_epp_routing.yaml"},
50+
Features: []features.FeatureName{
51+
features.FeatureName("SupportInferencePool"),
52+
features.SupportGateway,
53+
},
54+
Test: func(t *testing.T, s *suite.ConformanceTestSuite) {
55+
const (
56+
appBackendNamespace = "gateway-conformance-app-backend"
57+
infraNamespace = "gateway-conformance-infra"
58+
hostname = "primary.example.com"
59+
path = "/primary-gateway-test"
60+
expectedPodReplicas = 3
61+
// eppSelectionHeaderName is the custom header used by the testing-EPP service
62+
// to determine which endpoint to select.
63+
eppSelectionHeaderName = "test-epp-endpoint-selection"
64+
appPodBackendPrefix = "primary-inference-model-server"
65+
)
66+
67+
httpRouteNN := types.NamespacedName{Name: "httproute-for-primary-gw", Namespace: appBackendNamespace}
68+
gatewayNN := types.NamespacedName{Name: "conformance-primary-gateway", Namespace: infraNamespace}
69+
poolNN := types.NamespacedName{Name: "primary-inference-pool", Namespace: appBackendNamespace}
70+
backendPodLabels := map[string]string{"app": "primary-inference-model-server"}
71+
72+
t.Log("Verifying HTTPRoute and InferencePool are accepted and the Gateway has an address.")
73+
k8sutils.HTTPRouteMustBeAcceptedAndResolved(t, s.Client, s.TimeoutConfig, httpRouteNN, gatewayNN)
74+
k8sutils.InferencePoolMustBeAcceptedByParent(t, s.Client, poolNN)
75+
gwAddr := k8sutils.GetGatewayEndpoint(t, s.Client, s.TimeoutConfig, gatewayNN)
76+
77+
t.Logf("Fetching backend pods with labels: %v", backendPodLabels)
78+
pods, err := k8sutils.GetPodsWithLabel(t, s.Client, appBackendNamespace, backendPodLabels)
79+
require.NoError(t, err, "Failed to get backend pods")
80+
require.Len(t, pods, expectedPodReplicas, "Expected to find %d backend pods, but found %d.", expectedPodReplicas, len(pods))
81+
82+
podIPs := make([]string, len(pods))
83+
podNames := make([]string, len(pods))
84+
for i, pod := range pods {
85+
podIPs[i] = pod.Status.PodIP
86+
podNames[i] = pod.Name
87+
}
88+
89+
requestBody := `{
90+
"model": "conformance-fake-model",
91+
"prompt": "Write as if you were a critic: San Francisco"
92+
}`
93+
94+
for i := 0; i < len(pods); i++ {
95+
// Send an initial request targeting a single pod and wait for it to be successful to ensure the Gateway and EPP
96+
// are functioning correctly before running the main test cases.
97+
trafficutils.MakeRequestWithRequestParamAndExpectSuccess(
98+
t,
99+
s.RoundTripper,
100+
s.TimeoutConfig,
101+
gwAddr,
102+
trafficutils.Request{
103+
Host: hostname,
104+
Path: path,
105+
Headers: map[string]string{eppSelectionHeaderName: podIPs[i]},
106+
Method: http.MethodPost,
107+
Body: requestBody,
108+
Backend: podNames[i],
109+
Namespace: appBackendNamespace,
110+
},
111+
)
112+
}
113+
114+
testCases := []struct {
115+
name string
116+
podIPsToBeReturnedByEPP []string
117+
expectAllRequestsRoutedWithinPodNames []string
118+
}{
119+
{
120+
name: "should route traffic to a single designated pod",
121+
podIPsToBeReturnedByEPP: []string{podIPs[2]},
122+
expectAllRequestsRoutedWithinPodNames: []string{podNames[2]},
123+
},
124+
{
125+
name: "should route traffic to two designated pods",
126+
podIPsToBeReturnedByEPP: []string{podIPs[0], podIPs[1]},
127+
expectAllRequestsRoutedWithinPodNames: []string{podNames[0], podNames[1]},
128+
},
129+
{
130+
name: "should route traffic to all available pods",
131+
podIPsToBeReturnedByEPP: []string{podIPs[0], podIPs[1], podIPs[2]},
132+
expectAllRequestsRoutedWithinPodNames: []string{podNames[0], podNames[1], podNames[2]},
133+
},
134+
}
135+
136+
for _, tc := range testCases {
137+
t.Run(tc.name, func(t *testing.T) {
138+
eppHeaderValue := strings.Join(tc.podIPsToBeReturnedByEPP, ",")
139+
headers := map[string]string{eppSelectionHeaderName: eppHeaderValue}
140+
141+
t.Logf("Sending request to %s with EPP header '%s: %s'", gwAddr, eppSelectionHeaderName, eppHeaderValue)
142+
t.Logf("Expecting traffic to be routed to pod: %v", tc.expectAllRequestsRoutedWithinPodNames)
143+
144+
assertTrafficOnlyReachesToExpectedPods(t, s, gwAddr, gwhttp.ExpectedResponse{
145+
Request: gwhttp.Request{
146+
Host: hostname,
147+
Path: path,
148+
Method: http.MethodPost,
149+
Headers: headers,
150+
},
151+
Response: gwhttp.Response{
152+
StatusCode: http.StatusOK,
153+
},
154+
Backend: appPodBackendPrefix,
155+
Namespace: appBackendNamespace,
156+
}, requestBody, tc.expectAllRequestsRoutedWithinPodNames)
157+
})
158+
}
159+
},
160+
}
161+
162+
func assertTrafficOnlyReachesToExpectedPods(t *testing.T, suite *suite.ConformanceTestSuite, gwAddr string, expected gwhttp.ExpectedResponse, requestBody string, expectedPodNames []string) {
163+
t.Helper()
164+
const (
165+
concurrentRequests = 10
166+
totalRequests = 100
167+
)
168+
var (
169+
roundTripper = suite.RoundTripper
170+
g errgroup.Group
171+
req = gwhttp.MakeRequest(t, &expected, gwAddr, "HTTP", "http")
172+
)
173+
g.SetLimit(concurrentRequests)
174+
for i := 0; i < totalRequests; i++ {
175+
g.Go(func() error {
176+
cReq, cRes, err := traffic.MakeCallRoundTripper(t, roundTripper, &traffic.RequestWithBody{Request: req, Body: strings.NewReader(requestBody)})
177+
if err != nil {
178+
return fmt.Errorf("failed to roundtrip request: %w", err)
179+
}
180+
if err := gwhttp.CompareRequest(t, &req, cReq, cRes, expected); err != nil {
181+
return fmt.Errorf("response expectation failed for request: %w", err)
182+
}
183+
184+
if slices.Contains(expectedPodNames, cReq.Pod) {
185+
return nil
186+
}
187+
return fmt.Errorf("request was handled by an unexpected pod %q", cReq.Pod)
188+
})
189+
}
190+
if err := g.Wait(); err != nil {
191+
t.Fatalf("Not all the requests are sent to the expectedPods successfully, err: %v", err)
192+
}
193+
t.Logf("Traffic successfully reached only to expected pods: %v", expectedPodNames)
194+
}
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
# --- InferenceModel Definition ---
2+
# Service for the infra-backend-deployment.
3+
apiVersion: inference.networking.x-k8s.io/v1alpha2
4+
kind: InferenceModel
5+
metadata:
6+
name: conformance-fake-model-server
7+
namespace: gateway-conformance-app-backend
8+
spec:
9+
modelName: conformance-fake-model
10+
criticality: Critical # Mark it as critical to bypass the saturation check since the model server is fake and don't have such metrics.
11+
poolRef:
12+
name: primary-inference-pool
13+
---
14+
# --- HTTPRoute for Primary Gateway (conformance-gateway) ---
15+
apiVersion: gateway.networking.k8s.io/v1
16+
kind: HTTPRoute
17+
metadata:
18+
name: httproute-for-primary-gw
19+
namespace: gateway-conformance-app-backend
20+
spec:
21+
parentRefs:
22+
- group: gateway.networking.k8s.io
23+
kind: Gateway
24+
name: conformance-primary-gateway
25+
namespace: gateway-conformance-infra
26+
sectionName: http
27+
hostnames:
28+
- "primary.example.com"
29+
rules:
30+
- backendRefs:
31+
- group: inference.networking.x-k8s.io
32+
kind: InferencePool
33+
name: primary-inference-pool
34+
matches:
35+
- path:
36+
type: PathPrefix
37+
value: /primary-gateway-test
38+

conformance/utils/config/timing.go

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ import (
2020
"time"
2121

2222
// Import the upstream Gateway API timeout config
23+
2324
gatewayconfig "sigs.k8s.io/gateway-api/conformance/utils/config"
2425
)
2526

@@ -44,8 +45,12 @@ type InferenceExtensionTimeoutConfig struct {
4445

4546
// DefaultInferenceExtensionTimeoutConfig returns a new InferenceExtensionTimeoutConfig with default values.
4647
func DefaultInferenceExtensionTimeoutConfig() InferenceExtensionTimeoutConfig {
48+
config := gatewayconfig.DefaultTimeoutConfig()
49+
config.HTTPRouteMustHaveCondition = 300 * time.Second
50+
config.MaxTimeToConsistency = 200 * time.Second
51+
config.DefaultTestTimeout = 600 * time.Second
4752
return InferenceExtensionTimeoutConfig{
48-
TimeoutConfig: gatewayconfig.DefaultTimeoutConfig(),
53+
TimeoutConfig: config, // Initialize embedded struct
4954
GeneralMustHaveConditionTimeout: 300 * time.Second,
5055
InferencePoolMustHaveConditionInterval: 10 * time.Second,
5156
GatewayObjectPollInterval: 5 * time.Second,

conformance/utils/kubernetes/helpers.go

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -308,6 +308,28 @@ func GetGatewayEndpoint(t *testing.T, k8sClient client.Client, timeoutConfig gat
308308
return gwAddr
309309
}
310310

311+
// GetPodsWithLabel retrieves a list of Pods.
312+
// It finds pods matching the given labels in a specific namespace.
313+
func GetPodsWithLabel(t *testing.T, c client.Client, namespace string, labels map[string]string) ([]corev1.Pod, error) {
314+
t.Helper()
315+
316+
podList := &corev1.PodList{}
317+
listOptions := []client.ListOption{
318+
client.InNamespace(namespace),
319+
client.MatchingLabels(labels),
320+
}
321+
322+
t.Logf("Searching for Pods with labels %v in namespace %s", labels, namespace)
323+
if err := c.List(context.Background(), podList, listOptions...); err != nil {
324+
return nil, fmt.Errorf("failed to list pods with labels '%v' in namespace '%s': %w", labels, namespace, err)
325+
}
326+
327+
if len(podList.Items) == 0 {
328+
return nil, fmt.Errorf("no pods found with labels '%v' in namespace '%s'", labels, namespace)
329+
}
330+
return podList.Items, nil
331+
}
332+
311333
// GetPod waits for a Pod matching the specified labels to exist in the given
312334
// namespace and have an IP address assigned. This function returns the first
313335
// matching Pod found if there are multiple matches. It fails the on timeout or error.

0 commit comments

Comments
 (0)