Merge current v3.6 into master

This commit is contained in:
mmatur
2026-01-09 20:28:45 +01:00
43 changed files with 730 additions and 79 deletions
+7 -1
View File
@@ -79,7 +79,13 @@ func Append(router *mux.Router, basePath string, customAssets fs.FS) error {
router.Methods(http.MethodGet).
Path(basePath).
HandlerFunc(func(resp http.ResponseWriter, req *http.Request) {
prefix := strings.TrimSuffix(req.Header.Get("X-Forwarded-Prefix"), "/")
xfPrefix := req.Header.Get("X-Forwarded-Prefix")
if strings.Contains(xfPrefix, "//") {
log.Error().Msgf("X-Forwarded-Prefix contains an invalid value: %s, defaulting to empty prefix", xfPrefix)
xfPrefix = ""
}
prefix := strings.TrimSuffix(xfPrefix, "/")
http.Redirect(resp, req, prefix+dashboardPath, http.StatusFound)
})
+48
View File
@@ -9,7 +9,9 @@ import (
"testing/fstest"
"time"
"github.com/gorilla/mux"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func Test_ContentSecurityPolicy(t *testing.T) {
@@ -60,6 +62,52 @@ func Test_ContentSecurityPolicy(t *testing.T) {
}
}
func Test_XForwardedPrefix(t *testing.T) {
testCases := []struct {
desc string
prefix string
expected string
}{
{
desc: "location in X-Forwarded-Prefix",
prefix: "//foobar/test",
expected: "/dashboard/",
},
{
desc: "scheme in X-Forwarded-Prefix",
prefix: "http://foobar",
expected: "/dashboard/",
},
{
desc: "path in X-Forwarded-Prefix",
prefix: "foobar",
expected: "/foobar/dashboard/",
},
}
router := mux.NewRouter()
err := Append(router, "/", fstest.MapFS{"index.html": &fstest.MapFile{
Mode: 0o755,
ModTime: time.Now(),
}})
require.NoError(t, err)
for _, test := range testCases {
t.Run(test.desc, func(t *testing.T) {
t.Parallel()
req := httptest.NewRequest(http.MethodGet, "/", http.NoBody)
req.Header.Set("X-Forwarded-Prefix", test.prefix)
rw := httptest.NewRecorder()
router.ServeHTTP(rw, req)
assert.Equal(t, http.StatusFound, rw.Code)
assert.Equal(t, test.expected, rw.Result().Header.Get("Location"))
})
}
}
type errorFS struct{}
func (e errorFS) Open(name string) (fs.File, error) {
+12
View File
@@ -317,6 +317,18 @@ func (c *Configuration) SetEffectiveConfiguration() {
c.Providers.KubernetesGateway.EntryPoints = entryPoints
}
// Configure Ingress NGINX provider.
if c.Providers.KubernetesIngressNGINX != nil {
var nonTLSEntryPoints []string
for epName, entryPoint := range c.EntryPoints {
if entryPoint.HTTP.TLS == nil {
nonTLSEntryPoints = append(nonTLSEntryPoints, epName)
}
}
c.Providers.KubernetesIngressNGINX.NonTLSEntryPoints = nonTLSEntryPoints
}
// Defines the default rule syntax for the Kubernetes Ingress Provider.
// This allows the provider to adapt the matcher syntax to the desired rule syntax version.
if c.Core != nil && c.Providers.KubernetesIngress != nil {
+5 -3
View File
@@ -921,11 +921,11 @@ func (p *Provider) renewCertificates(ctx context.Context, renewPeriod time.Durat
for _, cert := range certificates {
client, err := p.getClient()
if err != nil {
logger.Info().Err(err).Msgf("Error renewing certificate from LE : %+v", cert.Domain)
logger.Info().Err(err).Msgf("Error renewing ACME certificate: %+v", cert.Domain)
continue
}
logger.Info().Msgf("Renewing certificate from LE : %+v", cert.Domain)
logger.Info().Msgf("Renewing ACME certificate: %+v", cert.Domain)
res := certificate.Resource{
Domain: cert.Domain.Main,
@@ -935,12 +935,14 @@ func (p *Provider) renewCertificates(ctx context.Context, renewPeriod time.Durat
opts := &certificate.RenewOptions{
Bundle: true,
EmailAddresses: p.EmailAddresses,
Profile: p.Profile,
PreferredChain: p.PreferredChain,
}
renewedCert, err := client.Certificate.RenewWithOptions(res, opts)
if err != nil {
logger.Error().Err(err).Msgf("Error renewing certificate from LE: %v", cert.Domain)
logger.Error().Err(err).Msgf("Error renewing ACME certificate: %v", cert.Domain)
continue
}
@@ -0,0 +1,23 @@
---
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: ingress-with-no-annotation
namespace: default
spec:
ingressClassName: nginx
rules:
- host: whoami.localhost
http:
paths:
- backend:
service:
name: whoami
port:
number: 80
path: /
pathType: Prefix
tls:
- hosts:
- whoami.localhost
secretName: whoami-tls
@@ -0,0 +1,22 @@
---
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: ingress-with-use-regex
namespace: default
annotations:
nginx.ingress.kubernetes.io/use-regex: "true"
spec:
ingressClassName: nginx
rules:
- host: use-regex.localhost
http:
paths:
- path: /test(.*)
pathType: ImplementationSpecific
backend:
service:
name: whoami
port:
number: 80
@@ -80,6 +80,9 @@ type Provider struct {
DefaultBackendService string `description:"Service used to serve HTTP requests not matching any known server name (catch-all). Takes the form 'namespace/name'." json:"defaultBackendService,omitempty" toml:"defaultBackendService,omitempty" yaml:"defaultBackendService,omitempty" export:"true"`
DisableSvcExternalName bool `description:"Disable support for Services of type ExternalName." json:"disableSvcExternalName,omitempty" toml:"disableSvcExternalName,omitempty" yaml:"disableSvcExternalName,omitempty" export:"true"`
// NonTLSEntryPoints contains the names of entrypoints that are configured without TLS.
NonTLSEntryPoints []string `json:"-" toml:"-" yaml:"-" label:"-" file:"-"`
defaultBackendServiceNamespace string
defaultBackendServiceName string
@@ -800,7 +803,7 @@ func (p *Provider) applyMiddlewares(namespace, routerKey string, ingressConfig i
// Apply SSL redirect is mandatory to be applied after all other middlewares.
// TODO: check how to remove this, and create the HTTP router elsewhere.
applySSLRedirectConfiguration(routerKey, ingressConfig, hasTLS, rt, conf)
p.applySSLRedirectConfiguration(routerKey, ingressConfig, hasTLS, rt, conf)
applyUpstreamVhost(routerKey, ingressConfig, rt, conf)
@@ -1007,7 +1010,7 @@ func applyWhitelistSourceRangeConfiguration(routerName string, ingressConfig ing
rt.Middlewares = append(rt.Middlewares, whitelistSourceRangeMiddlewareName)
}
func applySSLRedirectConfiguration(routerName string, ingressConfig ingressConfig, hasTLS bool, rt *dynamic.Router, conf *dynamic.Configuration) {
func (p *Provider) applySSLRedirectConfiguration(routerName string, ingressConfig ingressConfig, hasTLS bool, rt *dynamic.Router, conf *dynamic.Configuration) {
var forceSSLRedirect bool
if ingressConfig.ForceSSLRedirect != nil {
forceSSLRedirect = *ingressConfig.ForceSSLRedirect
@@ -1019,7 +1022,9 @@ func applySSLRedirectConfiguration(routerName string, ingressConfig ingressConfi
// An Ingress with TLS configuration creates only a Traefik router with a TLS configuration,
// so no Non-TLS router exists to handle HTTP traffic, and we should create it.
httpRouter := &dynamic.Router{
Rule: rt.Rule,
// Only attach to entryPoint which do not activate TLS.
EntryPoints: p.NonTLSEntryPoints,
Rule: rt.Rule,
// "default" stands for the default rule syntax in Traefik v3, i.e. the v3 syntax.
RuleSyntax: "default",
Middlewares: rt.Middlewares,
@@ -1133,7 +1138,7 @@ func buildRule(host string, pa netv1.HTTPIngressPath, config ingressConfig) stri
rules = append(rules, fmt.Sprintf("Path(`%s`)", pa.Path))
case netv1.PathTypePrefix:
if ptr.Deref(config.UseRegex, false) {
rules = append(rules, fmt.Sprintf("PathRegexp(`^%s`)", regexp.QuoteMeta(pa.Path)))
rules = append(rules, fmt.Sprintf("PathRegexp(`^%s`)", pa.Path))
} else {
rules = append(rules, buildPrefixRule(pa.Path))
}
@@ -98,6 +98,76 @@ func TestLoadIngresses(t *testing.T) {
TLS: &dynamic.TLSConfiguration{},
},
},
{
desc: "No annotation",
paths: []string{
"ingresses/00-ingress-with-no-annotation.yml",
"ingressclasses.yml",
"services.yml",
"secrets.yml",
},
expected: &dynamic.Configuration{
TCP: &dynamic.TCPConfiguration{
Routers: map[string]*dynamic.TCPRouter{},
Services: map[string]*dynamic.TCPService{},
},
HTTP: &dynamic.HTTPConfiguration{
Routers: map[string]*dynamic.Router{
"default-ingress-with-no-annotation-rule-0-path-0": {
Rule: "Host(`whoami.localhost`) && PathPrefix(`/`)",
RuleSyntax: "default",
TLS: &dynamic.RouterTLSConfig{},
Service: "default-ingress-with-no-annotation-whoami-80",
},
"default-ingress-with-no-annotation-rule-0-path-0-http": {
EntryPoints: []string{"web"},
Rule: "Host(`whoami.localhost`) && PathPrefix(`/`)",
RuleSyntax: "default",
Middlewares: []string{"default-ingress-with-no-annotation-rule-0-path-0-redirect-scheme"},
Service: "noop@internal",
},
},
Middlewares: map[string]*dynamic.Middleware{
"default-ingress-with-no-annotation-rule-0-path-0-redirect-scheme": {
RedirectScheme: &dynamic.RedirectScheme{
Scheme: "https",
ForcePermanentRedirect: true,
},
},
},
Services: map[string]*dynamic.Service{
"default-ingress-with-no-annotation-whoami-80": {
LoadBalancer: &dynamic.ServersLoadBalancer{
Servers: []dynamic.Server{
{
URL: "http://10.10.0.1:80",
},
{
URL: "http://10.10.0.2:80",
},
},
Strategy: "wrr",
PassHostHeader: ptr.To(true),
ResponseForwarding: &dynamic.ResponseForwarding{
FlushInterval: dynamic.DefaultFlushInterval,
},
},
},
},
ServersTransports: map[string]*dynamic.ServersTransport{},
},
TLS: &dynamic.TLSConfiguration{
Certificates: []*tls.CertAndStores{
{
Certificate: tls.Certificate{
CertFile: "-----BEGIN CERTIFICATE-----",
KeyFile: "-----BEGIN CERTIFICATE-----",
},
},
},
},
},
},
{
desc: "Basic Auth",
paths: []string{
@@ -228,15 +298,17 @@ func TestLoadIngresses(t *testing.T) {
Service: "default-ingress-with-ssl-redirect-whoami-80",
},
"default-ingress-with-ssl-redirect-rule-0-path-0-http": {
EntryPoints: []string{"web"},
Rule: "Host(`sslredirect.localhost`) && Path(`/`)",
RuleSyntax: "default",
Middlewares: []string{"default-ingress-with-ssl-redirect-rule-0-path-0-redirect-scheme"},
Service: "noop@internal",
},
"default-ingress-without-ssl-redirect-rule-0-path-0-http": {
Rule: "Host(`withoutsslredirect.localhost`) && Path(`/`)",
RuleSyntax: "default",
Service: "default-ingress-without-ssl-redirect-whoami-80",
EntryPoints: []string{"web"},
Rule: "Host(`withoutsslredirect.localhost`) && Path(`/`)",
RuleSyntax: "default",
Service: "default-ingress-without-ssl-redirect-whoami-80",
},
"default-ingress-without-ssl-redirect-rule-0-path-0": {
Rule: "Host(`withoutsslredirect.localhost`) && Path(`/`)",
@@ -637,6 +709,51 @@ func TestLoadIngresses(t *testing.T) {
TLS: &dynamic.TLSConfiguration{},
},
},
{
desc: "Use Regex",
paths: []string{
"services.yml",
"ingressclasses.yml",
"ingresses/10-ingress-with-use-regex.yml",
},
expected: &dynamic.Configuration{
TCP: &dynamic.TCPConfiguration{
Routers: map[string]*dynamic.TCPRouter{},
Services: map[string]*dynamic.TCPService{},
},
HTTP: &dynamic.HTTPConfiguration{
Routers: map[string]*dynamic.Router{
"default-ingress-with-use-regex-rule-0-path-0": {
Rule: "Host(`use-regex.localhost`) && PathRegexp(`^/test(.*)`)",
RuleSyntax: "default",
Service: "default-ingress-with-use-regex-whoami-80",
},
},
Middlewares: map[string]*dynamic.Middleware{},
Services: map[string]*dynamic.Service{
"default-ingress-with-use-regex-whoami-80": {
LoadBalancer: &dynamic.ServersLoadBalancer{
Servers: []dynamic.Server{
{
URL: "http://10.10.0.1:80",
},
{
URL: "http://10.10.0.2:80",
},
},
Strategy: "wrr",
PassHostHeader: ptr.To(true),
ResponseForwarding: &dynamic.ResponseForwarding{
FlushInterval: dynamic.DefaultFlushInterval,
},
},
},
},
ServersTransports: map[string]*dynamic.ServersTransport{},
},
TLS: &dynamic.TLSConfiguration{},
},
},
{
desc: "Default Backend",
defaultBackendServiceName: "whoami",
@@ -914,6 +1031,7 @@ func TestLoadIngresses(t *testing.T) {
k8sClient: client,
defaultBackendServiceName: test.defaultBackendServiceName,
defaultBackendServiceNamespace: test.defaultBackendServiceNamespace,
NonTLSEntryPoints: []string{"web"},
}
p.SetDefaults()
@@ -0,0 +1,12 @@
kind: Ingress
apiVersion: networking.k8s.io/v1
metadata:
name: defaultbackend
namespace: testing
spec:
defaultBackend:
resource:
apiGroup: example.com
kind: SomeBackend
name: foo
@@ -0,0 +1,8 @@
kind: Ingress
apiVersion: networking.k8s.io/v1
metadata:
name: defaultbackend
namespace: testing
spec:
defaultBackend: {}
@@ -269,6 +269,17 @@ func (p *Provider) loadConfigurationFromIngresses(ctx context.Context, client Cl
continue
}
if ingress.Spec.DefaultBackend.Resource != nil {
// https://kubernetes.io/docs/concepts/services-networking/ingress/#resource-backend
logger.Error().Msg("Resource is not supported for default backend")
continue
}
if ingress.Spec.DefaultBackend.Service == nil {
logger.Error().Msg("Default backend is missing service definition")
continue
}
service, err := p.loadService(client, ingress.Namespace, *ingress.Spec.DefaultBackend)
if err != nil {
logger.Error().
@@ -550,6 +550,26 @@ func TestLoadConfigurationFromIngresses(t *testing.T) {
},
},
},
{
desc: "Ingress with defaultbackend with resource",
expected: &dynamic.Configuration{
HTTP: &dynamic.HTTPConfiguration{
Middlewares: map[string]*dynamic.Middleware{},
Routers: map[string]*dynamic.Router{},
Services: map[string]*dynamic.Service{},
},
},
},
{
desc: "Ingress with empty defaultbackend",
expected: &dynamic.Configuration{
HTTP: &dynamic.HTTPConfiguration{
Middlewares: map[string]*dynamic.Middleware{},
Routers: map[string]*dynamic.Router{},
Services: map[string]*dynamic.Service{},
},
},
},
{
desc: "Ingress with one service without endpoint",
expected: &dynamic.Configuration{
+12 -1
View File
@@ -3,6 +3,7 @@ package tcp
import (
"bufio"
"bytes"
"context"
"crypto/tls"
"errors"
"io"
@@ -222,7 +223,17 @@ func (r *Router) acmeTLSALPNHandler() tcp.Handler {
}
return tcp.HandlerFunc(func(conn tcp.WriteCloser) {
_ = tls.Server(conn, r.httpsTLSConfig).Handshake()
tlsConn := tls.Server(conn, r.httpsTLSConfig)
defer tlsConn.Close()
// This avoids stale connections when validating the ACME challenge,
// as we expect a validation request to complete in a short period of time.
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
if err := tlsConn.HandshakeContext(ctx); err != nil {
log.Debug().Err(err).Msg("Error during ACME-TLS/1 handshake")
}
})
}
+58
View File
@@ -697,6 +697,64 @@ func Test_Routing(t *testing.T) {
}
}
func Test_Router_acmeTLSALPNHandlerTimeout(t *testing.T) {
router, err := NewRouter()
require.NoError(t, err)
router.httpsTLSConfig = &tls.Config{}
listener, err := net.Listen("tcp", "127.0.0.1:0")
require.NoError(t, err)
acceptCh := make(chan struct{}, 1)
go func() {
close(acceptCh)
conn, err := listener.Accept()
require.NoError(t, err)
defer listener.Close()
router.acmeTLSALPNHandler().
ServeTCP(conn.(*net.TCPConn))
}()
<-acceptCh
conn, err := net.DialTimeout("tcp", listener.Addr().String(), 2*time.Second)
require.NoError(t, err)
// This is a minimal truncated Client Hello message
// to simulate a hanging connection during TLS handshake.
clientHello := []byte{
// TLS Record Header
0x16, // Content Type: Handshake
0x03, 0x01, // Version: TLS 1.0 (for compatibility)
0x00, 0x50, // Length: 80 bytes
}
_, err = conn.Write(clientHello)
require.NoError(t, err)
errCh := make(chan error, 1)
go func() {
// This will return an EOF as the acmeTLSALPNHandler will close the connection
// after a timeout during the TLS handshake.
b := make([]byte, 256)
_, err = conn.Read(b)
errCh <- err
}()
select {
case err := <-errCh:
assert.ErrorIs(t, err, io.EOF)
case <-time.After(3 * time.Second):
t.Fatal("Error: Timeout waiting for acmeTLSALPNHandler to close the connection")
}
}
// routerTCPCatchAll configures a TCP CatchAll No TLS - HostSNI(`*`) router.
func routerTCPCatchAll(conf *runtime.Configuration) {
conf.TCPRouters["tcp-catchall"] = &runtime.TCPRouterInfo{
+17 -10
View File
@@ -12,11 +12,16 @@ import (
"github.com/stretchr/testify/assert"
)
// newTestRand creates a deterministic random source for reproducible tests.
func newTestRand() *rand.Rand {
return rand.New(rand.NewSource(12345))
}
// genIPAddress generate randomly an IP address as a string.
func genIPAddress() string {
func genIPAddress(rng *rand.Rand) string {
buf := make([]byte, 4)
ip := rand.Uint32()
ip := rng.Uint32()
binary.LittleEndian.PutUint32(buf, ip)
ipStr := net.IP(buf)
@@ -37,6 +42,7 @@ func initStatusArray(size int, value int) []int {
// The tests validate repartition using a margin of 10% of the number of requests
func TestBalancer(t *testing.T) {
rng := newTestRand()
balancer := New(false)
balancer.Add("first", http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) {
@@ -52,7 +58,7 @@ func TestBalancer(t *testing.T) {
recorder := &responseRecorder{ResponseRecorder: httptest.NewRecorder(), save: map[string]int{}}
req := httptest.NewRequest(http.MethodGet, "/", nil)
for range 100 {
req.RemoteAddr = genIPAddress()
req.RemoteAddr = genIPAddress(rng)
balancer.ServeHTTP(recorder, req)
}
assert.InDelta(t, 80, recorder.save["first"], 10)
@@ -132,6 +138,7 @@ func TestBalancerOneServerDown(t *testing.T) {
}
func TestBalancerDownThenUp(t *testing.T) {
rng := newTestRand()
balancer := New(false)
balancer.Add("first", http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) {
@@ -155,7 +162,7 @@ func TestBalancerDownThenUp(t *testing.T) {
recorder = &responseRecorder{ResponseRecorder: httptest.NewRecorder(), save: map[string]int{}}
req := httptest.NewRequest(http.MethodGet, "/", nil)
for range 100 {
req.RemoteAddr = genIPAddress()
req.RemoteAddr = genIPAddress(rng)
balancer.ServeHTTP(recorder, req)
}
assert.InDelta(t, 50, recorder.save["first"], 10)
@@ -163,6 +170,7 @@ func TestBalancerDownThenUp(t *testing.T) {
}
func TestBalancerPropagate(t *testing.T) {
rng := newTestRand()
balancer1 := New(true)
balancer1.Add("first", http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) {
@@ -188,8 +196,6 @@ func TestBalancerPropagate(t *testing.T) {
topBalancer.Add("balancer1", balancer1, Int(1), false)
_ = balancer1.RegisterStatusUpdater(func(up bool) {
topBalancer.SetStatus(context.WithValue(t.Context(), serviceName, "top"), "balancer1", up)
// TODO(mpl): if test gets flaky, add channel or something here to signal that
// propagation is done, and wait on it before sending request.
})
topBalancer.Add("balancer2", balancer2, Int(1), false)
_ = balancer2.RegisterStatusUpdater(func(up bool) {
@@ -199,7 +205,7 @@ func TestBalancerPropagate(t *testing.T) {
recorder := &responseRecorder{ResponseRecorder: httptest.NewRecorder(), save: map[string]int{}}
req := httptest.NewRequest(http.MethodGet, "/", nil)
for range 100 {
req.RemoteAddr = genIPAddress()
req.RemoteAddr = genIPAddress(rng)
topBalancer.ServeHTTP(recorder, req)
}
assert.InDelta(t, 25, recorder.save["first"], 10)
@@ -214,7 +220,7 @@ func TestBalancerPropagate(t *testing.T) {
recorder = &responseRecorder{ResponseRecorder: httptest.NewRecorder(), save: map[string]int{}}
req = httptest.NewRequest(http.MethodGet, "/", nil)
for range 100 {
req.RemoteAddr = genIPAddress()
req.RemoteAddr = genIPAddress(rng)
topBalancer.ServeHTTP(recorder, req)
}
assert.InDelta(t, 25, recorder.save["first"], 10)
@@ -230,7 +236,7 @@ func TestBalancerPropagate(t *testing.T) {
recorder = &responseRecorder{ResponseRecorder: httptest.NewRecorder(), save: map[string]int{}}
req = httptest.NewRequest(http.MethodGet, "/", nil)
for range 100 {
req.RemoteAddr = genIPAddress()
req.RemoteAddr = genIPAddress(rng)
topBalancer.ServeHTTP(recorder, req)
}
assert.InDelta(t, 50, recorder.save["first"], 10)
@@ -254,6 +260,7 @@ func TestBalancerAllServersZeroWeight(t *testing.T) {
}
func TestSticky(t *testing.T) {
rng := newTestRand()
balancer := New(false)
balancer.Add("first", http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) {
@@ -269,7 +276,7 @@ func TestSticky(t *testing.T) {
recorder := &responseRecorder{ResponseRecorder: httptest.NewRecorder(), save: map[string]int{}}
req := httptest.NewRequest(http.MethodGet, "/", nil)
req.RemoteAddr = genIPAddress()
req.RemoteAddr = genIPAddress(rng)
for range 10 {
for _, cookie := range recorder.Result().Cookies() {
req.AddCookie(cookie)
@@ -972,23 +972,22 @@ func TestTrafficShiftsWhenPerformanceDegrades(t *testing.T) {
assert.InDelta(t, 25, recorder.save["server2"], 10) // 25 ± 10 requests
// Phase 2: server1 degrades (simulating GC pause, CPU spike, or network latency).
server1Delay.Store(15) // Now 15ms (3x slower)
server1Delay.Store(50) // Now 50ms (10x slower) - dramatic degradation for reliable detection
// Make more requests to shift the moving average.
// Ring buffer has 100 samples, need significant new samples to shift average.
// server1's average will climb from ~5ms toward 15ms.
// server1's average will climb from ~5ms toward 50ms.
recorder2 := &responseRecorder{ResponseRecorder: httptest.NewRecorder(), save: map[string]int{}}
for range 60 {
balancer.ServeHTTP(recorder2, httptest.NewRequest(http.MethodGet, "/", nil))
}
// server2 should get significantly more traffic (>75%)
// Score for server1: (~10-15ms × 1) / 1 = 10-15 (as average climbs)
// Score for server2: (5ms × 1) / 1 = 5
// server2 should get significantly more traffic
// With 10x performance difference, server2 should dominate.
total2 := recorder2.save["server1"] + recorder2.save["server2"]
assert.Equal(t, 60, total2)
assert.Greater(t, recorder2.save["server2"], 45) // At least 75% (45/60)
assert.Less(t, recorder2.save["server1"], 15) // At most 25% (15/60)
assert.Greater(t, recorder2.save["server2"], 35) // At least ~60% (35/60)
assert.Less(t, recorder2.save["server1"], 25) // At most ~40% (25/60)
}
// TestMultipleServersWithSameScore tests WRR tie-breaking when multiple servers have identical scores.