1
0
Fork 0

Multi-layer routing

Co-authored-by: Romain <rtribotte@users.noreply.github.com>
This commit is contained in:
Simon Delicata 2025-10-22 11:58:05 +02:00 committed by GitHub
parent 8392503df7
commit d6598f370c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
37 changed files with 2834 additions and 37 deletions

View file

@ -69,6 +69,7 @@ type Router struct {
Middlewares []string `json:"middlewares,omitempty" toml:"middlewares,omitempty" yaml:"middlewares,omitempty" export:"true"`
Service string `json:"service,omitempty" toml:"service,omitempty" yaml:"service,omitempty" export:"true"`
Rule string `json:"rule,omitempty" toml:"rule,omitempty" yaml:"rule,omitempty"`
ParentRefs []string `json:"parentRefs,omitempty" toml:"parentRefs,omitempty" yaml:"parentRefs,omitempty" label:"-" export:"true"`
// Deprecated: Please do not use this field and rewrite the router rules to use the v3 syntax.
RuleSyntax string `json:"ruleSyntax,omitempty" toml:"ruleSyntax,omitempty" yaml:"ruleSyntax,omitempty" export:"true"`
Priority int `json:"priority,omitempty" toml:"priority,omitempty,omitzero" yaml:"priority,omitempty" export:"true"`

View file

@ -1369,6 +1369,11 @@ func (in *Router) DeepCopyInto(out *Router) {
*out = make([]string, len(*in))
copy(*out, *in)
}
if in.ParentRefs != nil {
in, out := &in.ParentRefs, &out.ParentRefs
*out = make([]string, len(*in))
copy(*out, *in)
}
if in.TLS != nil {
in, out := &in.TLS, &out.TLS
*out = new(RouterTLSConfig)

View file

@ -43,7 +43,8 @@ func (c *Configuration) GetRoutersByEntryPoints(ctx context.Context, entryPoints
entryPointsRouters[entryPointName][rtName] = rt
}
if entryPointsCount == 0 {
// Root routers must have at least one entry point.
if entryPointsCount == 0 && rt.ParentRefs == nil {
rt.AddError(errors.New("no valid entryPoint for this router"), true)
logger.Error().Msg("No valid entryPoint for this router")
}
@ -80,6 +81,11 @@ type RouterInfo struct {
// It is the caller's responsibility to set the initial status.
Status string `json:"status,omitempty"`
Using []string `json:"using,omitempty"` // Effective entry points used by that router.
// ChildRefs contains the names of child routers.
// This field is only filled during multi-layer routing computation of parentRefs,
// and used when building the runtime configuration.
ChildRefs []string `json:"-"`
}
// AddError adds err to r.Err, if it does not already exist.

View file

@ -2,6 +2,7 @@ package accesslog
import (
"net/http"
"strings"
"time"
"github.com/rs/zerolog/log"
@ -76,3 +77,37 @@ func InitServiceFields(rw http.ResponseWriter, req *http.Request, next http.Hand
next.ServeHTTP(rw, req)
}
const separator = " -> "
// ConcatFieldHandler concatenates field values instead of overriding them.
type ConcatFieldHandler struct {
next http.Handler
name string
value string
}
// NewConcatFieldHandler creates a ConcatField handler that concatenates values.
func NewConcatFieldHandler(next http.Handler, name, value string) http.Handler {
return &ConcatFieldHandler{next: next, name: name, value: value}
}
func (c *ConcatFieldHandler) ServeHTTP(rw http.ResponseWriter, req *http.Request) {
table := GetLogData(req)
if table == nil {
c.next.ServeHTTP(rw, req)
return
}
// Check if field already exists and concatenate if so
if existingValue, exists := table.Core[c.name]; exists && existingValue != nil {
if existingStr, ok := existingValue.(string); ok && strings.TrimSpace(existingStr) != "" {
table.Core[c.name] = existingStr + separator + c.value
c.next.ServeHTTP(rw, req)
return
}
}
table.Core[c.name] = c.value
c.next.ServeHTTP(rw, req)
}

View file

@ -0,0 +1,96 @@
package accesslog
import (
"context"
"net/http"
"net/http/httptest"
"testing"
"github.com/stretchr/testify/assert"
)
func TestConcatFieldHandler_ServeHTTP(t *testing.T) {
testCases := []struct {
desc string
existingValue interface{}
newValue string
expectedResult string
}{
{
desc: "first router - no existing value",
existingValue: nil,
newValue: "router1",
expectedResult: "router1",
},
{
desc: "second router - concatenate with existing string",
existingValue: "router1",
newValue: "router2",
expectedResult: "router1 -> router2",
},
{
desc: "third router - concatenate with existing chain",
existingValue: "router1 -> router2",
newValue: "router3",
expectedResult: "router1 -> router2 -> router3",
},
{
desc: "empty existing value - treat as first",
existingValue: " ",
newValue: "router1",
expectedResult: "router1",
},
{
desc: "non-string existing value - replace with new value",
existingValue: 123,
newValue: "router1",
expectedResult: "router1",
},
}
for _, test := range testCases {
t.Run(test.desc, func(t *testing.T) {
nextHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
})
logData := &LogData{
Core: CoreLogData{},
}
if test.existingValue != nil {
logData.Core[RouterName] = test.existingValue
}
req := httptest.NewRequest(http.MethodGet, "/test", nil)
req = req.WithContext(context.WithValue(req.Context(), DataTableKey, logData))
handler := NewConcatFieldHandler(nextHandler, RouterName, test.newValue)
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req)
assert.Equal(t, test.expectedResult, logData.Core[RouterName])
assert.Equal(t, http.StatusOK, rec.Code)
})
}
}
func TestConcatFieldHandler_ServeHTTP_NoLogData(t *testing.T) {
nextHandlerCalled := false
nextHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
nextHandlerCalled = true
w.WriteHeader(http.StatusOK)
})
handler := NewConcatFieldHandler(nextHandler, RouterName, "router1")
// Create request without LogData in context.
req := httptest.NewRequest(http.MethodGet, "/test", nil)
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req)
// Verify next handler was called and no panic occurred.
assert.True(t, nextHandlerCalled)
assert.Equal(t, http.StatusOK, rec.Code)
}

View file

@ -1180,6 +1180,83 @@ func logWriterTestHandlerFunc(rw http.ResponseWriter, r *http.Request) {
rw.WriteHeader(testStatus)
}
func TestConcatFieldHandler_LoggerIntegration(t *testing.T) {
logFilePath := filepath.Join(t.TempDir(), "access.log")
config := &otypes.AccessLog{FilePath: logFilePath, Format: CommonFormat}
logger, err := NewHandler(t.Context(), config)
require.NoError(t, err)
t.Cleanup(func() {
err := logger.Close()
require.NoError(t, err)
})
req := &http.Request{
Header: map[string][]string{
"User-Agent": {testUserAgent},
"Referer": {testReferer},
},
Proto: testProto,
Host: testHostname,
Method: testMethod,
RemoteAddr: fmt.Sprintf("%s:%d", testHostname, testPort),
URL: &url.URL{
User: url.UserPassword(testUsername, ""),
Path: testPath,
},
Body: io.NopCloser(bytes.NewReader([]byte("testdata"))),
}
chain := alice.New()
chain = chain.Append(capture.Wrap)
// Injection of the observability variables in the request context.
chain = chain.Append(func(next http.Handler) (http.Handler, error) {
return observability.WithObservabilityHandler(next, observability.Observability{
AccessLogsEnabled: true,
}), nil
})
chain = chain.Append(logger.AliceConstructor())
// Simulate multi-layer routing with concatenated router names
var handler http.Handler = http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) {
logData := GetLogData(r)
if logData != nil {
logData.Core[ServiceURL] = testServiceName
logData.Core[OriginStatus] = testStatus
logData.Core[OriginContentSize] = testContentSize
logData.Core[RetryAttempts] = testRetryAttempts
logData.Core[StartUTC] = testStart.UTC()
logData.Core[StartLocal] = testStart.Local()
}
rw.WriteHeader(testStatus)
if _, err := rw.Write([]byte(testContent)); err != nil {
http.Error(rw, err.Error(), http.StatusInternalServerError)
}
})
// Create chain of ConcatFieldHandlers to simulate multi-layer routing
handler = NewConcatFieldHandler(handler, RouterName, "child-router")
handler = NewConcatFieldHandler(handler, RouterName, "parent-router")
handler = NewConcatFieldHandler(handler, RouterName, "root-router")
finalHandler, err := chain.Then(handler)
require.NoError(t, err)
recorder := httptest.NewRecorder()
finalHandler.ServeHTTP(recorder, req)
logData, err := os.ReadFile(logFilePath)
require.NoError(t, err)
result, err := ParseAccessLog(string(logData))
require.NoError(t, err)
expectedRouterName := "\"root-router -> parent-router -> child-router\""
assert.Equal(t, expectedRouterName, result[RouterName])
}
func doLoggingWithAbortedStream(t *testing.T, config *otypes.AccessLog) {
t.Helper()

View file

@ -0,0 +1,28 @@
---
apiVersion: traefik.io/v1alpha1
kind: IngressRoute
metadata:
name: parent-cross
namespace: ns-a
spec:
entryPoints:
- web
routes:
- match: Host(`cross.example.com`)
kind: Rule
---
apiVersion: traefik.io/v1alpha1
kind: IngressRoute
metadata:
name: child-cross-allowed
namespace: ns-b
spec:
parentRefs:
- name: parent-cross
namespace: ns-a
routes:
- match: Path(`/cross`)
kind: Rule
services:
- name: cross-service
port: 9000

View file

@ -0,0 +1,28 @@
---
apiVersion: traefik.io/v1alpha1
kind: IngressRoute
metadata:
name: parent-cross
namespace: ns-a
spec:
entryPoints:
- web
routes:
- match: Host(`cross.example.com`)
kind: Rule
---
apiVersion: traefik.io/v1alpha1
kind: IngressRoute
metadata:
name: child-cross-denied
namespace: ns-b
spec:
parentRefs:
- name: parent-cross
namespace: ns-a
routes:
- match: Path(`/denied`)
kind: Rule
services:
- name: cross-service
port: 9000

View file

@ -0,0 +1,27 @@
---
apiVersion: traefik.io/v1alpha1
kind: IngressRoute
metadata:
name: parent-default
namespace: default
spec:
entryPoints:
- web
routes:
- match: Host(`default.example.com`)
kind: Rule
---
apiVersion: traefik.io/v1alpha1
kind: IngressRoute
metadata:
name: child-same
namespace: default
spec:
parentRefs:
- name: parent-default
routes:
- match: Path(`/same`)
kind: Rule
services:
- name: same-service
port: 9000

View file

@ -0,0 +1,16 @@
---
apiVersion: traefik.io/v1alpha1
kind: IngressRoute
metadata:
name: child-missing-parent
namespace: default
spec:
parentRefs:
- name: non-existent-parent
namespace: default
routes:
- match: Path(`/missing`)
kind: Rule
services:
- name: child-service
port: 9000

View file

@ -0,0 +1,42 @@
---
apiVersion: traefik.io/v1alpha1
kind: IngressRoute
metadata:
name: parent-a
namespace: default
spec:
entryPoints:
- web
routes:
- match: Host(`a.example.com`)
kind: Rule
---
apiVersion: traefik.io/v1alpha1
kind: IngressRoute
metadata:
name: parent-b
namespace: default
spec:
entryPoints:
- web
routes:
- match: Host(`b.example.com`)
kind: Rule
---
apiVersion: traefik.io/v1alpha1
kind: IngressRoute
metadata:
name: child-multi-parents
namespace: default
spec:
parentRefs:
- name: parent-a
namespace: default
- name: parent-b
namespace: default
routes:
- match: Path(`/shared`)
kind: Rule
services:
- name: shared-service
port: 9000

View file

@ -0,0 +1,139 @@
apiVersion: v1
kind: Service
metadata:
name: child-service
namespace: default
spec:
ports:
- name: web
port: 9000
---
kind: EndpointSlice
apiVersion: discovery.k8s.io/v1
metadata:
name: child-service-abc
namespace: default
labels:
kubernetes.io/service-name: child-service
addressType: IPv4
ports:
- name: web
port: 9000
endpoints:
- addresses:
- 10.10.2.1
- 10.10.2.2
conditions:
ready: true
---
apiVersion: v1
kind: Service
metadata:
name: users-service
namespace: default
spec:
ports:
- name: web
port: 9000
---
kind: EndpointSlice
apiVersion: discovery.k8s.io/v1
metadata:
name: users-service-abc
namespace: default
labels:
kubernetes.io/service-name: users-service
addressType: IPv4
ports:
- name: web
port: 9000
endpoints:
- addresses:
- 10.10.5.1
- 10.10.5.2
conditions:
ready: true
---
apiVersion: v1
kind: Service
metadata:
name: shared-service
namespace: default
spec:
ports:
- name: web
port: 9000
---
kind: EndpointSlice
apiVersion: discovery.k8s.io/v1
metadata:
name: shared-service-abc
namespace: default
labels:
kubernetes.io/service-name: shared-service
addressType: IPv4
ports:
- name: web
port: 9000
endpoints:
- addresses:
- 10.10.8.1
- 10.10.8.2
conditions:
ready: true
---
apiVersion: v1
kind: Service
metadata:
name: cross-service
namespace: ns-b
spec:
ports:
- name: web
port: 9000
---
kind: EndpointSlice
apiVersion: discovery.k8s.io/v1
metadata:
name: cross-service-abc
namespace: ns-b
labels:
kubernetes.io/service-name: cross-service
addressType: IPv4
ports:
- name: web
port: 9000
endpoints:
- addresses:
- 10.10.11.1
- 10.10.11.2
conditions:
ready: true
---
apiVersion: v1
kind: Service
metadata:
name: same-service
namespace: default
spec:
ports:
- name: web
port: 9000
---
kind: EndpointSlice
apiVersion: discovery.k8s.io/v1
metadata:
name: same-service-abc
namespace: default
labels:
kubernetes.io/service-name: same-service
addressType: IPv4
ports:
- name: web
port: 9000
endpoints:
- addresses:
- 10.10.14.1
- 10.10.14.2
conditions:
ready: true

View file

@ -0,0 +1,30 @@
---
apiVersion: traefik.io/v1alpha1
kind: IngressRoute
metadata:
name: parent-multi
namespace: default
spec:
entryPoints:
- web
routes:
- match: Host(`api.example.com`) && PathPrefix(`/v1`)
kind: Rule
- match: Host(`api.example.com`) && PathPrefix(`/v2`)
kind: Rule
---
apiVersion: traefik.io/v1alpha1
kind: IngressRoute
metadata:
name: child-multi-routes
namespace: default
spec:
parentRefs:
- name: parent-multi
namespace: default
routes:
- match: Path(`/users`)
kind: Rule
services:
- name: users-service
port: 9000

View file

@ -0,0 +1,28 @@
---
apiVersion: traefik.io/v1alpha1
kind: IngressRoute
metadata:
name: parent-single
namespace: default
spec:
entryPoints:
- web
routes:
- match: Host(`parent.example.com`)
kind: Rule
---
apiVersion: traefik.io/v1alpha1
kind: IngressRoute
metadata:
name: child-single
namespace: default
spec:
parentRefs:
- name: parent-single
namespace: default
routes:
- match: Path(`/api`)
kind: Rule
services:
- name: child-service
port: 9000

View file

@ -1379,15 +1379,18 @@ func buildCertificates(client Client, tlsStore, namespace string, certificates [
return nil
}
func makeServiceKey(rule, ingressName string) (string, error) {
func makeServiceKey(rule, ingressName string) string {
h := sha256.New()
// As explained in https://pkg.go.dev/hash#Hash,
// Write never returns an error.
if _, err := h.Write([]byte(rule)); err != nil {
return "", err
return ""
}
key := fmt.Sprintf("%s-%.10x", ingressName, h.Sum(nil))
return key, nil
return key
}
func makeID(namespace, name string) string {

View file

@ -61,6 +61,12 @@ func (p *Provider) loadIngressRouteConfiguration(ctx context.Context, client Cli
disableClusterScopeResources: p.DisableClusterScopeResources,
}
parentRouterNames, err := resolveParentRouterNames(client, ingressRoute, p.AllowCrossNamespace)
if err != nil {
logger.Error().Err(err).Msg("Error resolving parent routers")
continue
}
for _, route := range ingressRoute.Spec.Routes {
if len(route.Kind) > 0 && route.Kind != "Rule" {
logger.Error().Msgf("Unsupported match kind: %s. Only \"Rule\" is supported for now.", route.Kind)
@ -72,11 +78,7 @@ func (p *Provider) loadIngressRouteConfiguration(ctx context.Context, client Cli
continue
}
serviceKey, err := makeServiceKey(route.Match, ingressName)
if err != nil {
logger.Error().Err(err).Send()
continue
}
serviceKey := makeServiceKey(route.Match, ingressName)
mds, err := p.makeMiddlewareKeys(ctx, ingressRoute.Namespace, route.Middlewares)
if err != nil {
@ -87,7 +89,8 @@ func (p *Provider) loadIngressRouteConfiguration(ctx context.Context, client Cli
normalized := provider.Normalize(makeID(ingressRoute.Namespace, serviceKey))
serviceName := normalized
if len(route.Services) > 1 {
switch {
case len(route.Services) > 1:
spec := traefikv1alpha1.TraefikServiceSpec{
Weighted: &traefikv1alpha1.WeightedRoundRobin{
Services: route.Services,
@ -99,7 +102,7 @@ func (p *Provider) loadIngressRouteConfiguration(ctx context.Context, client Cli
logger.Error().Err(errBuild).Send()
continue
}
} else if len(route.Services) == 1 {
case len(route.Services) == 1:
fullName, serversLB, err := cb.nameAndService(ctx, ingressRoute.Namespace, route.Services[0].LoadBalancerSpec)
if err != nil {
logger.Error().Err(err).Send()
@ -111,6 +114,9 @@ func (p *Provider) loadIngressRouteConfiguration(ctx context.Context, client Cli
} else {
serviceName = fullName
}
default:
// Routes without services leave serviceName empty.
serviceName = ""
}
r := &dynamic.Router{
@ -121,6 +127,7 @@ func (p *Provider) loadIngressRouteConfiguration(ctx context.Context, client Cli
Rule: route.Match,
Service: serviceName,
Observability: route.Observability,
ParentRefs: parentRouterNames,
}
if ingressRoute.Spec.TLS != nil {
@ -202,6 +209,50 @@ func (p *Provider) makeMiddlewareKeys(ctx context.Context, ingRouteNamespace str
return mds, nil
}
// resolveParentRouterNames resolves parent IngressRoute references to router names.
// It returns the list of parent router names and an error if one occurred during processing.
func resolveParentRouterNames(client Client, ingressRoute *traefikv1alpha1.IngressRoute, allowCrossNamespace bool) ([]string, error) {
// If no parent refs, return empty list (not an error).
if len(ingressRoute.Spec.ParentRefs) == 0 {
return nil, nil
}
var parentRouterNames []string
for _, parentRef := range ingressRoute.Spec.ParentRefs {
// Determine parent namespace (default to child namespace if not specified).
parentNamespace := parentRef.Namespace
if parentNamespace == "" {
parentNamespace = ingressRoute.Namespace
}
// Validate cross-namespace access.
if !isNamespaceAllowed(allowCrossNamespace, ingressRoute.Namespace, parentNamespace) {
return nil, fmt.Errorf("cross-namespace reference to parent IngressRoute %s/%s not allowed", parentNamespace, parentRef.Name)
}
var parentIngressRoute *traefikv1alpha1.IngressRoute
for _, ir := range client.GetIngressRoutes() {
if ir.Name == parentRef.Name && ir.Namespace == parentNamespace {
parentIngressRoute = ir
break
}
}
if parentIngressRoute == nil {
return nil, fmt.Errorf("parent IngressRoute %s/%s does not exist", parentNamespace, parentRef.Name)
}
// Compute router names for all routes in parent IngressRoute.
for _, route := range parentIngressRoute.Spec.Routes {
serviceKey := makeServiceKey(route.Match, parentIngressRoute.Name)
routerName := provider.Normalize(makeID(parentIngressRoute.Namespace, serviceKey))
parentRouterNames = append(parentRouterNames, routerName)
}
}
return parentRouterNames, nil
}
type configBuilder struct {
client Client
allowCrossNamespace bool

View file

@ -50,11 +50,7 @@ func (p *Provider) loadIngressRouteTCPConfiguration(ctx context.Context, client
continue
}
key, err := makeServiceKey(route.Match, ingressName)
if err != nil {
logger.Error().Err(err).Send()
continue
}
key := makeServiceKey(route.Match, ingressName)
mds, err := p.makeMiddlewareTCPKeys(ctx, ingressRouteTCP.Namespace, route.Middlewares)
if err != nil {

View file

@ -5351,6 +5351,322 @@ func TestLoadIngressRoutes(t *testing.T) {
TLS: &dynamic.TLSConfiguration{},
},
},
{
desc: "IngressRoute with single parent (single route)",
paths: []string{"parent_refs_services.yml", "parent_refs_single_parent_single_route.yml"},
expected: &dynamic.Configuration{
UDP: &dynamic.UDPConfiguration{
Routers: map[string]*dynamic.UDPRouter{},
Services: map[string]*dynamic.UDPService{},
},
TCP: &dynamic.TCPConfiguration{
Routers: map[string]*dynamic.TCPRouter{},
Middlewares: map[string]*dynamic.TCPMiddleware{},
Services: map[string]*dynamic.TCPService{},
ServersTransports: map[string]*dynamic.TCPServersTransport{},
},
HTTP: &dynamic.HTTPConfiguration{
Routers: map[string]*dynamic.Router{
"default-parent-single-3c07cfffe8e5f876a01e": {
EntryPoints: []string{"web"},
Rule: "Host(`parent.example.com`)",
},
"default-child-single-2bba0a3de1b50b70a519": {
Service: "default-child-single-2bba0a3de1b50b70a519",
Rule: "Path(`/api`)",
ParentRefs: []string{"default-parent-single-3c07cfffe8e5f876a01e"},
},
},
Middlewares: map[string]*dynamic.Middleware{},
Services: map[string]*dynamic.Service{
"default-child-single-2bba0a3de1b50b70a519": {
LoadBalancer: &dynamic.ServersLoadBalancer{
Strategy: dynamic.BalancerStrategyWRR,
Servers: []dynamic.Server{
{
URL: "http://10.10.2.1:9000",
},
{
URL: "http://10.10.2.2:9000",
},
},
PassHostHeader: pointer(true),
ResponseForwarding: &dynamic.ResponseForwarding{
FlushInterval: ptypes.Duration(100 * time.Millisecond),
},
},
},
},
ServersTransports: map[string]*dynamic.ServersTransport{},
},
TLS: &dynamic.TLSConfiguration{},
},
},
{
desc: "IngressRoute with single parent (multiple routes) - all parent routers in ParentRefs",
paths: []string{"parent_refs_services.yml", "parent_refs_single_parent_multiple_routes.yml"},
expected: &dynamic.Configuration{
UDP: &dynamic.UDPConfiguration{
Routers: map[string]*dynamic.UDPRouter{},
Services: map[string]*dynamic.UDPService{},
},
TCP: &dynamic.TCPConfiguration{
Routers: map[string]*dynamic.TCPRouter{},
Middlewares: map[string]*dynamic.TCPMiddleware{},
Services: map[string]*dynamic.TCPService{},
ServersTransports: map[string]*dynamic.TCPServersTransport{},
},
HTTP: &dynamic.HTTPConfiguration{
Routers: map[string]*dynamic.Router{
"default-parent-multi-4aac0d541c2b669a2d5d": {
EntryPoints: []string{"web"},
Rule: "Host(`api.example.com`) && PathPrefix(`/v1`)",
},
"default-parent-multi-0af1ca0a94f5b87a125e": {
EntryPoints: []string{"web"},
Rule: "Host(`api.example.com`) && PathPrefix(`/v2`)",
},
"default-child-multi-routes-b0479051e6a353d66211": {
Service: "default-child-multi-routes-b0479051e6a353d66211",
Rule: "Path(`/users`)",
ParentRefs: []string{"default-parent-multi-4aac0d541c2b669a2d5d", "default-parent-multi-0af1ca0a94f5b87a125e"},
},
},
Middlewares: map[string]*dynamic.Middleware{},
Services: map[string]*dynamic.Service{
"default-child-multi-routes-b0479051e6a353d66211": {
LoadBalancer: &dynamic.ServersLoadBalancer{
Strategy: dynamic.BalancerStrategyWRR,
Servers: []dynamic.Server{
{
URL: "http://10.10.5.1:9000",
},
{
URL: "http://10.10.5.2:9000",
},
},
PassHostHeader: pointer(true),
ResponseForwarding: &dynamic.ResponseForwarding{
FlushInterval: ptypes.Duration(100 * time.Millisecond),
},
},
},
},
ServersTransports: map[string]*dynamic.ServersTransport{},
},
TLS: &dynamic.TLSConfiguration{},
},
},
{
desc: "IngressRoute with multiple parents",
paths: []string{"parent_refs_services.yml", "parent_refs_multiple_parents.yml"},
expected: &dynamic.Configuration{
UDP: &dynamic.UDPConfiguration{
Routers: map[string]*dynamic.UDPRouter{},
Services: map[string]*dynamic.UDPService{},
},
TCP: &dynamic.TCPConfiguration{
Routers: map[string]*dynamic.TCPRouter{},
Middlewares: map[string]*dynamic.TCPMiddleware{},
Services: map[string]*dynamic.TCPService{},
ServersTransports: map[string]*dynamic.TCPServersTransport{},
},
HTTP: &dynamic.HTTPConfiguration{
Routers: map[string]*dynamic.Router{
"default-parent-a-629990b524bf9a1a8d27": {
EntryPoints: []string{"web"},
Rule: "Host(`a.example.com`)",
},
"default-parent-b-add617f9b95cff009054": {
EntryPoints: []string{"web"},
Rule: "Host(`b.example.com`)",
},
"default-child-multi-parents-8013b5025acddd1761d1": {
Service: "default-child-multi-parents-8013b5025acddd1761d1",
Rule: "Path(`/shared`)",
ParentRefs: []string{"default-parent-a-629990b524bf9a1a8d27", "default-parent-b-add617f9b95cff009054"},
},
},
Middlewares: map[string]*dynamic.Middleware{},
Services: map[string]*dynamic.Service{
"default-child-multi-parents-8013b5025acddd1761d1": {
LoadBalancer: &dynamic.ServersLoadBalancer{
Strategy: dynamic.BalancerStrategyWRR,
Servers: []dynamic.Server{
{
URL: "http://10.10.8.1:9000",
},
{
URL: "http://10.10.8.2:9000",
},
},
PassHostHeader: pointer(true),
ResponseForwarding: &dynamic.ResponseForwarding{
FlushInterval: ptypes.Duration(100 * time.Millisecond),
},
},
},
},
ServersTransports: map[string]*dynamic.ServersTransport{},
},
TLS: &dynamic.TLSConfiguration{},
},
},
{
desc: "IngressRoute with missing parent - routers skipped",
paths: []string{"parent_refs_services.yml", "parent_refs_missing_parent.yml"},
expected: &dynamic.Configuration{
UDP: &dynamic.UDPConfiguration{
Routers: map[string]*dynamic.UDPRouter{},
Services: map[string]*dynamic.UDPService{},
},
TCP: &dynamic.TCPConfiguration{
Routers: map[string]*dynamic.TCPRouter{},
Middlewares: map[string]*dynamic.TCPMiddleware{},
Services: map[string]*dynamic.TCPService{},
ServersTransports: map[string]*dynamic.TCPServersTransport{},
},
HTTP: &dynamic.HTTPConfiguration{
Routers: map[string]*dynamic.Router{},
Middlewares: map[string]*dynamic.Middleware{},
Services: map[string]*dynamic.Service{},
ServersTransports: map[string]*dynamic.ServersTransport{},
},
TLS: &dynamic.TLSConfiguration{},
},
},
{
desc: "IngressRoute with cross-namespace parent allowed",
allowCrossNamespace: true,
paths: []string{"parent_refs_services.yml", "parent_refs_cross_namespace_allowed.yml"},
expected: &dynamic.Configuration{
UDP: &dynamic.UDPConfiguration{
Routers: map[string]*dynamic.UDPRouter{},
Services: map[string]*dynamic.UDPService{},
},
TCP: &dynamic.TCPConfiguration{
Routers: map[string]*dynamic.TCPRouter{},
Middlewares: map[string]*dynamic.TCPMiddleware{},
Services: map[string]*dynamic.TCPService{},
ServersTransports: map[string]*dynamic.TCPServersTransport{},
},
HTTP: &dynamic.HTTPConfiguration{
Routers: map[string]*dynamic.Router{
"ns-a-parent-cross-74575ab54671a3ede28c": {
EntryPoints: []string{"web"},
Rule: "Host(`cross.example.com`)",
},
"ns-b-child-cross-allowed-0bad04de665623bf2362": {
Service: "ns-b-child-cross-allowed-0bad04de665623bf2362",
Rule: "Path(`/cross`)",
ParentRefs: []string{"ns-a-parent-cross-74575ab54671a3ede28c"},
},
},
Middlewares: map[string]*dynamic.Middleware{},
Services: map[string]*dynamic.Service{
"ns-b-child-cross-allowed-0bad04de665623bf2362": {
LoadBalancer: &dynamic.ServersLoadBalancer{
Strategy: dynamic.BalancerStrategyWRR,
Servers: []dynamic.Server{
{
URL: "http://10.10.11.1:9000",
},
{
URL: "http://10.10.11.2:9000",
},
},
PassHostHeader: pointer(true),
ResponseForwarding: &dynamic.ResponseForwarding{
FlushInterval: ptypes.Duration(100 * time.Millisecond),
},
},
},
},
ServersTransports: map[string]*dynamic.ServersTransport{},
},
TLS: &dynamic.TLSConfiguration{},
},
},
{
desc: "IngressRoute with cross-namespace parent denied",
allowCrossNamespace: false,
paths: []string{"parent_refs_services.yml", "parent_refs_cross_namespace_denied.yml"},
expected: &dynamic.Configuration{
UDP: &dynamic.UDPConfiguration{
Routers: map[string]*dynamic.UDPRouter{},
Services: map[string]*dynamic.UDPService{},
},
TCP: &dynamic.TCPConfiguration{
Routers: map[string]*dynamic.TCPRouter{},
Middlewares: map[string]*dynamic.TCPMiddleware{},
Services: map[string]*dynamic.TCPService{},
ServersTransports: map[string]*dynamic.TCPServersTransport{},
},
HTTP: &dynamic.HTTPConfiguration{
Routers: map[string]*dynamic.Router{
"ns-a-parent-cross-74575ab54671a3ede28c": {
EntryPoints: []string{"web"},
Rule: "Host(`cross.example.com`)",
},
},
Middlewares: map[string]*dynamic.Middleware{},
Services: map[string]*dynamic.Service{},
ServersTransports: map[string]*dynamic.ServersTransport{},
},
TLS: &dynamic.TLSConfiguration{},
},
},
{
desc: "IngressRoute with parent namespace defaulting to child namespace",
paths: []string{"parent_refs_services.yml", "parent_refs_default_namespace.yml"},
expected: &dynamic.Configuration{
UDP: &dynamic.UDPConfiguration{
Routers: map[string]*dynamic.UDPRouter{},
Services: map[string]*dynamic.UDPService{},
},
TCP: &dynamic.TCPConfiguration{
Routers: map[string]*dynamic.TCPRouter{},
Middlewares: map[string]*dynamic.TCPMiddleware{},
Services: map[string]*dynamic.TCPService{},
ServersTransports: map[string]*dynamic.TCPServersTransport{},
},
HTTP: &dynamic.HTTPConfiguration{
Routers: map[string]*dynamic.Router{
"default-parent-default-9b8ab283eeed3eb66561": {
EntryPoints: []string{"web"},
Rule: "Host(`default.example.com`)",
},
"default-child-same-9234eba1edcfbd8a7723": {
Service: "default-child-same-9234eba1edcfbd8a7723",
Rule: "Path(`/same`)",
ParentRefs: []string{"default-parent-default-9b8ab283eeed3eb66561"},
},
},
Middlewares: map[string]*dynamic.Middleware{},
Services: map[string]*dynamic.Service{
"default-child-same-9234eba1edcfbd8a7723": {
LoadBalancer: &dynamic.ServersLoadBalancer{
Strategy: dynamic.BalancerStrategyWRR,
Servers: []dynamic.Server{
{
URL: "http://10.10.14.1:9000",
},
{
URL: "http://10.10.14.2:9000",
},
},
PassHostHeader: pointer(true),
ResponseForwarding: &dynamic.ResponseForwarding{
FlushInterval: ptypes.Duration(100 * time.Millisecond),
},
},
},
},
ServersTransports: map[string]*dynamic.ServersTransport{},
},
TLS: &dynamic.TLSConfiguration{},
},
},
}
for _, test := range testCases {

View file

@ -19,6 +19,10 @@ type IngressRouteSpec struct {
// TLS defines the TLS configuration.
// More info: https://doc.traefik.io/traefik/v3.5/reference/routing-configuration/http/routing/router/#tls
TLS *TLS `json:"tls,omitempty"`
// ParentRefs defines references to parent IngressRoute resources for multi-layer routing.
// When set, this IngressRoute's routers will be children of the referenced parent IngressRoute's routers.
// More info: https://doc.traefik.io/traefik/v3.5/routing/routers/#parentrefs
ParentRefs []IngressRouteRef `json:"parentRefs,omitempty"`
}
// Route holds the HTTP route configuration.
@ -211,6 +215,14 @@ type MiddlewareRef struct {
Namespace string `json:"namespace,omitempty"`
}
// IngressRouteRef is a reference to an IngressRoute resource.
type IngressRouteRef struct {
// Name defines the name of the referenced IngressRoute resource.
Name string `json:"name"`
// Namespace defines the namespace of the referenced IngressRoute resource.
Namespace string `json:"namespace,omitempty"`
}
// +genclient
// +k8s:deepcopy-gen:interfaces=k8s.io/apimachinery/pkg/runtime.Object
// +kubebuilder:storageversion

View file

@ -432,6 +432,22 @@ func (in *IngressRouteList) DeepCopyObject() runtime.Object {
return nil
}
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
func (in *IngressRouteRef) DeepCopyInto(out *IngressRouteRef) {
*out = *in
return
}
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new IngressRouteRef.
func (in *IngressRouteRef) DeepCopy() *IngressRouteRef {
if in == nil {
return nil
}
out := new(IngressRouteRef)
in.DeepCopyInto(out)
return out
}
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
func (in *IngressRouteSpec) DeepCopyInto(out *IngressRouteSpec) {
*out = *in
@ -452,6 +468,11 @@ func (in *IngressRouteSpec) DeepCopyInto(out *IngressRouteSpec) {
*out = new(TLS)
(*in).DeepCopyInto(*out)
}
if in.ParentRefs != nil {
in, out := &in.ParentRefs, &out.ParentRefs
*out = make([]IngressRouteRef, len(*in))
copy(*out, *in)
}
return
}

View file

@ -46,7 +46,9 @@ func mergeConfiguration(configurations dynamic.Configurations, defaultEntryPoint
for pvd, configuration := range configurations {
if configuration.HTTP != nil {
for routerName, router := range configuration.HTTP.Routers {
if len(router.EntryPoints) == 0 {
// If no entrypoint is defined, and the router has no parentRefs (i.e. is not a child router),
// we set the default entrypoints.
if len(router.EntryPoints) == 0 && router.ParentRefs == nil {
log.Debug().
Str(logs.RouterName, routerName).
Strs(logs.EntryPointName, defaultEntryPoints).
@ -164,6 +166,11 @@ func applyModel(cfg dynamic.Configuration) dynamic.Configuration {
rts := make(map[string]*dynamic.Router)
for name, rt := range cfg.HTTP.Routers {
// Only root routers can have models applied.
if rt.ParentRefs != nil {
continue
}
router := rt.DeepCopy()
if !router.DefaultRule && router.RuleSyntax == "" {
@ -265,6 +272,11 @@ func applyModel(cfg dynamic.Configuration) dynamic.Configuration {
func applyDefaultObservabilityModel(cfg dynamic.Configuration) {
if cfg.HTTP != nil {
for _, router := range cfg.HTTP.Routers {
// Only root routers can have models applied.
if router.ParentRefs != nil {
continue
}
if router.Observability == nil {
router.Observability = &dynamic.RouterObservabilityConfig{
AccessLogs: pointer(true),

View file

@ -6,6 +6,8 @@ import (
"fmt"
"math"
"net/http"
"slices"
"sort"
"strings"
"github.com/containous/alice"
@ -149,6 +151,12 @@ func (m *Manager) buildEntryPointHandler(ctx context.Context, entryPointName str
continue
}
// Only build handlers for root routers (routers without ParentRefs).
// Routers with ParentRefs will be built as part of their parent router's muxer.
if len(routerConfig.ParentRefs) > 0 {
continue
}
handler, err := m.buildRouterHandler(ctxRouter, routerName, routerConfig)
if err != nil {
routerConfig.AddError(err, true)
@ -215,33 +223,272 @@ func (m *Manager) buildHTTPHandler(ctx context.Context, router *runtime.RouterIn
}
router.Middlewares = qualifiedNames
if router.Service == "" {
return nil, errors.New("the service is missing on the router")
}
qualifiedService := provider.GetQualifiedName(ctx, router.Service)
chain := alice.New()
if router.DefaultRule {
chain = chain.Append(denyrouterrecursion.WrapHandler(routerName))
}
// Access logs, metrics, and tracing middlewares are idempotent if the associated signal is disabled.
chain = chain.Append(observability.WrapRouterHandler(ctx, routerName, router.Rule, qualifiedService))
metricsHandler := metricsMiddle.RouterMetricsHandler(ctx, m.observabilityMgr.MetricsRegistry(), routerName, qualifiedService)
var (
nextHandler http.Handler
serviceName string
err error
)
// Check if this router has child routers or a service.
switch {
case len(router.ChildRefs) > 0:
// This router routes to child routers - create a muxer for them
nextHandler, err = m.buildChildRoutersMuxer(ctx, router.ChildRefs)
if err != nil {
return nil, fmt.Errorf("building child routers muxer: %w", err)
}
serviceName = fmt.Sprintf("%s-muxer", routerName)
case router.Service != "":
// This router routes to a service
qualifiedService := provider.GetQualifiedName(ctx, router.Service)
nextHandler, err = m.serviceManager.BuildHTTP(ctx, qualifiedService)
if err != nil {
return nil, err
}
serviceName = qualifiedService
default:
return nil, errors.New("router must have either a service or child routers")
}
// Access logs, metrics, and tracing middlewares are idempotent if the associated signal is disabled.
chain = chain.Append(observability.WrapRouterHandler(ctx, routerName, router.Rule, serviceName))
metricsHandler := metricsMiddle.RouterMetricsHandler(ctx, m.observabilityMgr.MetricsRegistry(), routerName, serviceName)
chain = chain.Append(observability.WrapMiddleware(ctx, metricsHandler))
chain = chain.Append(func(next http.Handler) (http.Handler, error) {
return accesslog.NewFieldHandler(next, accesslog.RouterName, routerName, nil), nil
return accesslog.NewConcatFieldHandler(next, accesslog.RouterName, routerName), nil
})
mHandler := m.middlewaresBuilder.BuildChain(ctx, router.Middlewares)
sHandler, err := m.serviceManager.BuildHTTP(ctx, qualifiedService)
if err != nil {
return nil, err
return chain.Extend(*mHandler).Then(nextHandler)
}
// ParseRouterTree sets up router tree and validates router configuration.
// This function performs the following operations in order:
//
// 1. Populate ChildRefs: Uses ParentRefs to build the parent-child relationship graph
// 2. Root-first traversal: Starting from root routers (no ParentRefs), traverses the tree
// 3. Cycle detection: Detects circular dependencies and removes cyclic links
// 4. Reachability check: Marks routers unreachable from any root as disabled
// 5. Dead-end detection: Marks routers with no service and no children as disabled
// 6. Validation: Checks for configuration errors
//
// Router status is set during this process:
// - Enabled: Reachable routers with valid configuration
// - Disabled: Unreachable, dead-end, or routers with critical errors
// - Warning: Routers with non-critical errors (like cycles)
//
// The function modifies router.Status, router.ChildRefs, and adds errors to router.Err.
func (m *Manager) ParseRouterTree() {
if m.conf == nil || m.conf.Routers == nil {
return
}
return chain.Extend(*mHandler).Then(sHandler)
// Populate ChildRefs based on ParentRefs and find root routers.
var rootRouters []string
for routerName, router := range m.conf.Routers {
if len(router.ParentRefs) == 0 {
rootRouters = append(rootRouters, routerName)
continue
}
for _, parentName := range router.ParentRefs {
if parentRouter, exists := m.conf.Routers[parentName]; exists {
// Add this router as a child of its parent
if !slices.Contains(parentRouter.ChildRefs, routerName) {
parentRouter.ChildRefs = append(parentRouter.ChildRefs, routerName)
}
} else {
router.AddError(fmt.Errorf("parent router %q does not exist", parentName), true)
}
}
// Check for non-root router with TLS config.
if router.TLS != nil {
router.AddError(errors.New("non-root router cannot have TLS configuration"), true)
continue
}
// Check for non-root router with Observability config.
if router.Observability != nil {
router.AddError(errors.New("non-root router cannot have Observability configuration"), true)
continue
}
// Check for non-root router with Entrypoint config.
if len(router.EntryPoints) > 0 {
router.AddError(errors.New("non-root router cannot have Entrypoints configuration"), true)
continue
}
}
sort.Strings(rootRouters)
// Root-first traversal with cycle detection.
visited := make(map[string]bool)
currentPath := make(map[string]bool)
var path []string
for _, rootName := range rootRouters {
if !visited[rootName] {
m.traverse(rootName, visited, currentPath, path)
}
}
for routerName, router := range m.conf.Routers {
// Set status for all routers based on reachability.
if !visited[routerName] {
router.AddError(errors.New("router is not reachable"), true)
continue
}
// Detect dead-end routers (no service + no children) - AFTER cycle handling.
if router.Service == "" && len(router.ChildRefs) == 0 {
router.AddError(errors.New("router has no service and no child routers"), true)
continue
}
// Check for router with service that is referenced as a parent.
if router.Service != "" && len(router.ChildRefs) > 0 {
router.AddError(errors.New("router has both a service and is referenced as a parent by other routers"), true)
continue
}
}
}
// traverse performs a depth-first traversal starting from root routers,
// detecting cycles and marking visited routers for reachability detection.
func (m *Manager) traverse(routerName string, visited, currentPath map[string]bool, path []string) {
if currentPath[routerName] {
// Found a cycle - handle it properly.
m.handleCycle(routerName, path)
return
}
if visited[routerName] {
return
}
router, exists := m.conf.Routers[routerName]
// Since the ChildRefs population already guarantees router existence, this check is purely defensive.
if !exists {
visited[routerName] = true
return
}
visited[routerName] = true
currentPath[routerName] = true
newPath := append(path, routerName)
// Sort ChildRefs for deterministic behavior.
sortedChildRefs := make([]string, len(router.ChildRefs))
copy(sortedChildRefs, router.ChildRefs)
sort.Strings(sortedChildRefs)
// Traverse children.
for _, childName := range sortedChildRefs {
m.traverse(childName, visited, currentPath, newPath)
}
currentPath[routerName] = false
}
// handleCycle handles cycle detection and removes the victim from guilty router's ChildRefs.
func (m *Manager) handleCycle(victimRouter string, path []string) {
// Find where the cycle starts in the path
cycleStart := -1
for i, name := range path {
if name == victimRouter {
cycleStart = i
break
}
}
if cycleStart < 0 {
return
}
// Build the cycle path: from cycle start to current + victim.
cyclePath := append(path[cycleStart:], victimRouter)
cycleRouters := strings.Join(cyclePath, " -> ")
// The guilty router is the last one in the path (the one creating the cycle).
if len(path) > 0 {
guiltyRouterName := path[len(path)-1]
guiltyRouter, exists := m.conf.Routers[guiltyRouterName]
if !exists {
return
}
// Add cycle error to guilty router.
guiltyRouter.AddError(fmt.Errorf("cyclic reference detected in router tree: %s", cycleRouters), false)
// Remove victim from guilty router's ChildRefs.
for i, childRef := range guiltyRouter.ChildRefs {
if childRef == victimRouter {
guiltyRouter.ChildRefs = append(guiltyRouter.ChildRefs[:i], guiltyRouter.ChildRefs[i+1:]...)
break
}
}
}
}
// buildChildRoutersMuxer creates a muxer for child routers.
func (m *Manager) buildChildRoutersMuxer(ctx context.Context, childRefs []string) (http.Handler, error) {
childMuxer := httpmuxer.NewMuxer(m.parser)
// Set a default handler for the child muxer (404 Not Found).
childMuxer.SetDefaultHandler(http.NotFoundHandler())
childCount := 0
for _, childName := range childRefs {
childRouter, exists := m.conf.Routers[childName]
if !exists {
return nil, fmt.Errorf("child router %q does not exist", childName)
}
// Skip child routers with errors.
if len(childRouter.Err) > 0 {
continue
}
logger := log.Ctx(ctx).With().Str(logs.RouterName, childName).Logger()
ctxChild := logger.WithContext(provider.AddInContext(ctx, childName))
// Set priority if not set.
if childRouter.Priority == 0 {
childRouter.Priority = httpmuxer.GetRulePriority(childRouter.Rule)
}
// Build the child router handler.
childHandler, err := m.buildRouterHandler(ctxChild, childName, childRouter)
if err != nil {
childRouter.AddError(err, true)
logger.Error().Err(err).Send()
continue
}
// Add the child router to the muxer.
if err = childMuxer.AddRoute(childRouter.Rule, childRouter.RuleSyntax, childRouter.Priority, childHandler); err != nil {
childRouter.AddError(err, true)
logger.Error().Err(err).Send()
continue
}
childCount++
}
// Prevent empty muxer.
if childCount == 0 {
return nil, fmt.Errorf("no child routers could be added to muxer (%d skipped)", len(childRefs))
}
return childMuxer, nil
}

View file

@ -1,6 +1,7 @@
package router
import (
"context"
"crypto/tls"
"io"
"math"
@ -11,6 +12,7 @@ import (
"testing"
"time"
"github.com/containous/alice"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
ptypes "github.com/traefik/paerser/types"
@ -927,6 +929,940 @@ func BenchmarkService(b *testing.B) {
}
}
func TestManager_ComputeMultiLayerRouting(t *testing.T) {
testCases := []struct {
desc string
routers map[string]*dynamic.Router
expectedStatuses map[string]string
expectedChildRefs map[string][]string
expectedErrors map[string][]string
expectedErrorCounts map[string]int
}{
{
desc: "Simple router",
routers: map[string]*dynamic.Router{
"A": {
Service: "A-service",
},
},
expectedStatuses: map[string]string{
"A": runtime.StatusEnabled,
},
expectedChildRefs: map[string][]string{
"A": {},
},
},
{
// A->B1
// ->B2
desc: "Router with two children",
routers: map[string]*dynamic.Router{
"A": {},
"B1": {
ParentRefs: []string{"A"},
Service: "B1-service",
},
"B2": {
ParentRefs: []string{"A"},
Service: "B2-service",
},
},
expectedStatuses: map[string]string{
"A": runtime.StatusEnabled,
"B1": runtime.StatusEnabled,
"B2": runtime.StatusEnabled,
},
expectedChildRefs: map[string][]string{
"A": {"B1", "B2"},
"B1": nil,
"B2": nil,
},
},
{
desc: "Non-root router with TLS config",
routers: map[string]*dynamic.Router{
"A": {},
"B": {
ParentRefs: []string{"A"},
Service: "B-service",
TLS: &dynamic.RouterTLSConfig{},
},
},
expectedStatuses: map[string]string{
"A": runtime.StatusEnabled,
"B": runtime.StatusDisabled,
},
expectedChildRefs: map[string][]string{
"A": {"B"},
"B": nil,
},
expectedErrors: map[string][]string{
"B": {"non-root router cannot have TLS configuration"},
},
},
{
desc: "Non-root router with observability config",
routers: map[string]*dynamic.Router{
"A": {},
"B": {
ParentRefs: []string{"A"},
Service: "B-service",
Observability: &dynamic.RouterObservabilityConfig{},
},
},
expectedStatuses: map[string]string{
"A": runtime.StatusEnabled,
"B": runtime.StatusDisabled,
},
expectedChildRefs: map[string][]string{
"A": {"B"},
"B": nil,
},
expectedErrors: map[string][]string{
"B": {"non-root router cannot have Observability configuration"},
},
},
{
desc: "Non-root router with EntryPoints config",
routers: map[string]*dynamic.Router{
"A": {},
"B": {
ParentRefs: []string{"A"},
Service: "B-service",
EntryPoints: []string{"web"},
},
},
expectedStatuses: map[string]string{
"A": runtime.StatusEnabled,
"B": runtime.StatusDisabled,
},
expectedChildRefs: map[string][]string{
"A": {"B"},
"B": nil,
},
expectedErrors: map[string][]string{
"B": {"non-root router cannot have Entrypoints configuration"},
},
},
{
desc: "Router with non-existing parent",
routers: map[string]*dynamic.Router{
"B": {
ParentRefs: []string{"A"},
Service: "B-service",
},
},
expectedStatuses: map[string]string{
"B": runtime.StatusDisabled,
},
expectedChildRefs: map[string][]string{
"B": nil,
},
expectedErrors: map[string][]string{
"B": {"parent router \"A\" does not exist", "router is not reachable"},
},
},
{
desc: "Dead-end router with no child and no service",
routers: map[string]*dynamic.Router{
"A": {},
},
expectedStatuses: map[string]string{
"A": runtime.StatusDisabled,
},
expectedChildRefs: map[string][]string{
"A": {},
},
expectedErrors: map[string][]string{
"A": {"router has no service and no child routers"},
},
},
{
// A->B->A
desc: "Router is not reachable",
routers: map[string]*dynamic.Router{
"A": {
ParentRefs: []string{"B"},
},
"B": {
ParentRefs: []string{"A"},
},
},
expectedStatuses: map[string]string{
"A": runtime.StatusDisabled,
"B": runtime.StatusDisabled,
},
expectedChildRefs: map[string][]string{
"A": {"B"},
"B": {"A"},
},
// Cycle detection does not visit unreachable routers (it avoids computing the cycle dependency graph for unreachable routers).
expectedErrors: map[string][]string{
"A": {"router is not reachable"},
"B": {"router is not reachable"},
},
},
{
// A->B->C->D->B
desc: "Router creating a cycle is a dead-end and should be disabled",
routers: map[string]*dynamic.Router{
"A": {},
"B": {
ParentRefs: []string{"A", "D"},
},
"C": {
ParentRefs: []string{"B"},
},
"D": {
ParentRefs: []string{"C"},
},
},
expectedStatuses: map[string]string{
"A": runtime.StatusEnabled,
"B": runtime.StatusEnabled,
"C": runtime.StatusEnabled,
"D": runtime.StatusDisabled, // Dead-end router is disabled, because the cycle error broke the link with B.
},
expectedChildRefs: map[string][]string{
"A": {"B"},
"B": {"C"},
"C": {"D"},
"D": {},
},
expectedErrors: map[string][]string{
"D": {
"cyclic reference detected in router tree: B -> C -> D -> B",
"router has no service and no child routers",
},
},
},
{
// A->B->C->D->B
// ->E
desc: "Router creating a cycle A->B->C->D->B but which is referenced elsewhere, must be set to warning status",
routers: map[string]*dynamic.Router{
"A": {},
"B": {
ParentRefs: []string{"A", "D"},
},
"C": {
ParentRefs: []string{"B"},
},
"D": {
ParentRefs: []string{"C"},
},
"E": {
ParentRefs: []string{"D"},
Service: "E-service",
},
},
expectedStatuses: map[string]string{
"A": runtime.StatusEnabled,
"B": runtime.StatusEnabled,
"C": runtime.StatusEnabled,
"D": runtime.StatusWarning,
"E": runtime.StatusEnabled,
},
expectedChildRefs: map[string][]string{
"A": {"B"},
"B": {"C"},
"C": {"D"},
"D": {"E"},
},
expectedErrors: map[string][]string{
"D": {"cyclic reference detected in router tree: B -> C -> D -> B"},
},
},
{
desc: "Parent router with all children having errors",
routers: map[string]*dynamic.Router{
"parent": {},
"child-a": {
ParentRefs: []string{"parent"},
Service: "child-a-service",
TLS: &dynamic.RouterTLSConfig{}, // Invalid: non-root cannot have TLS
},
"child-b": {
ParentRefs: []string{"parent"},
Service: "child-b-service",
TLS: &dynamic.RouterTLSConfig{}, // Invalid: non-root cannot have TLS
},
},
expectedStatuses: map[string]string{
"parent": runtime.StatusEnabled, // Enabled during ParseRouterTree (no config errors). Would be disabled during handler building when empty muxer is detected.
"child-a": runtime.StatusDisabled,
"child-b": runtime.StatusDisabled,
},
expectedChildRefs: map[string][]string{
"parent": {"child-a", "child-b"},
"child-a": nil,
"child-b": nil,
},
expectedErrors: map[string][]string{
"child-a": {"non-root router cannot have TLS configuration"},
"child-b": {"non-root router cannot have TLS configuration"},
},
},
}
for _, test := range testCases {
t.Run(test.desc, func(t *testing.T) {
// Create runtime routers
runtimeRouters := make(map[string]*runtime.RouterInfo)
for name, router := range test.routers {
runtimeRouters[name] = &runtime.RouterInfo{
Router: router,
Status: runtime.StatusEnabled,
}
}
conf := &runtime.Configuration{
Routers: runtimeRouters,
}
manager := &Manager{
conf: conf,
}
// Execute the function we're testing
manager.ParseRouterTree()
// Verify ChildRefs are populated correctly
for routerName, expectedChildren := range test.expectedChildRefs {
router := runtimeRouters[routerName]
assert.ElementsMatch(t, expectedChildren, router.ChildRefs)
}
// Verify statuses are set correctly
var gotStatuses map[string]string
for routerName, router := range runtimeRouters {
if gotStatuses == nil {
gotStatuses = make(map[string]string)
}
gotStatuses[routerName] = router.Status
}
assert.Equal(t, test.expectedStatuses, gotStatuses)
// Verify errors are added correctly
var gotErrors map[string][]string
for routerName, router := range runtimeRouters {
for _, err := range router.Err {
if gotErrors == nil {
gotErrors = make(map[string][]string)
}
gotErrors[routerName] = append(gotErrors[routerName], err)
}
}
assert.Equal(t, test.expectedErrors, gotErrors)
})
}
}
func TestManager_buildChildRoutersMuxer(t *testing.T) {
testCases := []struct {
desc string
childRefs []string
routers map[string]*dynamic.Router
services map[string]*dynamic.Service
middlewares map[string]*dynamic.Middleware
expectedError string
expectedRequests []struct {
path string
statusCode int
}
}{
{
desc: "simple child router with service",
childRefs: []string{"child1"},
routers: map[string]*dynamic.Router{
"child1": {
Rule: "Path(`/api`)",
Service: "child1-service",
},
},
services: map[string]*dynamic.Service{
"child1-service": {
LoadBalancer: &dynamic.ServersLoadBalancer{
Servers: []dynamic.Server{{URL: "http://localhost:8080"}},
},
},
},
expectedRequests: []struct {
path string
statusCode int
}{
{path: "/api", statusCode: http.StatusOK},
{path: "/unknown", statusCode: http.StatusNotFound},
},
},
{
desc: "multiple child routers with different rules",
childRefs: []string{"child1", "child2"},
routers: map[string]*dynamic.Router{
"child1": {
Rule: "Path(`/api`)",
Service: "child1-service",
},
"child2": {
Rule: "Path(`/web`)",
Service: "child2-service",
},
},
services: map[string]*dynamic.Service{
"child1-service": {
LoadBalancer: &dynamic.ServersLoadBalancer{
Servers: []dynamic.Server{{URL: "http://localhost:8080"}},
},
},
"child2-service": {
LoadBalancer: &dynamic.ServersLoadBalancer{
Servers: []dynamic.Server{{URL: "http://localhost:8081"}},
},
},
},
expectedRequests: []struct {
path string
statusCode int
}{
{path: "/api", statusCode: http.StatusOK},
{path: "/web", statusCode: http.StatusOK},
{path: "/unknown", statusCode: http.StatusNotFound},
},
},
{
desc: "child router with middleware",
childRefs: []string{"child1"},
routers: map[string]*dynamic.Router{
"child1": {
Rule: "Path(`/api`)",
Service: "child1-service",
Middlewares: []string{"test-middleware"},
},
},
services: map[string]*dynamic.Service{
"child1-service": {
LoadBalancer: &dynamic.ServersLoadBalancer{
Servers: []dynamic.Server{{URL: "http://localhost:8080"}},
},
},
},
middlewares: map[string]*dynamic.Middleware{
"test-middleware": {
Headers: &dynamic.Headers{
CustomRequestHeaders: map[string]string{"X-Test": "value"},
},
},
},
expectedRequests: []struct {
path string
statusCode int
}{
{path: "/api", statusCode: http.StatusOK},
{path: "/unknown", statusCode: http.StatusNotFound},
},
},
{
desc: "nested child routers (child with its own children)",
childRefs: []string{"intermediate"},
routers: map[string]*dynamic.Router{
"intermediate": {
Rule: "PathPrefix(`/api`)",
// No service - this will have its own children
},
"leaf1": {
Rule: "Path(`/api/v1`)",
Service: "leaf1-service",
ParentRefs: []string{"intermediate"},
},
"leaf2": {
Rule: "Path(`/api/v2`)",
Service: "leaf2-service",
ParentRefs: []string{"intermediate"},
},
},
services: map[string]*dynamic.Service{
"leaf1-service": {
LoadBalancer: &dynamic.ServersLoadBalancer{
Servers: []dynamic.Server{{URL: "http://localhost:8080"}},
},
},
"leaf2-service": {
LoadBalancer: &dynamic.ServersLoadBalancer{
Servers: []dynamic.Server{{URL: "http://localhost:8081"}},
},
},
},
expectedRequests: []struct {
path string
statusCode int
}{
{path: "/api/v1", statusCode: http.StatusOK},
{path: "/api/v2", statusCode: http.StatusOK},
{path: "/unknown", statusCode: http.StatusNotFound},
},
},
{
desc: "all child routers have errors - should return error",
childRefs: []string{"child1", "child2"},
routers: map[string]*dynamic.Router{
"child1": {
Rule: "Path(`/api`)",
Service: "child1-service",
ParentRefs: []string{"parent"},
TLS: &dynamic.RouterTLSConfig{}, // Invalid: non-root router cannot have TLS
},
"child2": {
Rule: "Path(`/web`)",
Service: "child2-service",
ParentRefs: []string{"parent"},
TLS: &dynamic.RouterTLSConfig{}, // Invalid: non-root router cannot have TLS
},
},
services: map[string]*dynamic.Service{
"child1-service": {
LoadBalancer: &dynamic.ServersLoadBalancer{
Servers: []dynamic.Server{{URL: "http://localhost:8080"}},
},
},
"child2-service": {
LoadBalancer: &dynamic.ServersLoadBalancer{
Servers: []dynamic.Server{{URL: "http://localhost:8081"}},
},
},
},
expectedError: "no child routers could be added to muxer (2 skipped)",
},
}
for _, test := range testCases {
t.Run(test.desc, func(t *testing.T) {
// Create runtime routers
runtimeRouters := make(map[string]*runtime.RouterInfo)
for name, router := range test.routers {
runtimeRouters[name] = &runtime.RouterInfo{
Router: router,
}
}
// Create runtime services
runtimeServices := make(map[string]*runtime.ServiceInfo)
for name, service := range test.services {
runtimeServices[name] = &runtime.ServiceInfo{
Service: service,
}
}
// Create runtime middlewares
runtimeMiddlewares := make(map[string]*runtime.MiddlewareInfo)
for name, middleware := range test.middlewares {
runtimeMiddlewares[name] = &runtime.MiddlewareInfo{
Middleware: middleware,
}
}
conf := &runtime.Configuration{
Routers: runtimeRouters,
Services: runtimeServices,
Middlewares: runtimeMiddlewares,
}
// Set up the manager with mocks
serviceManager := &mockServiceManager{}
middlewareBuilder := &mockMiddlewareBuilder{}
parser, err := httpmuxer.NewSyntaxParser()
require.NoError(t, err)
manager := NewManager(conf, serviceManager, middlewareBuilder, nil, nil, parser)
// Compute multi-layer routing to populate ChildRefs
manager.ParseRouterTree()
// Build the child routers muxer
ctx := t.Context()
muxer, err := manager.buildChildRoutersMuxer(ctx, test.childRefs)
if test.expectedError != "" {
require.Error(t, err)
assert.Contains(t, err.Error(), test.expectedError)
return
}
if len(test.childRefs) == 0 {
assert.Error(t, err)
return
}
require.NoError(t, err)
require.NotNil(t, muxer)
// Test that the muxer routes requests correctly
for _, req := range test.expectedRequests {
recorder := httptest.NewRecorder()
request := httptest.NewRequest(http.MethodGet, req.path, nil)
muxer.ServeHTTP(recorder, request)
assert.Equal(t, req.statusCode, recorder.Code, "unexpected status code for path %s", req.path)
}
})
}
}
func TestManager_buildHTTPHandler_WithChildRouters(t *testing.T) {
testCases := []struct {
desc string
router *runtime.RouterInfo
childRouters map[string]*dynamic.Router
services map[string]*dynamic.Service
expectedError string
expectedRequests []struct {
path string
statusCode int
}
}{
{
desc: "router with child routers",
router: &runtime.RouterInfo{
Router: &dynamic.Router{
Rule: "PathPrefix(`/api`)",
},
ChildRefs: []string{"child1", "child2"},
},
childRouters: map[string]*dynamic.Router{
"child1": {
Rule: "Path(`/api/v1`)",
Service: "child1-service",
},
"child2": {
Rule: "Path(`/api/v2`)",
Service: "child2-service",
},
},
services: map[string]*dynamic.Service{
"child1-service": {
LoadBalancer: &dynamic.ServersLoadBalancer{
Servers: []dynamic.Server{{URL: "http://localhost:8080"}},
},
},
"child2-service": {
LoadBalancer: &dynamic.ServersLoadBalancer{
Servers: []dynamic.Server{{URL: "http://localhost:8081"}},
},
},
},
expectedRequests: []struct {
path string
statusCode int
}{
{path: "/unknown", statusCode: http.StatusNotFound},
},
},
{
desc: "router with service (normal case)",
router: &runtime.RouterInfo{
Router: &dynamic.Router{
Rule: "PathPrefix(`/api`)",
Service: "main-service",
},
},
services: map[string]*dynamic.Service{
"main-service": {
LoadBalancer: &dynamic.ServersLoadBalancer{
Servers: []dynamic.Server{{URL: "http://localhost:8080"}},
},
},
},
expectedRequests: []struct {
path string
statusCode int
}{},
},
{
desc: "router with neither service nor child routers - error",
router: &runtime.RouterInfo{
Router: &dynamic.Router{
Rule: "PathPrefix(`/api`)",
},
},
expectedError: "router must have either a service or child routers",
},
{
desc: "router with child routers but missing child - error",
router: &runtime.RouterInfo{
Router: &dynamic.Router{
Rule: "PathPrefix(`/api`)",
},
ChildRefs: []string{"nonexistent"},
},
expectedError: "child router \"nonexistent\" does not exist",
},
{
desc: "router with all children having errors - returns empty muxer error",
router: &runtime.RouterInfo{
Router: &dynamic.Router{
Rule: "PathPrefix(`/api`)",
},
ChildRefs: []string{"child1", "child2"},
},
childRouters: map[string]*dynamic.Router{
"child1": {
Rule: "Path(`/api/v1`)",
Service: "child1-service",
ParentRefs: []string{"parent"},
TLS: &dynamic.RouterTLSConfig{}, // Invalid for non-root
},
"child2": {
Rule: "Path(`/api/v2`)",
Service: "child2-service",
ParentRefs: []string{"parent"},
TLS: &dynamic.RouterTLSConfig{}, // Invalid for non-root
},
},
services: map[string]*dynamic.Service{
"child1-service": {
LoadBalancer: &dynamic.ServersLoadBalancer{
Servers: []dynamic.Server{{URL: "http://localhost:8080"}},
},
},
"child2-service": {
LoadBalancer: &dynamic.ServersLoadBalancer{
Servers: []dynamic.Server{{URL: "http://localhost:8081"}},
},
},
},
expectedError: "no child routers could be added to muxer (2 skipped)",
},
}
for _, test := range testCases {
t.Run(test.desc, func(t *testing.T) {
// Create runtime routers
runtimeRouters := make(map[string]*runtime.RouterInfo)
runtimeRouters["test-router"] = test.router
for name, router := range test.childRouters {
runtimeRouters[name] = &runtime.RouterInfo{
Router: router,
}
}
// Create runtime services
runtimeServices := make(map[string]*runtime.ServiceInfo)
for name, service := range test.services {
runtimeServices[name] = &runtime.ServiceInfo{
Service: service,
}
}
conf := &runtime.Configuration{
Routers: runtimeRouters,
Services: runtimeServices,
}
// Set up the manager with mocks
serviceManager := &mockServiceManager{}
middlewareBuilder := &mockMiddlewareBuilder{}
parser, err := httpmuxer.NewSyntaxParser()
require.NoError(t, err)
manager := NewManager(conf, serviceManager, middlewareBuilder, nil, nil, parser)
// Run ParseRouterTree to validate configuration and populate ChildRefs/errors
manager.ParseRouterTree()
// Build the HTTP handler
ctx := t.Context()
handler, err := manager.buildHTTPHandler(ctx, test.router, "test-router")
if test.expectedError != "" {
assert.Error(t, err)
assert.Contains(t, err.Error(), test.expectedError)
return
}
require.NoError(t, err)
require.NotNil(t, handler)
// Test that the handler routes requests correctly
for _, req := range test.expectedRequests {
recorder := httptest.NewRecorder()
request := httptest.NewRequest(http.MethodGet, req.path, nil)
handler.ServeHTTP(recorder, request)
assert.Equal(t, req.statusCode, recorder.Code, "unexpected status code for path %s", req.path)
}
})
}
}
func TestManager_BuildHandlers_WithChildRouters(t *testing.T) {
testCases := []struct {
desc string
routers map[string]*dynamic.Router
services map[string]*dynamic.Service
entryPoints []string
expectedEntryPoint string
expectedRequests []struct {
path string
statusCode int
}
}{
{
desc: "parent router with child routers",
routers: map[string]*dynamic.Router{
"parent": {
EntryPoints: []string{"web"},
Rule: "PathPrefix(`/api`)",
},
"child1": {
Rule: "Path(`/api/v1`)",
Service: "child1-service",
ParentRefs: []string{"parent"},
},
"child2": {
Rule: "Path(`/api/v2`)",
Service: "child2-service",
ParentRefs: []string{"parent"},
},
},
services: map[string]*dynamic.Service{
"child1-service": {
LoadBalancer: &dynamic.ServersLoadBalancer{
Servers: []dynamic.Server{{URL: "http://localhost:8080"}},
},
},
"child2-service": {
LoadBalancer: &dynamic.ServersLoadBalancer{
Servers: []dynamic.Server{{URL: "http://localhost:8081"}},
},
},
},
entryPoints: []string{"web"},
expectedEntryPoint: "web",
expectedRequests: []struct {
path string
statusCode int
}{
{path: "/unknown", statusCode: http.StatusNotFound},
},
},
{
desc: "multiple parent routers with children",
routers: map[string]*dynamic.Router{
"api-parent": {
EntryPoints: []string{"web"},
Rule: "PathPrefix(`/api`)",
},
"web-parent": {
EntryPoints: []string{"web"},
Rule: "PathPrefix(`/web`)",
},
"api-child": {
Rule: "Path(`/api/v1`)",
Service: "api-service",
ParentRefs: []string{"api-parent"},
},
"web-child": {
Rule: "Path(`/web/index`)",
Service: "web-service",
ParentRefs: []string{"web-parent"},
},
},
services: map[string]*dynamic.Service{
"api-service": {
LoadBalancer: &dynamic.ServersLoadBalancer{
Servers: []dynamic.Server{{URL: "http://localhost:8080"}},
},
},
"web-service": {
LoadBalancer: &dynamic.ServersLoadBalancer{
Servers: []dynamic.Server{{URL: "http://localhost:8081"}},
},
},
},
entryPoints: []string{"web"},
expectedEntryPoint: "web",
expectedRequests: []struct {
path string
statusCode int
}{},
},
}
for _, test := range testCases {
t.Run(test.desc, func(t *testing.T) {
// Create runtime routers
runtimeRouters := make(map[string]*runtime.RouterInfo)
for name, router := range test.routers {
runtimeRouters[name] = &runtime.RouterInfo{
Router: router,
}
}
// Create runtime services
runtimeServices := make(map[string]*runtime.ServiceInfo)
for name, service := range test.services {
runtimeServices[name] = &runtime.ServiceInfo{
Service: service,
}
}
conf := &runtime.Configuration{
Routers: runtimeRouters,
Services: runtimeServices,
}
// Set up the manager with mocks
serviceManager := &mockServiceManager{}
middlewareBuilder := &mockMiddlewareBuilder{}
parser, err := httpmuxer.NewSyntaxParser()
require.NoError(t, err)
manager := NewManager(conf, serviceManager, middlewareBuilder, nil, nil, parser)
// Compute multi-layer routing to set up parent-child relationships
manager.ParseRouterTree()
// Build handlers
ctx := t.Context()
handlers := manager.BuildHandlers(ctx, test.entryPoints, false)
require.Contains(t, handlers, test.expectedEntryPoint)
handler := handlers[test.expectedEntryPoint]
require.NotNil(t, handler)
// Test that the handler routes requests correctly
for _, req := range test.expectedRequests {
recorder := httptest.NewRecorder()
request := httptest.NewRequest(http.MethodGet, req.path, nil)
request.Host = "test.com"
handler.ServeHTTP(recorder, request)
assert.Equal(t, req.statusCode, recorder.Code, "unexpected status code for path %s", req.path)
}
})
}
}
// Mock implementations for testing
type mockServiceManager struct{}
func (m *mockServiceManager) BuildHTTP(_ context.Context, _ string) (http.Handler, error) {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte("mock service response"))
}), nil
}
func (m *mockServiceManager) LaunchHealthCheck(_ context.Context) {}
type mockMiddlewareBuilder struct{}
func (m *mockMiddlewareBuilder) BuildChain(_ context.Context, _ []string) *alice.Chain {
chain := alice.New()
return &chain
}
type proxyBuilderMock struct{}
func (p proxyBuilderMock) Build(_ string, _ *url.URL, _, _ bool, _ time.Duration) (http.Handler, error) {

View file

@ -105,6 +105,8 @@ func (f *RouterFactory) CreateRouters(rtConf *runtime.Configuration) (map[string
routerManager := router.NewManager(rtConf, serviceManager, middlewaresBuilder, f.observabilityMgr, f.tlsManager, f.parser)
routerManager.ParseRouterTree()
handlersNonTLS := routerManager.BuildHandlers(ctx, f.entryPointsTCP, false)
handlersTLS := routerManager.BuildHandlers(ctx, f.entryPointsTCP, true)