mirror of
https://github.com/alibaba/higress.git
synced 2026-06-01 08:37:26 +08:00
fix(kingress): include header match in duplicate-route dedup key (#3580)
Fix incorrect duplicate-route detection in the KIngress controller that caused header-differentiated routes to be silently dropped.
This commit is contained in:
@@ -503,7 +503,7 @@ func (c *controller) ConvertHTTPRoute(convertOptions *common.ConvertOptions, wra
|
|||||||
|
|
||||||
// Two duplicated rules in the same ingress.
|
// Two duplicated rules in the same ingress.
|
||||||
if ingressRouteBuilder.Event == common.Normal {
|
if ingressRouteBuilder.Event == common.Normal {
|
||||||
pathFormat := wrapperHttpRoute.PathFormat()
|
pathFormat := wrapperHttpRoute.PathFormat() + kingressPathHeadersKey(httpPath.Headers)
|
||||||
if definedRules.Contains(pathFormat) {
|
if definedRules.Contains(pathFormat) {
|
||||||
ingressRouteBuilder.PreIngress = cfg
|
ingressRouteBuilder.PreIngress = cfg
|
||||||
ingressRouteBuilder.Event = common.DuplicatedRoute
|
ingressRouteBuilder.Event = common.DuplicatedRoute
|
||||||
@@ -726,3 +726,25 @@ func isIngressPublic(ingSpec *ingress.IngressSpec) bool {
|
|||||||
}
|
}
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// kingressPathHeadersKey builds a stable string from path-level headers for use
|
||||||
|
// in duplicate-route detection. KIngress paths are distinguished by headers
|
||||||
|
// (not by URL path), so the dedup key must include header information.
|
||||||
|
func kingressPathHeadersKey(headers map[string]ingress.HeaderMatch) string {
|
||||||
|
if len(headers) == 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
keys := make([]string, 0, len(headers))
|
||||||
|
for k := range headers {
|
||||||
|
keys = append(keys, k)
|
||||||
|
}
|
||||||
|
sort.Strings(keys)
|
||||||
|
var sb strings.Builder
|
||||||
|
for _, k := range keys {
|
||||||
|
sb.WriteByte('\x00')
|
||||||
|
sb.WriteString(k)
|
||||||
|
sb.WriteByte('=')
|
||||||
|
sb.WriteString(headers[k].Exact)
|
||||||
|
}
|
||||||
|
return sb.String()
|
||||||
|
}
|
||||||
|
|||||||
@@ -619,3 +619,182 @@ func TestCreateRuleKey(t *testing.T) {
|
|||||||
func buildHigressAnnotationKey(key string) string {
|
func buildHigressAnnotationKey(key string) string {
|
||||||
return annotations.HigressAnnotationsPrefix + "/" + key
|
return annotations.HigressAnnotationsPrefix + "/" + key
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestKingressPathHeadersKey verifies that kingressPathHeadersKey produces
|
||||||
|
// stable, unique keys for different header combinations.
|
||||||
|
func TestKingressPathHeadersKey(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
headers map[string]ingress.HeaderMatch
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "nil headers",
|
||||||
|
headers: nil,
|
||||||
|
want: "",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "empty headers",
|
||||||
|
headers: map[string]ingress.HeaderMatch{},
|
||||||
|
want: "",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "single header",
|
||||||
|
headers: map[string]ingress.HeaderMatch{
|
||||||
|
"x-version": {Exact: "v1"},
|
||||||
|
},
|
||||||
|
want: "\x00x-version=v1",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "multiple headers are sorted deterministically",
|
||||||
|
headers: map[string]ingress.HeaderMatch{
|
||||||
|
"x-version": {Exact: "v2"},
|
||||||
|
"x-env": {Exact: "prod"},
|
||||||
|
},
|
||||||
|
// sorted: x-env, x-version
|
||||||
|
want: "\x00x-env=prod\x00x-version=v2",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "same headers different values produce different keys",
|
||||||
|
headers: map[string]ingress.HeaderMatch{
|
||||||
|
"x-version": {Exact: "v2"},
|
||||||
|
},
|
||||||
|
want: "\x00x-version=v2",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
got := kingressPathHeadersKey(tt.headers)
|
||||||
|
if got != tt.want {
|
||||||
|
t.Errorf("kingressPathHeadersKey() = %q, want %q", got, tt.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify that v1 and v2 keys are distinct.
|
||||||
|
keyV1 := kingressPathHeadersKey(map[string]ingress.HeaderMatch{"x-version": {Exact: "v1"}})
|
||||||
|
keyV2 := kingressPathHeadersKey(map[string]ingress.HeaderMatch{"x-version": {Exact: "v2"}})
|
||||||
|
if keyV1 == keyV2 {
|
||||||
|
t.Errorf("expected distinct keys for different header values, got %q == %q", keyV1, keyV2)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestConvertHTTPRoute_HeaderDistinctPaths verifies that two KIngress paths
|
||||||
|
// sharing the same URL path but differing only in header-match rules are NOT
|
||||||
|
// treated as duplicates and both produce VirtualService routes.
|
||||||
|
//
|
||||||
|
// KIngress example that triggers the bug (before fix):
|
||||||
|
//
|
||||||
|
// apiVersion: networking.internal.knative.dev/v1alpha1
|
||||||
|
// kind: Ingress
|
||||||
|
// metadata:
|
||||||
|
// name: hello-header-routing
|
||||||
|
// namespace: default
|
||||||
|
// spec:
|
||||||
|
// rules:
|
||||||
|
// - hosts: ["hello.default.example.com"]
|
||||||
|
// http:
|
||||||
|
// paths:
|
||||||
|
// - path: "/"
|
||||||
|
// headers:
|
||||||
|
// x-version:
|
||||||
|
// exact: "v1"
|
||||||
|
// splits:
|
||||||
|
// - serviceName: hello-v1
|
||||||
|
// servicePort: 80
|
||||||
|
// percent: 100
|
||||||
|
// - path: "/"
|
||||||
|
// headers:
|
||||||
|
// x-version:
|
||||||
|
// exact: "v2"
|
||||||
|
// splits:
|
||||||
|
// - serviceName: hello-v2
|
||||||
|
// servicePort: 80
|
||||||
|
// percent: 100
|
||||||
|
//
|
||||||
|
// Before the fix, the second path (x-version: v2) was incorrectly marked as
|
||||||
|
// DuplicatedRoute and dropped, leaving only the v1 route in the VirtualService.
|
||||||
|
// After the fix, both routes are preserved.
|
||||||
|
func TestConvertHTTPRoute_HeaderDistinctPaths(t *testing.T) {
|
||||||
|
fakeClient := kube.NewFakeClient()
|
||||||
|
options := common.Options{IngressClass: "mse", ClusterId: "", EnableStatus: true}
|
||||||
|
secretController := secret.NewController(fakeClient, options)
|
||||||
|
c := NewController(fakeClient, fakeClient, options, secretController)
|
||||||
|
|
||||||
|
convertOptions := &common.ConvertOptions{
|
||||||
|
IngressDomainCache: &common.IngressDomainCache{
|
||||||
|
Valid: make(map[string]*common.IngressDomainBuilder),
|
||||||
|
Invalid: make([]model.IngressDomain, 0),
|
||||||
|
},
|
||||||
|
Route2Ingress: map[string]*common.WrapperConfigWithRuleKey{},
|
||||||
|
VirtualServices: make(map[string]*common.WrapperVirtualService),
|
||||||
|
Gateways: make(map[string]*common.WrapperGateway),
|
||||||
|
IngressRouteCache: common.NewIngressRouteCache(),
|
||||||
|
HTTPRoutes: make(map[string][]*common.WrapperHTTPRoute),
|
||||||
|
}
|
||||||
|
|
||||||
|
wrapperConfig := &common.WrapperConfig{
|
||||||
|
Config: &config.Config{
|
||||||
|
Meta: config.Meta{
|
||||||
|
Name: "hello-header-routing",
|
||||||
|
Namespace: "default",
|
||||||
|
},
|
||||||
|
// Two paths share the same URL "/" but differ by x-version header.
|
||||||
|
// Before fix: second path was dropped as DuplicatedRoute.
|
||||||
|
// After fix: both paths are kept.
|
||||||
|
Spec: ingress.IngressSpec{
|
||||||
|
Rules: []ingress.IngressRule{
|
||||||
|
{
|
||||||
|
Hosts: []string{"hello.default.example.com"},
|
||||||
|
HTTP: &ingress.HTTPIngressRuleValue{
|
||||||
|
Paths: []ingress.HTTPIngressPath{
|
||||||
|
{
|
||||||
|
Path: "/",
|
||||||
|
Headers: map[string]ingress.HeaderMatch{
|
||||||
|
"x-version": {Exact: "v1"},
|
||||||
|
},
|
||||||
|
Splits: []ingress.IngressBackendSplit{{
|
||||||
|
IngressBackend: ingress.IngressBackend{
|
||||||
|
ServiceNamespace: "default",
|
||||||
|
ServiceName: "hello-v1",
|
||||||
|
ServicePort: intstr.FromInt(80),
|
||||||
|
},
|
||||||
|
Percent: 100,
|
||||||
|
}},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Path: "/",
|
||||||
|
Headers: map[string]ingress.HeaderMatch{
|
||||||
|
"x-version": {Exact: "v2"},
|
||||||
|
},
|
||||||
|
Splits: []ingress.IngressBackendSplit{{
|
||||||
|
IngressBackend: ingress.IngressBackend{
|
||||||
|
ServiceNamespace: "default",
|
||||||
|
ServiceName: "hello-v2",
|
||||||
|
ServicePort: intstr.FromInt(80),
|
||||||
|
},
|
||||||
|
Percent: 100,
|
||||||
|
}},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Visibility: ingress.IngressVisibilityExternalIP,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
AnnotationsConfig: &annotations.Ingress{},
|
||||||
|
}
|
||||||
|
|
||||||
|
err := c.ConvertHTTPRoute(convertOptions, wrapperConfig)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
routes, ok := convertOptions.HTTPRoutes["hello.default.example.com"]
|
||||||
|
require.True(t, ok, "expected HTTPRoutes entry for hello.default.example.com")
|
||||||
|
|
||||||
|
// Both header-differentiated paths must survive dedup and appear as
|
||||||
|
// separate WrapperHTTPRoute entries destined for distinct backends.
|
||||||
|
require.Equal(t, 2, len(routes),
|
||||||
|
"expected 2 routes (one per header value), got %d; "+
|
||||||
|
"the second path was likely dropped as a false duplicate", len(routes))
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user