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

@ -322,6 +322,10 @@ linters:
text: 'SA6002: argument should be pointer-like to avoid allocations'
- path: integration/integration_test.go
text: 'var (gatewayAPIConformanceRunTest|traefikVersion) is unused'
- path: pkg/server/router/router.go
text: 'appendAssign: append result not assigned to the same slice'
linters:
- gocritic
paths:
- pkg/provider/kubernetes/crd/generated/

View File

@ -48,6 +48,26 @@ spec:
items:
type: string
type: array
parentRefs:
description: |-
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
items:
description: IngressRouteRef is a reference to an IngressRoute resource.
properties:
name:
description: Name defines the name of the referenced IngressRoute
resource.
type: string
namespace:
description: Namespace defines the namespace of the referenced
IngressRoute resource.
type: string
required:
- name
type: object
type: array
routes:
description: Routes defines the list of routes.
items:

View File

@ -48,6 +48,26 @@ spec:
items:
type: string
type: array
parentRefs:
description: |-
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
items:
description: IngressRouteRef is a reference to an IngressRoute resource.
properties:
name:
description: Name defines the name of the referenced IngressRoute
resource.
type: string
namespace:
description: Namespace defines the namespace of the referenced
IngressRoute resource.
type: string
required:
- name
type: object
type: array
routes:
description: Routes defines the list of routes.
items:

View File

@ -0,0 +1,188 @@
---
title: "Multi-Layer Routing"
description: "Learn how to use Traefik's multi-layer routing to create hierarchical router relationships where parent routers can apply middleware before child routers make routing decisions."
---
# Multi-Layer Routing
Hierarchical Router Relationships for Advanced Routing Scenarios.
## Overview
Multi-layer routing enables you to create hierarchical relationships between routers,
where parent routers can process requests through middleware before child routers make final routing decisions.
This feature allows middleware at the parent level to modify requests (adding headers, performing authentication, etc.) that influence how child routers evaluate their rules and route traffic to services.
Multi-layer routing is particularly useful for progressive request enrichment, where each layer adds context to the request, enabling increasingly specific routing decisions:
- **Authentication-Based Routing**: Parent router authenticates requests and adds user context (roles, permissions) as headers, child routers route based on these headers
- **Staged Middleware Application**: Apply common middleware (rate limiting, CORS) at parent level (for a given domain/path), but specific middleware at child level
!!! info "Provider Support"
Multi-layer routing is supported by the following providers:
- **File provider** (YAML, TOML, JSON)
- **KV stores** (Consul, etcd, Redis, ZooKeeper)
- **Kubernetes CRD** (IngressRoute)
Multi-layer routing is not available for other providers (Docker, Kubernetes Ingress, Gateway API, etc.).
## How It Works
```
Request → EntryPoint → Parent Router → Middleware → Child Router A → Service A
↓ → Child Router B → Service B
Modify Request
(e.g., add headers)
```
1. **Request arrives** at an entrypoint
2. **Parent router matches** based on its rule (e.g., ```Host(`example.com`)```)
3. **Parent middleware executes**, potentially modifying the request
4. **One child router matches** based on its rule (which may use modified request attributes)
5. **Request is forwarded** to the matching child router's service
## Building a Router Hierarchy
### Root Routers
- Have no `parentRefs` (top of the hierarchy)
- **Can** have `tls`, `observability`, and `entryPoints` configuration
- Can be either parent routers (with children) or standalone routers (with service)
- **Can** have models applied (non-root routers cannot have models)
### Intermediate Routers
- Reference their parent router(s) via `parentRefs`
- Have one or more child routers
- **Must not** have a `service` defined
- **Must not** have `entryPoints`, `tls`, or `observability` configuration
### Leaf Routers
- Reference their parent router(s) via `parentRefs`
- **Must** have a `service` defined
- **Must not** have `entryPoints`, `tls`, or `observability` configuration
## Configuration Example
??? example "Authentication-Based Routing"
```yaml tab="File (YAML)"
## Dynamic configuration
http:
routers:
# Parent router with authentication
api-parent:
rule: "PathPrefix(`/api`)"
middlewares:
- auth-middleware
entryPoints:
- websecure
tls: {}
# Note: No service defined - this is a parent router
# Child router for admin users
api-admin:
rule: "HeadersRegexp(`X-User-Role`, `admin`)"
service: admin-service
parentRefs:
- api-parent
# Child router for regular users
api-user:
rule: "HeadersRegexp(`X-User-Role`, `user`)"
service: user-service
parentRefs:
- api-parent
middlewares:
auth-middleware:
forwardAuth:
address: "http://auth-service:8080/auth"
authResponseHeaders:
- X-User-Role
- X-User-Name
services:
admin-service:
loadBalancer:
servers:
- url: "http://admin-backend:8080"
user-service:
loadBalancer:
servers:
- url: "http://user-backend:8080"
```
```toml tab="File (TOML)"
## Dynamic configuration
[http.routers]
# Parent router with authentication
[http.routers.api-parent]
rule = "PathPrefix(`/api`)"
middlewares = ["auth-middleware"]
entryPoints = ["websecure"]
[http.routers.api-parent.tls]
# Note: No service defined - this is a parent router
# Child router for admin users
[http.routers.api-admin]
rule = "HeadersRegexp(`X-User-Role`, `admin`)"
service = "admin-service"
parentRefs = ["api-parent"]
# Child router for regular users
[http.routers.api-user]
rule = "HeadersRegexp(`X-User-Role`, `user`)"
service = "user-service"
parentRefs = ["api-parent"]
[http.middlewares]
[http.middlewares.auth-middleware.forwardAuth]
address = "http://auth-service:8080/auth"
authResponseHeaders = ["X-User-Role", "X-User-Name"]
[http.services]
[http.services.admin-service.loadBalancer]
[[http.services.admin-service.loadBalancer.servers]]
url = "http://admin-backend:8080"
[http.services.user-service.loadBalancer]
[[http.services.user-service.loadBalancer.servers]]
url = "http://user-backend:8080"
```
```txt tab="KV (Consul/etcd/Redis/ZK)"
| Key | Value |
|------------------------------------------------------------------------|---------------------------------|
| `traefik/http/routers/api-parent/rule` | `PathPrefix(\`/api\`)` |
| `traefik/http/routers/api-parent/middlewares/0` | `auth-middleware` |
| `traefik/http/routers/api-parent/entrypoints/0` | `websecure` |
| `traefik/http/routers/api-parent/tls` | `true` |
| `traefik/http/routers/api-admin/rule` | `HeadersRegexp(\`X-User-Role\`, \`admin\`)` |
| `traefik/http/routers/api-admin/service` | `admin-service` |
| `traefik/http/routers/api-admin/parentrefs/0` | `api-parent` |
| `traefik/http/routers/api-user/rule` | `HeadersRegexp(\`X-User-Role\`, \`user\`)` |
| `traefik/http/routers/api-user/service` | `user-service` |
| `traefik/http/routers/api-user/parentrefs/0` | `api-parent` |
| `traefik/http/middlewares/auth-middleware/forwardauth/address` | `http://auth-service:8080/auth` |
| `traefik/http/middlewares/auth-middleware/forwardauth/authresponseheaders/0` | `X-User-Role` |
| `traefik/http/middlewares/auth-middleware/forwardauth/authresponseheaders/1` | `X-User-Name` |
| `traefik/http/services/admin-service/loadbalancer/servers/0/url` | `http://admin-backend:8080` |
| `traefik/http/services/user-service/loadbalancer/servers/0/url` | `http://user-backend:8080` |
```
**How it works:**
1. Request to `/api/endpoint` matches `api-parent` router
2. `auth-middleware` (ForwardAuth) validates the request and adds `X-User-Role` header
3. Modified request is evaluated by child routers
4. If `X-User-Role: admin`, `api-admin` router matches and forwards to `admin-service`
5. If `X-User-Role: user`, `api-user` router matches and forwards to `user-service`
{!traefik-for-business-applications.md!}

View File

@ -32,6 +32,9 @@ http:
metrics: true
accessLogs: true
tracing: true
parentRefs:
- "parent-router-1"
- "parent-router-2"
service: my-service
```
@ -43,6 +46,7 @@ http:
priority = 10
middlewares = ["auth", "ratelimit"]
service = "my-service"
parentRefs = ["parent-router-1", "parent-router-2"]
[http.routers.my-router.tls]
certResolver = "letsencrypt"
@ -88,7 +92,7 @@ labels:
"traefik.http.routers.my-router.tls.domains[0].sans=www.example.com",
"traefik.http.routers.my-router.observability.metrics=true",
"traefik.http.routers.my-router.observability.accessLogs=true",
"traefik.http.routers.my-router.observability.tracing=true"
"traefik.http.routers.my-router.observability.tracing=true",
]
}
```
@ -96,7 +100,7 @@ labels:
## Configuration Options
| Field | Description | Default | Required |
|----------------------------------------------------------------------------------------------------|----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|-----------------------------|----------|
|----------------------------------------------------------------------------------------------------------------|----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|-----------------------------|----------|
| <a id="opt-entryPoints" href="#opt-entryPoints" title="#opt-entryPoints">`entryPoints`</a> | The list of entry points to which the router is attached. If not specified, HTTP routers are attached to all entry points. | All entry points | No |
| <a id="opt-rule" href="#opt-rule" title="#opt-rule">`rule`</a> | Rules are a set of matchers configured with values, that determine if a particular request matches specific criteria. If the rule is verified, the router becomes active, calls middlewares, and then forwards the request to the service. See [Rules & Priority](./rules-and-priority.md) for details. | | Yes |
| <a id="opt-priority" href="#opt-priority" title="#opt-priority">`priority`</a> | To avoid path overlap, routes are sorted, by default, in descending order using rules length. The priority is directly equal to the length of the rule, and so the longest length has the highest priority. A value of `0` for the priority is ignored. See [Rules & Priority](./rules-and-priority.md) for details. | Rule length | No |
@ -106,6 +110,7 @@ labels:
| <a id="opt-tls-options" href="#opt-tls-options" title="#opt-tls-options">`tls.options`</a> | The name of the TLS options to use for configuring TLS parameters (cipher suites, min/max TLS version, client authentication, etc.). See [TLS Options](../tls/tls-options.md) for detailed configuration. | `default` | No |
| <a id="opt-tls-domains" href="#opt-tls-domains" title="#opt-tls-domains">`tls.domains`</a> | List of domains and Subject Alternative Names (SANs) for explicit certificate domain specification. When using ACME certificate resolvers, domains are automatically extracted from router rules, making this option optional. | | No |
| <a id="opt-observability" href="#opt-observability" title="#opt-observability">`observability`</a> | Observability configuration for the router. Allows fine-grained control over access logs, metrics, and tracing per router. See [Observability](./observability.md) for details. | Inherited from entry points | No |
| <a id="opt-parentRefs" href="#opt-parentRefs" title="#opt-parentRefs">`parentRefs`</a> | References to parent router names for multi-layer routing. When specified, this router becomes a child router that processes requests after parent routers have applied their middlewares. See [Multi-Layer Routing](../../../../routing/multi-layer-routing.md) for details. | | No |
| <a id="opt-service" href="#opt-service" title="#opt-service">`service`</a> | The name of the service that will handle the matched requests. Services can be load balancer services, weighted round robin, mirroring, or failover services. See [Service](../load-balancing/service.md) for details. | | Yes |
## Router Naming

View File

@ -23,6 +23,9 @@ metadata:
spec:
entryPoints:
- web
parentRefs:
- name: parent-gateway
namespace: default # Optional - defaults to same namespace
routes:
- kind: Rule
# Rule on the Host
@ -75,8 +78,11 @@ spec:
## Configuration Options
| Field | Description | Default | Required |
|:-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|:---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|:--------|:---------|
|:-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|:---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|:--------|:---------|
| <a id="opt-entryPoints" href="#opt-entryPoints" title="#opt-entryPoints">`entryPoints`</a> | List of [entry points](../../../../install-configuration/entrypoints.md) names.<br />If not specified, HTTP routers will accept requests from all EntryPoints in the list of default EntryPoints. | | No |
| <a id="opt-parentRefs" href="#opt-parentRefs" title="#opt-parentRefs">`parentRefs`</a> | List of references to parent IngressRoute resources for multi-layer routing. When specified, this IngressRoute's routers become children of the referenced parent IngressRoute's routers. See [Multi-Layer Routing](#multi-layer-routing-with-ingressroutes) section for details. | | No |
| <a id="opt-parentRefsn-name" href="#opt-parentRefsn-name" title="#opt-parentRefsn-name">`parentRefs[n].name`</a> | Name of the referenced parent IngressRoute resource. | | Yes |
| <a id="opt-parentRefsn-namespace" href="#opt-parentRefsn-namespace" title="#opt-parentRefsn-namespace">`parentRefs[n].namespace`</a> | Namespace of the referenced parent IngressRoute resource.<br />If not specified, defaults to the same namespace as the child IngressRoute.<br />Cross-namespace references require `allowCrossNamespace` provider option to be enabled. | | No |
| <a id="opt-routes" href="#opt-routes" title="#opt-routes">`routes`</a> | List of routes. | | Yes |
| <a id="opt-routesn-kind" href="#opt-routesn-kind" title="#opt-routesn-kind">`routes[n].kind`</a> | Kind of router matching, only `Rule` is allowed yet. | "Rule" | No |
| <a id="opt-routesn-match" href="#opt-routesn-match" title="#opt-routesn-match">`routes[n].match`</a> | Defines the [rule](../../../http/routing/rules-and-priority.md#rules) corresponding to an underlying router. | | Yes |
@ -216,3 +222,159 @@ TLS options references, a conflict occurs, such as in the example below.
If that happens, both mappings are discarded, and the host name
(`example.net` in the example) for these routers gets associated with
the default TLS options instead.
### Multi-Layer Routing with IngressRoutes
Multi-layer routing allows creating hierarchical relationships between IngressRoutes,
where parent IngressRoutes can apply middleware before child IngressRoutes make routing decisions.
This is particularly useful for authentication-based routing,
where a parent IngressRoute authenticates requests and adds context (e.g., user roles as headers),
and child IngressRoutes route based on that context.
When a child IngressRoute references a parent IngressRoute with multiple routes,
**all** parent routers then become parents of **all** child routers.
!!! info "Comprehensive Multi-Layer Routing Documentation"
For detailed information about multi-layer routing concepts, validation rules, and use cases, see the dedicated [Multi-Layer Routing](../../../../routing-configuration/http/routing/multi-layer-routing.md) page.
#### Configuration Requirements
### Root IngressRoutes
- Have no `parentRefs` (top of the hierarchy)
- **Can** have `entryPoints`, `tls`, and `observability` configuration
- Can be either parent IngressRoutes (with children) or standalone IngressRoutes (with service)
### Intermediate IngressRoutes
- Reference their parent IngressRoute(s) via `parentRefs`
- Have one or more child IngressRoutes
- **Must not** have a `service` defined
- **Must not** have `entryPoints`, `tls`, or `observability` configuration
### Leaf IngressRoutes
- Reference their parent IngressRoute(s) via `parentRefs`
- **Must** have a `service` defined
- **Must not** have `entryPoints`, `tls`, or `observability` configuration
!!! warning "Cross-Namespace References"
Cross-namespace parent references require the `allowCrossNamespace` provider option to be enabled.
If disabled, child IngressRoute creation will be skipped with an error logged.
#### Example: Authentication-Based Routing
??? example "Parent IngressRoute with ForwardAuth and Child IngressRoutes"
```yaml tab="Parent IngressRoute"
apiVersion: traefik.io/v1alpha1
kind: IngressRoute
metadata:
name: api-parent
namespace: default
spec:
entryPoints:
- websecure
tls:
certResolver: letsencrypt
routes:
# Parent route with authentication - no services
- match: Host(`api.example.com`) && PathPrefix(`/api`)
kind: Rule
middlewares:
- name: auth-middleware
namespace: default
---
apiVersion: traefik.io/v1alpha1
kind: Middleware
metadata:
name: auth-middleware
namespace: default
spec:
forwardAuth:
address: "http://auth-service.default.svc.cluster.local:8080/auth"
authResponseHeaders:
- X-User-Role
- X-User-Name
```
```yaml tab="Child IngressRoutes"
# Child IngressRoute for admin users
apiVersion: traefik.io/v1alpha1
kind: IngressRoute
metadata:
name: api-admin
namespace: default
spec:
parentRefs:
- name: api-parent
namespace: default # Optional - defaults to same namespace
routes:
- match: HeadersRegexp(`X-User-Role`, `admin`)
kind: Rule
services:
- name: admin-service
port: 80
---
# Child IngressRoute for regular users
apiVersion: traefik.io/v1alpha1
kind: IngressRoute
metadata:
name: api-user
namespace: default
spec:
parentRefs:
- name: api-parent
routes:
- match: HeadersRegexp(`X-User-Role`, `user`)
kind: Rule
services:
- name: user-service
port: 80
```
```yaml tab="Services"
apiVersion: v1
kind: Service
metadata:
name: auth-service
namespace: default
spec:
ports:
- port: 8080
selector:
app: auth-service
---
apiVersion: v1
kind: Service
metadata:
name: admin-service
namespace: default
spec:
ports:
- port: 80
selector:
app: admin-backend
---
apiVersion: v1
kind: Service
metadata:
name: user-service
namespace: default
spec:
ports:
- port: 80
selector:
app: user-backend
```
**How it works:**
1. Request to `https://api.example.com/api/endpoint` matches the parent router
2. `auth-middleware` (ForwardAuth) validates the request with `auth-service`
3. `auth-service` returns 200 OK with `X-User-Role` header (e.g., `admin` or `user`)
4. Child routers evaluate rules against the modified request (with `X-User-Role` header)
5. Request is routed to `admin-service` or `user-service` based on the role

View File

@ -7,6 +7,7 @@
middlewares = ["foobar", "foobar"]
service = "foobar"
rule = "foobar"
parentRefs = ["foobar", "foobar"]
ruleSyntax = "foobar"
priority = 42
[http.routers.Router0.tls]
@ -30,6 +31,7 @@
middlewares = ["foobar", "foobar"]
service = "foobar"
rule = "foobar"
parentRefs = ["foobar", "foobar"]
ruleSyntax = "foobar"
priority = 42
[http.routers.Router1.tls]

View File

@ -11,6 +11,9 @@ http:
- foobar
service: foobar
rule: foobar
parentRefs:
- foobar
- foobar
ruleSyntax: foobar
priority: 42
tls:
@ -39,6 +42,9 @@ http:
- foobar
service: foobar
rule: foobar
parentRefs:
- foobar
- foobar
ruleSyntax: foobar
priority: 42
tls:

View File

@ -267,6 +267,7 @@ nav:
- 'Router' : 'reference/routing-configuration/http/routing/router.md'
- 'Rules & Priority' : 'reference/routing-configuration/http/routing/rules-and-priority.md'
- 'Observability': 'reference/routing-configuration/http/routing/observability.md'
- 'Multi-Layer Routing': 'reference/routing-configuration/http/routing/multi-layer-routing.md'
- 'Load Balancing' :
- 'Service' : 'reference/routing-configuration/http/load-balancing/service.md'
- 'ServersTransport' : 'reference/routing-configuration/http/load-balancing/serverstransport.md'
@ -363,6 +364,8 @@ nav:
- 'Features': 'deprecation/features.md'
- 'User Guides':
- 'FastProxy': 'user-guides/fastproxy.md'
- 'Kubernetes and Let''s Encrypt': 'user-guides/crd-acme/index.md'
- 'Kubernetes and cert-manager': 'user-guides/cert-manager.md'
- 'gRPC Examples': 'user-guides/grpc.md'
- 'WebSocket Examples': 'user-guides/websocket.md'
- 'Contributing':

View File

@ -48,6 +48,26 @@ spec:
items:
type: string
type: array
parentRefs:
description: |-
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
items:
description: IngressRouteRef is a reference to an IngressRoute resource.
properties:
name:
description: Name defines the name of the referenced IngressRoute
resource.
type: string
namespace:
description: Namespace defines the namespace of the referenced
IngressRoute resource.
type: string
required:
- name
type: object
type: array
routes:
description: Routes defines the list of routes.
items:

View File

@ -0,0 +1,51 @@
[global]
checkNewVersion = false
sendAnonymousUsage = false
[log]
level = "DEBUG"
noColor = true
[entryPoints]
[entryPoints.web]
address = ":8000"
[api]
insecure = true
[providers.file]
filename = "{{ .SelfFilename }}"
## Dynamic Configuration ##
[http.middlewares]
[http.middlewares.auth-middleware.forwardAuth]
address = "http://127.0.0.1:{{ .AuthPort }}/auth"
authResponseHeaders = ["X-User-Role", "X-User-Name"]
[http.services]
[http.services.admin-service.loadBalancer]
[[http.services.admin-service.loadBalancer.servers]]
url = "http://{{ .AdminIP }}:80"
[http.services.developer-service.loadBalancer]
[[http.services.developer-service.loadBalancer.servers]]
url = "http://{{ .DeveloperIP }}:80"
[http.routers]
# Parent router: matches path, applies auth middleware
[http.routers.parent-router]
rule = "PathPrefix(`/whoami`)"
middlewares = ["auth-middleware"]
# Child router for admin role
[http.routers.admin-router]
rule = "Header(`X-User-Role`, `admin`)"
service = "admin-service"
parentRefs = ["parent-router@file"]
# Child router for developer role
[http.routers.developer-router]
rule = "Header(`X-User-Role`, `developer`)"
service = "developer-service"
parentRefs = ["parent-router@file"]

View File

@ -0,0 +1,8 @@
services:
whoami-admin:
image: traefik/whoami
hostname: whoami-admin
whoami-developer:
image: traefik/whoami
hostname: whoami-developer

154
integration/routing_test.go Normal file
View File

@ -0,0 +1,154 @@
package integration
import (
"net"
"net/http"
"strings"
"testing"
"time"
"github.com/stretchr/testify/require"
"github.com/stretchr/testify/suite"
"github.com/traefik/traefik/v3/integration/try"
)
// RoutingSuite tests multi-layer routing with authentication middleware.
type RoutingSuite struct{ BaseSuite }
func TestRoutingSuite(t *testing.T) {
suite.Run(t, new(RoutingSuite))
}
func (s *RoutingSuite) SetupSuite() {
s.BaseSuite.SetupSuite()
s.createComposeProject("routing")
s.composeUp()
}
func (s *RoutingSuite) TearDownSuite() {
s.BaseSuite.TearDownSuite()
}
// authHandler implements the ForwardAuth protocol.
// It validates Bearer tokens and adds X-User-Role and X-User-Name headers.
func authHandler(w http.ResponseWriter, r *http.Request) {
authHeader := r.Header.Get("Authorization")
if authHeader == "" {
w.WriteHeader(http.StatusUnauthorized)
return
}
if !strings.HasPrefix(authHeader, "Bearer ") {
w.WriteHeader(http.StatusUnauthorized)
return
}
token := strings.TrimPrefix(authHeader, "Bearer ")
role, username, ok := getUserByToken(token)
if !ok {
w.WriteHeader(http.StatusUnauthorized)
return
}
// Set headers that will be forwarded by Traefik
w.Header().Set("X-User-Role", role)
w.Header().Set("X-User-Name", username)
w.WriteHeader(http.StatusOK)
}
// getUserByToken returns the role and username for a given token.
func getUserByToken(token string) (role, username string, ok bool) {
users := map[string]struct {
role string
username string
}{
"bob-token": {role: "admin", username: "bob"},
"jack-token": {role: "developer", username: "jack"},
"alice-token": {role: "guest", username: "alice"},
}
u, exists := users[token]
return u.role, u.username, exists
}
// TestMultiLayerRoutingWithAuth tests the complete multi layer routing scenario:
// - Parent router matches path and applies authentication middleware
// - Auth middleware validates token and adds role header
// - Child routers route based on the role header added by the middleware
func (s *RoutingSuite) TestMultiLayerRoutingWithAuth() {
listener, err := net.Listen("tcp", "127.0.0.1:0")
require.NoError(s.T(), err)
defer listener.Close()
_, authPort, err := net.SplitHostPort(listener.Addr().String())
require.NoError(s.T(), err)
go func() {
_ = http.Serve(listener, http.HandlerFunc(authHandler))
}()
adminIP := s.getComposeServiceIP("whoami-admin")
require.NotEmpty(s.T(), adminIP)
developerIP := s.getComposeServiceIP("whoami-developer")
require.NotEmpty(s.T(), developerIP)
file := s.adaptFile("fixtures/routing/multi_layer_auth.toml", struct {
AuthPort string
AdminIP string
DeveloperIP string
}{
AuthPort: authPort,
AdminIP: adminIP,
DeveloperIP: developerIP,
})
s.traefikCmd(withConfigFile(file))
err = try.GetRequest("http://127.0.0.1:8080/api/rawdata", 2*time.Second, try.BodyContains("parent-router"))
require.NoError(s.T(), err)
// Test 1: bob (admin role) routes to admin-service
req, err := http.NewRequest(http.MethodGet, "http://127.0.0.1:8000/whoami", nil)
require.NoError(s.T(), err)
req.Header.Set("Authorization", "Bearer bob-token")
err = try.Request(req, 2*time.Second,
try.StatusCodeIs(http.StatusOK),
try.BodyContains("whoami-admin"))
require.NoError(s.T(), err)
// Test 2: jack (developer role) routes to developer-service
req, err = http.NewRequest(http.MethodGet, "http://127.0.0.1:8000/whoami", nil)
require.NoError(s.T(), err)
req.Header.Set("Authorization", "Bearer jack-token")
err = try.Request(req, 2*time.Second,
try.StatusCodeIs(http.StatusOK),
try.BodyContains("whoami-developer"))
require.NoError(s.T(), err)
// Test 3: Invalid token returns 401
req, err = http.NewRequest(http.MethodGet, "http://127.0.0.1:8000/whoami", nil)
require.NoError(s.T(), err)
req.Header.Set("Authorization", "Bearer invalid-token")
err = try.Request(req, 2*time.Second, try.StatusCodeIs(http.StatusUnauthorized))
require.NoError(s.T(), err)
// Test 4: Missing token returns 401
req, err = http.NewRequest(http.MethodGet, "http://127.0.0.1:8000/whoami", nil)
require.NoError(s.T(), err)
err = try.Request(req, 2*time.Second, try.StatusCodeIs(http.StatusUnauthorized))
require.NoError(s.T(), err)
// Test 5: Valid auth but role has no matching child router returns 404
req, err = http.NewRequest(http.MethodGet, "http://127.0.0.1:8000/whoami", nil)
require.NoError(s.T(), err)
req.Header.Set("Authorization", "Bearer alice-token")
err = try.Request(req, 2*time.Second, try.StatusCodeIs(http.StatusNotFound))
require.NoError(s.T(), err)
}

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)
}
return chain.Extend(*mHandler).Then(sHandler)
// 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
}
// 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)