Skip to content

Commit eb75cda

Browse files
conformance tests for percentage-based request mirroring (#3508)
* conformance tests for percentage-based request mirroring * replace multi-mirror test cases * add TODOs with issues * fix linting issues and add sleep * dedicated TimeoutConfig
1 parent 6711203 commit eb75cda

File tree

9 files changed

+417
-32
lines changed

9 files changed

+417
-32
lines changed

conformance/tests/httproute-request-mirror.go

Lines changed: 16 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -57,10 +57,14 @@ var HTTPRouteRequestMirror = suite.ConformanceTest{
5757
},
5858
},
5959
Backend: "infra-backend-v1",
60-
MirroredTo: []http.BackendRef{{
61-
Name: "infra-backend-v2",
62-
Namespace: ns,
63-
}},
60+
MirroredTo: []http.MirroredBackend{
61+
{
62+
BackendRef: http.BackendRef{
63+
Name: "infra-backend-v2",
64+
Namespace: ns,
65+
},
66+
},
67+
},
6468
Namespace: ns,
6569
},
6670
{
@@ -84,10 +88,14 @@ var HTTPRouteRequestMirror = suite.ConformanceTest{
8488
},
8589
Namespace: ns,
8690
Backend: "infra-backend-v1",
87-
MirroredTo: []http.BackendRef{{
88-
Name: "infra-backend-v2",
89-
Namespace: ns,
90-
}},
91+
MirroredTo: []http.MirroredBackend{
92+
{
93+
BackendRef: http.BackendRef{
94+
Name: "infra-backend-v2",
95+
Namespace: ns,
96+
},
97+
},
98+
},
9199
},
92100
}
93101
for i := range testCases {

conformance/tests/httproute-request-multiple-mirrors.go

Lines changed: 18 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -58,14 +58,18 @@ var HTTPRouteRequestMultipleMirrors = suite.ConformanceTest{
5858
},
5959
},
6060
Backend: "infra-backend-v1",
61-
MirroredTo: []http.BackendRef{
61+
MirroredTo: []http.MirroredBackend{
6262
{
63-
Name: "infra-backend-v2",
64-
Namespace: ns,
63+
BackendRef: http.BackendRef{
64+
Name: "infra-backend-v2",
65+
Namespace: ns,
66+
},
6567
},
6668
{
67-
Name: "infra-backend-v3",
68-
Namespace: ns,
69+
BackendRef: http.BackendRef{
70+
Name: "infra-backend-v3",
71+
Namespace: ns,
72+
},
6973
},
7074
},
7175
Namespace: ns,
@@ -90,14 +94,18 @@ var HTTPRouteRequestMultipleMirrors = suite.ConformanceTest{
9094
},
9195
Namespace: ns,
9296
Backend: "infra-backend-v1",
93-
MirroredTo: []http.BackendRef{
97+
MirroredTo: []http.MirroredBackend{
9498
{
95-
Name: "infra-backend-v2",
96-
Namespace: ns,
99+
BackendRef: http.BackendRef{
100+
Name: "infra-backend-v2",
101+
Namespace: ns,
102+
},
97103
},
98104
{
99-
Name: "infra-backend-v3",
100-
Namespace: ns,
105+
BackendRef: http.BackendRef{
106+
Name: "infra-backend-v3",
107+
Namespace: ns,
108+
},
101109
},
102110
},
103111
},
Lines changed: 236 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,236 @@
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 tests
18+
19+
import (
20+
"errors"
21+
"fmt"
22+
"regexp"
23+
"sync"
24+
"testing"
25+
"time"
26+
27+
"github.com/stretchr/testify/require"
28+
"k8s.io/apimachinery/pkg/types"
29+
"k8s.io/utils/ptr"
30+
31+
"sigs.k8s.io/gateway-api/conformance/utils/config"
32+
"sigs.k8s.io/gateway-api/conformance/utils/http"
33+
"sigs.k8s.io/gateway-api/conformance/utils/kubernetes"
34+
"sigs.k8s.io/gateway-api/conformance/utils/roundtripper"
35+
"sigs.k8s.io/gateway-api/conformance/utils/suite"
36+
"sigs.k8s.io/gateway-api/conformance/utils/tlog"
37+
"sigs.k8s.io/gateway-api/pkg/features"
38+
)
39+
40+
const (
41+
concurrentRequests = 10
42+
tolerancePercentage = 5.0
43+
totalRequests = 500.0
44+
numDistributionChecks = 5
45+
)
46+
47+
func init() {
48+
ConformanceTests = append(ConformanceTests, HTTPRouteRequestPercentageMirror)
49+
}
50+
51+
var HTTPRouteRequestPercentageMirror = suite.ConformanceTest{
52+
ShortName: "HTTPRouteRequestPercentageMirror",
53+
Description: "An HTTPRoute with percentage based request mirroring",
54+
Manifests: []string{"tests/httproute-request-percentage-mirror.yaml"},
55+
Features: []features.FeatureName{
56+
features.SupportGateway,
57+
features.SupportHTTPRoute,
58+
features.SupportHTTPRouteRequestPercentageMirror,
59+
},
60+
Provisional: true,
61+
Test: func(t *testing.T, suite *suite.ConformanceTestSuite) {
62+
var (
63+
ns = "gateway-conformance-infra"
64+
routeNN = types.NamespacedName{Name: "request-percentage-mirror", Namespace: ns}
65+
gwNN = types.NamespacedName{Name: "same-namespace", Namespace: ns}
66+
gwAddr = kubernetes.GatewayAndHTTPRoutesMustBeAccepted(t, suite.Client, suite.TimeoutConfig, suite.ControllerName, kubernetes.NewGatewayRef(gwNN), routeNN)
67+
)
68+
69+
kubernetes.HTTPRouteMustHaveResolvedRefsConditionsTrue(t, suite.Client, suite.TimeoutConfig, routeNN, gwNN)
70+
71+
testCases := []http.ExpectedResponse{
72+
{
73+
Request: http.Request{Path: "/percent-mirror"},
74+
Namespace: ns,
75+
ExpectedRequest: &http.ExpectedRequest{
76+
Request: http.Request{
77+
Path: "/percent-mirror",
78+
},
79+
},
80+
Backend: "infra-backend-v1",
81+
MirroredTo: []http.MirroredBackend{
82+
{
83+
BackendRef: http.BackendRef{
84+
Name: "infra-backend-v2",
85+
Namespace: ns,
86+
},
87+
Percent: ptr.To(int32(20)),
88+
},
89+
},
90+
}, {
91+
Request: http.Request{Path: "/percent-mirror-fraction"},
92+
Namespace: ns,
93+
ExpectedRequest: &http.ExpectedRequest{
94+
Request: http.Request{
95+
Path: "/percent-mirror-fraction",
96+
},
97+
},
98+
Backend: "infra-backend-v1",
99+
MirroredTo: []http.MirroredBackend{
100+
{
101+
BackendRef: http.BackendRef{
102+
Name: "infra-backend-v2",
103+
Namespace: ns,
104+
},
105+
Percent: ptr.To(int32(50)),
106+
},
107+
},
108+
}, {
109+
Request: http.Request{
110+
Path: "/percent-mirror-and-modify-headers",
111+
Headers: map[string]string{
112+
"X-Header-Remove": "remove-val",
113+
"X-Header-Add-Append": "append-val-1",
114+
},
115+
},
116+
ExpectedRequest: &http.ExpectedRequest{
117+
Request: http.Request{
118+
Path: "/percent-mirror-and-modify-headers",
119+
Headers: map[string]string{
120+
"X-Header-Add": "header-val-1",
121+
"X-Header-Add-Append": "append-val-1,header-val-2",
122+
"X-Header-Set": "set-overwrites-values",
123+
},
124+
},
125+
AbsentHeaders: []string{"X-Header-Remove"},
126+
},
127+
Namespace: ns,
128+
Backend: "infra-backend-v1",
129+
MirroredTo: []http.MirroredBackend{
130+
{
131+
BackendRef: http.BackendRef{
132+
Name: "infra-backend-v2",
133+
Namespace: ns,
134+
},
135+
Percent: ptr.To(int32(35)),
136+
},
137+
},
138+
},
139+
}
140+
141+
for i := range testCases {
142+
expected := testCases[i]
143+
t.Run(expected.GetTestCaseName(i), func(t *testing.T) {
144+
// Assert request succeeds before doing our distribution check
145+
http.MakeRequestAndExpectEventuallyConsistentResponse(t, suite.RoundTripper, suite.TimeoutConfig, gwAddr, expected)
146+
147+
// Override to not have more requests than expected
148+
var dedicatedTimeoutConfig config.TimeoutConfig
149+
dedicatedTimeoutConfig = suite.TimeoutConfig
150+
dedicatedTimeoutConfig.RequiredConsecutiveSuccesses = 1
151+
// used to limit number of parallel go routines
152+
semaphore := make(chan struct{}, concurrentRequests)
153+
var wg sync.WaitGroup
154+
155+
for k := 0; k < numDistributionChecks; k++ {
156+
timeNow := time.Now()
157+
for j := 0; j < totalRequests; j++ {
158+
wg.Add(1)
159+
semaphore <- struct{}{}
160+
go func(t *testing.T, r roundtripper.RoundTripper, timeoutConfig config.TimeoutConfig, gwAddr string, expected http.ExpectedResponse) {
161+
defer wg.Done()
162+
defer func() { <-semaphore }()
163+
http.MakeRequestAndExpectEventuallyConsistentResponse(t, r, timeoutConfig, gwAddr, expected)
164+
}(t, suite.RoundTripper, dedicatedTimeoutConfig, gwAddr, expected)
165+
}
166+
wg.Wait()
167+
if err := testMirroredRequestsDistribution(t, suite, expected, timeNow); err != nil {
168+
t.Logf("Traffic distribution test failed (%d/%d): %s", k+1, numDistributionChecks, err)
169+
time.Sleep(2 * time.Second)
170+
} else {
171+
return
172+
}
173+
}
174+
t.Fatal("Percentage based mirror distribution tests failed")
175+
})
176+
}
177+
},
178+
}
179+
180+
func testMirroredRequestsDistribution(t *testing.T, suite *suite.ConformanceTestSuite, expected http.ExpectedResponse, timeVal time.Time) error {
181+
mirrorPods := expected.MirroredTo
182+
for i, mirrorPod := range mirrorPods {
183+
if mirrorPod.Name == "" {
184+
tlog.Fatalf(t, "Mirrored BackendRef[%d].Name wasn't provided in the testcase, this test should only check http request mirror.", i)
185+
}
186+
}
187+
188+
var mu sync.Mutex
189+
mirroredCounts := make(map[string]int)
190+
191+
for _, mirrorPod := range mirrorPods {
192+
require.Eventually(t, func() bool {
193+
mirrorLogRegexp := regexp.MustCompile(fmt.Sprintf("Echoing back request made to \\%s to client", expected.Request.Path))
194+
195+
tlog.Log(t, "Searching for the mirrored request log")
196+
tlog.Logf(t, `Reading "%s/%s" logs`, mirrorPod.Namespace, mirrorPod.Name)
197+
logs, err := kubernetes.DumpEchoLogs(mirrorPod.Namespace, mirrorPod.Name, suite.Client, suite.Clientset, timeVal)
198+
if err != nil {
199+
tlog.Logf(t, `Couldn't read "%s/%s" logs: %v`, mirrorPod.Namespace, mirrorPod.Name, err)
200+
return false
201+
}
202+
203+
count := 0
204+
for _, log := range logs {
205+
if mirrorLogRegexp.MatchString(log) {
206+
count++
207+
}
208+
}
209+
mu.Lock()
210+
mirroredCounts[mirrorPod.Name] += count
211+
mu.Unlock()
212+
213+
return true
214+
}, 60*time.Second, time.Millisecond*100, fmt.Sprintf(`Couldn't verify the logs for "%s/%s"`, mirrorPod.Namespace, mirrorPod.Name))
215+
}
216+
217+
var errs []error
218+
219+
for _, mirrorPod := range mirrorPods {
220+
expected := float64(totalRequests) * float64(*mirrorPod.Percent) / 100.0
221+
minExpected := expected * (1 - tolerancePercentage/100)
222+
maxExpected := expected * (1 + tolerancePercentage/100)
223+
224+
actual := float64(mirroredCounts[mirrorPod.Name])
225+
tlog.Logf(t, "Pod: %s, Expected: %f (min: %f, max: %f), Actual: %f", mirrorPod.Name, expected, minExpected, maxExpected, actual)
226+
227+
if actual < minExpected || actual > maxExpected {
228+
errs = append(errs, fmt.Errorf("Pod %s did not meet the mirroring percentage within tolerance. Expected between %f and %f, but got %f", mirrorPod.Name, minExpected, maxExpected, actual))
229+
}
230+
}
231+
if len(errs) > 0 {
232+
return errors.Join(errs...)
233+
}
234+
tlog.Log(t, "Validated mirrored request logs across all desired backends within the given tolerance")
235+
return nil
236+
}
Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
apiVersion: gateway.networking.k8s.io/v1
2+
kind: HTTPRoute
3+
metadata:
4+
name: request-percentage-mirror
5+
namespace: gateway-conformance-infra
6+
spec:
7+
parentRefs:
8+
- name: same-namespace
9+
rules:
10+
- matches:
11+
- path:
12+
type: PathPrefix
13+
value: /percent-mirror
14+
filters:
15+
- type: RequestMirror
16+
requestMirror:
17+
backendRef:
18+
name: infra-backend-v2
19+
namespace: gateway-conformance-infra
20+
port: 8080
21+
percent: 20
22+
backendRefs:
23+
- name: infra-backend-v1
24+
port: 8080
25+
namespace: gateway-conformance-infra
26+
- matches:
27+
- path:
28+
type: PathPrefix
29+
value: /percent-mirror-fraction
30+
filters:
31+
- type: RequestMirror
32+
requestMirror:
33+
backendRef:
34+
name: infra-backend-v2
35+
namespace: gateway-conformance-infra
36+
port: 8080
37+
fraction:
38+
numerator: 25
39+
denominator: 50
40+
backendRefs:
41+
- name: infra-backend-v1
42+
port: 8080
43+
namespace: gateway-conformance-infra
44+
- matches:
45+
- path:
46+
type: PathPrefix
47+
value: /percent-mirror-and-modify-headers
48+
filters:
49+
- type: RequestHeaderModifier
50+
requestHeaderModifier:
51+
set:
52+
- name: X-Header-Set
53+
value: set-overwrites-values
54+
add:
55+
- name: X-Header-Add
56+
value: header-val-1
57+
- name: X-Header-Add-Append
58+
value: header-val-2
59+
remove:
60+
- X-Header-Remove
61+
- type: RequestMirror
62+
requestMirror:
63+
backendRef:
64+
name: infra-backend-v2
65+
namespace: gateway-conformance-infra
66+
port: 8080
67+
percent: 35
68+
backendRefs:
69+
- name: infra-backend-v1
70+
port: 8080
71+
namespace: gateway-conformance-infra

0 commit comments

Comments
 (0)