From a4a91344edcdd6276c1b766ca19ee3f0e346480f Mon Sep 17 00:00:00 2001 From: Harold Ozouf Date: Thu, 29 Jan 2026 17:38:06 +0100 Subject: [PATCH] Add routing configuration extension points --- .golangci.yml | 1 + go.mod | 6 +- pkg/config/dynamic/ext/ext.go | 7 + pkg/config/dynamic/ext/go.mod | 3 + pkg/config/dynamic/http_config.go | 60 ++- pkg/config/dynamic/tcp_config.go | 35 +- pkg/config/dynamic/udp_config.go | 35 +- pkg/config/dynamic/zz_generated.deepcopy.go | 2 + pkg/provider/configuration.go | 381 ------------- pkg/provider/consulcatalog/config.go | 2 +- pkg/provider/docker/config.go | 2 +- pkg/provider/ecs/config.go | 2 +- pkg/provider/file/file.go | 180 +------ pkg/provider/merge.go | 330 ++++++++++++ pkg/provider/merge_test.go | 557 ++++++++++++++++++++ pkg/provider/nomad/config.go | 2 +- 16 files changed, 1049 insertions(+), 556 deletions(-) create mode 100644 pkg/config/dynamic/ext/ext.go create mode 100644 pkg/config/dynamic/ext/go.mod create mode 100644 pkg/provider/merge.go create mode 100644 pkg/provider/merge_test.go diff --git a/.golangci.yml b/.golangci.yml index d49dcdcae..fa489d956 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -82,6 +82,7 @@ linters: toolchain-pattern: go1\.\d+\.\d+$ tool-forbidden: true go-version-pattern: ^1\.\d+(\.0)?$ + replace-local: true replace-allow-list: - github.com/abbot/go-http-auth - github.com/gorilla/mux diff --git a/go.mod b/go.mod index 79a1e9a94..5c0935e89 100644 --- a/go.mod +++ b/go.mod @@ -37,6 +37,7 @@ require ( github.com/hashicorp/go-version v1.8.0 github.com/hashicorp/nomad/api v0.0.0-20231213195942-64e3dca9274b // No tag on the repo. github.com/http-wasm/http-wasm-host-go v0.7.0 + github.com/huandu/xstrings v1.5.0 github.com/influxdata/influxdb-client-go/v2 v2.7.0 github.com/influxdata/influxdb1-client v0.0.0-20200827194710-b269163b24ab // No tag on the repo. github.com/klauspost/compress v1.18.0 @@ -71,6 +72,7 @@ require ( github.com/tidwall/gjson v1.17.0 github.com/traefik/grpc-web v0.16.0 github.com/traefik/paerser v0.2.2 + github.com/traefik/traefik/dynamic/ext v0.0.0-00010101000000-000000000000 github.com/traefik/yaegi v0.16.1 github.com/unrolled/render v1.0.2 github.com/unrolled/secure v1.0.9 @@ -253,7 +255,6 @@ require ( github.com/hashicorp/golang-lru v1.0.2 // indirect github.com/hashicorp/hcl v1.0.1-vault-5 // indirect github.com/hashicorp/serf v0.10.1 // indirect - github.com/huandu/xstrings v1.5.0 // indirect github.com/huaweicloud/huaweicloud-sdk-go-v3 v0.1.182 // indirect github.com/iij/doapi v0.0.0-20190504054126-0bbf12d6d7df // indirect github.com/imdario/mergo v0.3.16 // indirect @@ -411,6 +412,9 @@ require ( sigs.k8s.io/randfill v1.0.0 // indirect ) +// Dynamic config extension. +replace github.com/traefik/traefik/dynamic/ext => ./pkg/config/dynamic/ext + // Containous forks replace ( github.com/abbot/go-http-auth => github.com/containous/go-http-auth v0.4.1-0.20200324110947-a37a7636d23e diff --git a/pkg/config/dynamic/ext/ext.go b/pkg/config/dynamic/ext/ext.go new file mode 100644 index 000000000..6fc62b373 --- /dev/null +++ b/pkg/config/dynamic/ext/ext.go @@ -0,0 +1,7 @@ +package ext + +// HTTP is a dynamic.HTTP extension. +type HTTP struct{} + +// Router is a dynamic.Router extension. +type Router struct{} diff --git a/pkg/config/dynamic/ext/go.mod b/pkg/config/dynamic/ext/go.mod new file mode 100644 index 000000000..8dc321263 --- /dev/null +++ b/pkg/config/dynamic/ext/go.mod @@ -0,0 +1,3 @@ +module github.com/traefik/traefik/dynamic/ext + +go 1.24.0 diff --git a/pkg/config/dynamic/http_config.go b/pkg/config/dynamic/http_config.go index b1ccd08b0..a58143daa 100644 --- a/pkg/config/dynamic/http_config.go +++ b/pkg/config/dynamic/http_config.go @@ -5,6 +5,7 @@ import ( "time" ptypes "github.com/traefik/paerser/types" + "github.com/traefik/traefik/dynamic/ext" otypes "github.com/traefik/traefik/v3/pkg/observability/types" traefiktls "github.com/traefik/traefik/v3/pkg/tls" "github.com/traefik/traefik/v3/pkg/types" @@ -33,6 +34,8 @@ const ( // HTTPConfiguration contains all the HTTP configuration parameters. type HTTPConfiguration struct { + ext.HTTP `yaml:",inline"` + Routers map[string]*Router `json:"routers,omitempty" toml:"routers,omitempty" yaml:"routers,omitempty" export:"true"` Services map[string]*Service `json:"services,omitempty" toml:"services,omitempty" yaml:"services,omitempty" export:"true"` Middlewares map[string]*Middleware `json:"middlewares,omitempty" toml:"middlewares,omitempty" yaml:"middlewares,omitempty" export:"true"` @@ -63,10 +66,22 @@ type Service struct { Failover *Failover `json:"failover,omitempty" toml:"failover,omitempty" yaml:"failover,omitempty" label:"-" export:"true"` } +// Merge merges another Service into this one. +// Returns true if the merge succeeds, false if configurations conflict. +func (s *Service) Merge(other *Service) bool { + if s.LoadBalancer == nil || other.LoadBalancer == nil { + return reflect.DeepEqual(s, other) + } + + return s.LoadBalancer.Merge(other.LoadBalancer) +} + // +k8s:deepcopy-gen=true // Router holds the router configuration. type Router struct { + ext.Router `yaml:",inline"` + EntryPoints []string `json:"entryPoints,omitempty" toml:"entryPoints,omitempty" yaml:"entryPoints,omitempty" export:"true"` 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"` @@ -343,8 +358,39 @@ type ServersLoadBalancer struct { ServersTransport string `json:"serversTransport,omitempty" toml:"serversTransport,omitempty" yaml:"serversTransport,omitempty" export:"true"` } -// Mergeable tells if the given service is mergeable. -func (l *ServersLoadBalancer) Mergeable(loadBalancer *ServersLoadBalancer) bool { +// Merge merges the other load balancer into this one. +// Returns true if merge succeeded, false if configurations conflict. +func (l *ServersLoadBalancer) Merge(other *ServersLoadBalancer) bool { + if !l.mergeable(other) { + return false + } + + // Deduplicate and append servers. + uniq := make(map[string]struct{}, len(l.Servers)) + for _, server := range l.Servers { + uniq[server.URL] = struct{}{} + } + for _, server := range other.Servers { + if _, ok := uniq[server.URL]; !ok { + l.Servers = append(l.Servers, server) + } + } + + return true +} + +// SetDefaults Default values for a ServersLoadBalancer. +func (l *ServersLoadBalancer) SetDefaults() { + defaultPassHostHeader := DefaultPassHostHeader + l.PassHostHeader = &defaultPassHostHeader + + l.Strategy = BalancerStrategyWRR + l.ResponseForwarding = &ResponseForwarding{} + l.ResponseForwarding.SetDefaults() +} + +// mergeable tells if the given service is mergeable. +func (l *ServersLoadBalancer) mergeable(loadBalancer *ServersLoadBalancer) bool { savedServers := l.Servers defer func() { l.Servers = savedServers @@ -360,16 +406,6 @@ func (l *ServersLoadBalancer) Mergeable(loadBalancer *ServersLoadBalancer) bool return reflect.DeepEqual(l, loadBalancer) } -// SetDefaults Default values for a ServersLoadBalancer. -func (l *ServersLoadBalancer) SetDefaults() { - defaultPassHostHeader := DefaultPassHostHeader - l.PassHostHeader = &defaultPassHostHeader - - l.Strategy = BalancerStrategyWRR - l.ResponseForwarding = &ResponseForwarding{} - l.ResponseForwarding.SetDefaults() -} - // +k8s:deepcopy-gen=true // ResponseForwarding holds the response forwarding configuration. diff --git a/pkg/config/dynamic/tcp_config.go b/pkg/config/dynamic/tcp_config.go index 51a96373a..2cb3f0aa7 100644 --- a/pkg/config/dynamic/tcp_config.go +++ b/pkg/config/dynamic/tcp_config.go @@ -35,6 +35,16 @@ type TCPService struct { Weighted *TCPWeightedRoundRobin `json:"weighted,omitempty" toml:"weighted,omitempty" yaml:"weighted,omitempty" label:"-" export:"true"` } +// Merge merges another TCPService into this one. +// Returns true if the merge succeeds, false if configurations conflict. +func (s *TCPService) Merge(other *TCPService) bool { + if s.LoadBalancer == nil || other.LoadBalancer == nil { + return reflect.DeepEqual(s, other) + } + + return s.LoadBalancer.Merge(other.LoadBalancer) +} + // +k8s:deepcopy-gen=true // TCPWeightedRoundRobin is a weighted round robin tcp load-balancer of services. @@ -102,8 +112,29 @@ type TCPServersLoadBalancer struct { HealthCheck *TCPServerHealthCheck `json:"healthCheck,omitempty" toml:"healthCheck,omitempty" yaml:"healthCheck,omitempty" label:"allowEmpty" file:"allowEmpty" kv:"allowEmpty" export:"true"` } -// Mergeable tells if the given service is mergeable. -func (l *TCPServersLoadBalancer) Mergeable(loadBalancer *TCPServersLoadBalancer) bool { +// Merge merges the other load balancer into this one. +// Returns true if the merge succeeds, false if configurations conflict. +func (l *TCPServersLoadBalancer) Merge(other *TCPServersLoadBalancer) bool { + if !l.mergeable(other) { + return false + } + + // Deduplicate and append servers. + uniq := make(map[string]struct{}, len(l.Servers)) + for _, server := range l.Servers { + uniq[server.Address] = struct{}{} + } + for _, server := range other.Servers { + if _, ok := uniq[server.Address]; !ok { + l.Servers = append(l.Servers, server) + } + } + + return true +} + +// mergeable tells if the given service is mergeable. +func (l *TCPServersLoadBalancer) mergeable(loadBalancer *TCPServersLoadBalancer) bool { savedServers := l.Servers defer func() { l.Servers = savedServers diff --git a/pkg/config/dynamic/udp_config.go b/pkg/config/dynamic/udp_config.go index 9e601a2df..96d22163a 100644 --- a/pkg/config/dynamic/udp_config.go +++ b/pkg/config/dynamic/udp_config.go @@ -20,6 +20,16 @@ type UDPService struct { Weighted *UDPWeightedRoundRobin `json:"weighted,omitempty" toml:"weighted,omitempty" yaml:"weighted,omitempty" label:"-" export:"true"` } +// Merge merges another UDPService into this one. +// Returns true if the merge succeeds, false if configurations conflict. +func (s *UDPService) Merge(other *UDPService) bool { + if s.LoadBalancer == nil || other.LoadBalancer == nil { + return reflect.DeepEqual(s, other) + } + + return s.LoadBalancer.Merge(other.LoadBalancer) +} + // +k8s:deepcopy-gen=true // UDPWeightedRoundRobin is a weighted round robin UDP load-balancer of services. @@ -56,8 +66,29 @@ type UDPServersLoadBalancer struct { Servers []UDPServer `json:"servers,omitempty" toml:"servers,omitempty" yaml:"servers,omitempty" label-slice-as-struct:"server" export:"true"` } -// Mergeable reports whether the given load-balancer can be merged with the receiver. -func (l *UDPServersLoadBalancer) Mergeable(loadBalancer *UDPServersLoadBalancer) bool { +// Merge merges the other load balancer into this one. +// Returns true if merge succeeded, false if configurations conflict. +func (l *UDPServersLoadBalancer) Merge(other *UDPServersLoadBalancer) bool { + if !l.mergeable(other) { + return false + } + + // Deduplicate and append servers. + uniq := make(map[string]struct{}, len(l.Servers)) + for _, server := range l.Servers { + uniq[server.Address] = struct{}{} + } + for _, server := range other.Servers { + if _, ok := uniq[server.Address]; !ok { + l.Servers = append(l.Servers, server) + } + } + + return true +} + +// mergeable reports whether the given load-balancer can be merged with the receiver. +func (l *UDPServersLoadBalancer) mergeable(loadBalancer *UDPServersLoadBalancer) bool { savedServers := l.Servers defer func() { l.Servers = savedServers diff --git a/pkg/config/dynamic/zz_generated.deepcopy.go b/pkg/config/dynamic/zz_generated.deepcopy.go index 41ffec75d..965175453 100644 --- a/pkg/config/dynamic/zz_generated.deepcopy.go +++ b/pkg/config/dynamic/zz_generated.deepcopy.go @@ -489,6 +489,7 @@ func (in *HRWService) DeepCopy() *HRWService { // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *HTTPConfiguration) DeepCopyInto(out *HTTPConfiguration) { *out = *in + out.HTTP = in.HTTP if in.Routers != nil { in, out := &in.Routers, &out.Routers *out = make(map[string]*Router, len(*in)) @@ -1390,6 +1391,7 @@ func (in *Retry) DeepCopy() *Retry { // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *Router) DeepCopyInto(out *Router) { *out = *in + out.Router = in.Router if in.EntryPoints != nil { in, out := &in.EntryPoints, &out.EntryPoints *out = make([]string, len(*in)) diff --git a/pkg/provider/configuration.go b/pkg/provider/configuration.go index d310881f8..f69ad1f1d 100644 --- a/pkg/provider/configuration.go +++ b/pkg/provider/configuration.go @@ -4,7 +4,6 @@ import ( "bytes" "context" "maps" - "reflect" "slices" "strings" "text/template" @@ -14,388 +13,8 @@ import ( "github.com/rs/zerolog/log" "github.com/traefik/traefik/v3/pkg/config/dynamic" "github.com/traefik/traefik/v3/pkg/observability/logs" - "github.com/traefik/traefik/v3/pkg/tls" ) -// Merge merges multiple configurations. -func Merge(ctx context.Context, configurations map[string]*dynamic.Configuration) *dynamic.Configuration { - logger := log.Ctx(ctx) - - configuration := &dynamic.Configuration{ - HTTP: &dynamic.HTTPConfiguration{ - Routers: make(map[string]*dynamic.Router), - Middlewares: make(map[string]*dynamic.Middleware), - Services: make(map[string]*dynamic.Service), - ServersTransports: make(map[string]*dynamic.ServersTransport), - }, - TCP: &dynamic.TCPConfiguration{ - Routers: make(map[string]*dynamic.TCPRouter), - Services: make(map[string]*dynamic.TCPService), - Middlewares: make(map[string]*dynamic.TCPMiddleware), - ServersTransports: make(map[string]*dynamic.TCPServersTransport), - }, - UDP: &dynamic.UDPConfiguration{ - Routers: make(map[string]*dynamic.UDPRouter), - Services: make(map[string]*dynamic.UDPService), - }, - TLS: &dynamic.TLSConfiguration{ - Stores: make(map[string]tls.Store), - }, - } - - servicesToDelete := map[string]struct{}{} - services := map[string][]string{} - - routersToDelete := map[string]struct{}{} - routers := map[string][]string{} - - servicesTCPToDelete := map[string]struct{}{} - servicesTCP := map[string][]string{} - - routersTCPToDelete := map[string]struct{}{} - routersTCP := map[string][]string{} - - servicesUDPToDelete := map[string]struct{}{} - servicesUDP := map[string][]string{} - - routersUDPToDelete := map[string]struct{}{} - routersUDP := map[string][]string{} - - middlewaresToDelete := map[string]struct{}{} - middlewares := map[string][]string{} - - middlewaresTCPToDelete := map[string]struct{}{} - middlewaresTCP := map[string][]string{} - - transportsToDelete := map[string]struct{}{} - transports := map[string][]string{} - - transportsTCPToDelete := map[string]struct{}{} - transportsTCP := map[string][]string{} - - storesToDelete := map[string]struct{}{} - stores := map[string][]string{} - - var sortedKeys []string - for key := range configurations { - sortedKeys = append(sortedKeys, key) - } - slices.Sort(sortedKeys) - - for _, root := range sortedKeys { - conf := configurations[root] - for serviceName, service := range conf.HTTP.Services { - services[serviceName] = append(services[serviceName], root) - if !AddService(configuration.HTTP, serviceName, service) { - servicesToDelete[serviceName] = struct{}{} - } - } - - for routerName, router := range conf.HTTP.Routers { - routers[routerName] = append(routers[routerName], root) - if !AddRouter(configuration.HTTP, routerName, router) { - routersToDelete[routerName] = struct{}{} - } - } - - for transportName, transport := range conf.HTTP.ServersTransports { - transports[transportName] = append(transports[transportName], root) - if !AddTransport(configuration.HTTP, transportName, transport) { - transportsToDelete[transportName] = struct{}{} - } - } - - for serviceName, service := range conf.TCP.Services { - servicesTCP[serviceName] = append(servicesTCP[serviceName], root) - if !AddServiceTCP(configuration.TCP, serviceName, service) { - servicesTCPToDelete[serviceName] = struct{}{} - } - } - - for routerName, router := range conf.TCP.Routers { - routersTCP[routerName] = append(routersTCP[routerName], root) - if !AddRouterTCP(configuration.TCP, routerName, router) { - routersTCPToDelete[routerName] = struct{}{} - } - } - - for transportName, transport := range conf.TCP.ServersTransports { - transportsTCP[transportName] = append(transportsTCP[transportName], root) - if !AddTransportTCP(configuration.TCP, transportName, transport) { - transportsTCPToDelete[transportName] = struct{}{} - } - } - - for serviceName, service := range conf.UDP.Services { - servicesUDP[serviceName] = append(servicesUDP[serviceName], root) - if !AddServiceUDP(configuration.UDP, serviceName, service) { - servicesUDPToDelete[serviceName] = struct{}{} - } - } - - for routerName, router := range conf.UDP.Routers { - routersUDP[routerName] = append(routersUDP[routerName], root) - if !AddRouterUDP(configuration.UDP, routerName, router) { - routersUDPToDelete[routerName] = struct{}{} - } - } - - for middlewareName, middleware := range conf.HTTP.Middlewares { - middlewares[middlewareName] = append(middlewares[middlewareName], root) - if !AddMiddleware(configuration.HTTP, middlewareName, middleware) { - middlewaresToDelete[middlewareName] = struct{}{} - } - } - - for middlewareName, middleware := range conf.TCP.Middlewares { - middlewaresTCP[middlewareName] = append(middlewaresTCP[middlewareName], root) - if !AddMiddlewareTCP(configuration.TCP, middlewareName, middleware) { - middlewaresTCPToDelete[middlewareName] = struct{}{} - } - } - - for storeName, store := range conf.TLS.Stores { - stores[storeName] = append(stores[storeName], root) - if !AddStore(configuration.TLS, storeName, store) { - storesToDelete[storeName] = struct{}{} - } - } - } - - for serviceName := range servicesToDelete { - logger.Error().Str(logs.ServiceName, serviceName). - Interface("configuration", services[serviceName]). - Msg("Service defined multiple times with different configurations") - delete(configuration.HTTP.Services, serviceName) - } - - for routerName := range routersToDelete { - logger.Error().Str(logs.RouterName, routerName). - Interface("configuration", routers[routerName]). - Msg("Router defined multiple times with different configurations") - delete(configuration.HTTP.Routers, routerName) - } - - for transportName := range transportsToDelete { - logger.Error().Str(logs.ServersTransportName, transportName). - Interface("configuration", transports[transportName]). - Msg("ServersTransport defined multiple times with different configurations") - delete(configuration.HTTP.ServersTransports, transportName) - } - - for serviceName := range servicesTCPToDelete { - logger.Error().Str(logs.ServiceName, serviceName). - Interface("configuration", servicesTCP[serviceName]). - Msg("Service TCP defined multiple times with different configurations") - delete(configuration.TCP.Services, serviceName) - } - - for routerName := range routersTCPToDelete { - logger.Error().Str(logs.RouterName, routerName). - Interface("configuration", routersTCP[routerName]). - Msg("Router TCP defined multiple times with different configurations") - delete(configuration.TCP.Routers, routerName) - } - - for transportName := range transportsTCPToDelete { - logger.Error().Str(logs.ServersTransportName, transportName). - Interface("configuration", transportsTCP[transportName]). - Msg("ServersTransport TCP defined multiple times with different configurations") - delete(configuration.TCP.ServersTransports, transportName) - } - - for serviceName := range servicesUDPToDelete { - logger.Error().Str(logs.ServiceName, serviceName). - Interface("configuration", servicesUDP[serviceName]). - Msg("UDP service defined multiple times with different configurations") - delete(configuration.UDP.Services, serviceName) - } - - for routerName := range routersUDPToDelete { - logger.Error().Str(logs.RouterName, routerName). - Interface("configuration", routersUDP[routerName]). - Msg("UDP router defined multiple times with different configurations") - delete(configuration.UDP.Routers, routerName) - } - - for middlewareName := range middlewaresToDelete { - logger.Error().Str(logs.MiddlewareName, middlewareName). - Interface("configuration", middlewares[middlewareName]). - Msg("Middleware defined multiple times with different configurations") - delete(configuration.HTTP.Middlewares, middlewareName) - } - - for middlewareName := range middlewaresTCPToDelete { - logger.Error().Str(logs.MiddlewareName, middlewareName). - Interface("configuration", middlewaresTCP[middlewareName]). - Msg("TCP Middleware defined multiple times with different configurations") - delete(configuration.TCP.Middlewares, middlewareName) - } - - for storeName := range storesToDelete { - logger.Error().Str("storeName", storeName). - Msgf("TLS store defined multiple times with different configurations in %v", stores[storeName]) - delete(configuration.TLS.Stores, storeName) - } - - return configuration -} - -// AddServiceTCP adds a service to a configuration. -func AddServiceTCP(configuration *dynamic.TCPConfiguration, serviceName string, service *dynamic.TCPService) bool { - if _, ok := configuration.Services[serviceName]; !ok { - configuration.Services[serviceName] = service - return true - } - - if !configuration.Services[serviceName].LoadBalancer.Mergeable(service.LoadBalancer) { - return false - } - - uniq := map[string]struct{}{} - for _, server := range configuration.Services[serviceName].LoadBalancer.Servers { - uniq[server.Address] = struct{}{} - } - - for _, server := range service.LoadBalancer.Servers { - if _, ok := uniq[server.Address]; !ok { - configuration.Services[serviceName].LoadBalancer.Servers = append(configuration.Services[serviceName].LoadBalancer.Servers, server) - } - } - - return true -} - -// AddRouterTCP adds a router to a configuration. -func AddRouterTCP(configuration *dynamic.TCPConfiguration, routerName string, router *dynamic.TCPRouter) bool { - if _, ok := configuration.Routers[routerName]; !ok { - configuration.Routers[routerName] = router - return true - } - - return reflect.DeepEqual(configuration.Routers[routerName], router) -} - -// AddMiddlewareTCP adds a middleware to a configuration. -func AddMiddlewareTCP(configuration *dynamic.TCPConfiguration, middlewareName string, middleware *dynamic.TCPMiddleware) bool { - if _, ok := configuration.Middlewares[middlewareName]; !ok { - configuration.Middlewares[middlewareName] = middleware - return true - } - - return reflect.DeepEqual(configuration.Middlewares[middlewareName], middleware) -} - -// AddTransportTCP adds a servers transport to a configuration. -func AddTransportTCP(configuration *dynamic.TCPConfiguration, transportName string, transport *dynamic.TCPServersTransport) bool { - if _, ok := configuration.ServersTransports[transportName]; !ok { - configuration.ServersTransports[transportName] = transport - return true - } - - return reflect.DeepEqual(configuration.ServersTransports[transportName], transport) -} - -// AddServiceUDP adds a service to a configuration. -func AddServiceUDP(configuration *dynamic.UDPConfiguration, serviceName string, service *dynamic.UDPService) bool { - if _, ok := configuration.Services[serviceName]; !ok { - configuration.Services[serviceName] = service - return true - } - - if !configuration.Services[serviceName].LoadBalancer.Mergeable(service.LoadBalancer) { - return false - } - - uniq := map[string]struct{}{} - for _, server := range configuration.Services[serviceName].LoadBalancer.Servers { - uniq[server.Address] = struct{}{} - } - - for _, server := range service.LoadBalancer.Servers { - if _, ok := uniq[server.Address]; !ok { - configuration.Services[serviceName].LoadBalancer.Servers = append(configuration.Services[serviceName].LoadBalancer.Servers, server) - } - } - - return true -} - -// AddRouterUDP adds a router to a configuration. -func AddRouterUDP(configuration *dynamic.UDPConfiguration, routerName string, router *dynamic.UDPRouter) bool { - if _, ok := configuration.Routers[routerName]; !ok { - configuration.Routers[routerName] = router - return true - } - - return reflect.DeepEqual(configuration.Routers[routerName], router) -} - -// AddService adds a service to a configuration. -func AddService(configuration *dynamic.HTTPConfiguration, serviceName string, service *dynamic.Service) bool { - if _, ok := configuration.Services[serviceName]; !ok { - configuration.Services[serviceName] = service - return true - } - - if !configuration.Services[serviceName].LoadBalancer.Mergeable(service.LoadBalancer) { - return false - } - - uniq := map[string]struct{}{} - for _, server := range configuration.Services[serviceName].LoadBalancer.Servers { - uniq[server.URL] = struct{}{} - } - - for _, server := range service.LoadBalancer.Servers { - if _, ok := uniq[server.URL]; !ok { - configuration.Services[serviceName].LoadBalancer.Servers = append(configuration.Services[serviceName].LoadBalancer.Servers, server) - } - } - - return true -} - -// AddRouter adds a router to a configuration. -func AddRouter(configuration *dynamic.HTTPConfiguration, routerName string, router *dynamic.Router) bool { - if _, ok := configuration.Routers[routerName]; !ok { - configuration.Routers[routerName] = router - return true - } - - return reflect.DeepEqual(configuration.Routers[routerName], router) -} - -// AddTransport adds a servers transport to a configuration. -func AddTransport(configuration *dynamic.HTTPConfiguration, transportName string, transport *dynamic.ServersTransport) bool { - if _, ok := configuration.ServersTransports[transportName]; !ok { - configuration.ServersTransports[transportName] = transport - return true - } - - return reflect.DeepEqual(configuration.ServersTransports[transportName], transport) -} - -// AddMiddleware adds a middleware to a configuration. -func AddMiddleware(configuration *dynamic.HTTPConfiguration, middlewareName string, middleware *dynamic.Middleware) bool { - if _, ok := configuration.Middlewares[middlewareName]; !ok { - configuration.Middlewares[middlewareName] = middleware - return true - } - - return reflect.DeepEqual(configuration.Middlewares[middlewareName], middleware) -} - -// AddStore adds a middleware to a configurations. -func AddStore(configuration *dynamic.TLSConfiguration, storeName string, store tls.Store) bool { - if _, ok := configuration.Stores[storeName]; !ok { - configuration.Stores[storeName] = store - return true - } - - return reflect.DeepEqual(configuration.Stores[storeName], store) -} - // MakeDefaultRuleTemplate creates the default rule template. func MakeDefaultRuleTemplate(defaultRule string, funcMap template.FuncMap) (*template.Template, error) { defaultFuncMap := sprig.TxtFuncMap() diff --git a/pkg/provider/consulcatalog/config.go b/pkg/provider/consulcatalog/config.go index 34c9a90dd..06d7c94c0 100644 --- a/pkg/provider/consulcatalog/config.go +++ b/pkg/provider/consulcatalog/config.go @@ -106,7 +106,7 @@ func (p *Provider) buildConfiguration(ctx context.Context, items []itemData, cer configurations[svcName] = confFromLabel } - return provider.Merge(ctx, configurations) + return provider.Merge(ctx, provider.NameSortedConfigurations(configurations), provider.ResourceStrategyMerge) } func (p *Provider) keepContainer(ctx context.Context, item itemData) bool { diff --git a/pkg/provider/docker/config.go b/pkg/provider/docker/config.go index 96133db4f..d88f538a9 100644 --- a/pkg/provider/docker/config.go +++ b/pkg/provider/docker/config.go @@ -101,7 +101,7 @@ func (p *DynConfBuilder) build(ctx context.Context, containersInspected []docker configurations[containerName] = confFromLabel } - return provider.Merge(ctx, configurations) + return provider.Merge(ctx, provider.NameSortedConfigurations(configurations), provider.ResourceStrategyMerge) } func (p *DynConfBuilder) buildTCPServiceConfiguration(ctx context.Context, container dockerData, configuration *dynamic.TCPConfiguration) error { diff --git a/pkg/provider/ecs/config.go b/pkg/provider/ecs/config.go index 7e87f5675..ad39ae176 100644 --- a/pkg/provider/ecs/config.go +++ b/pkg/provider/ecs/config.go @@ -86,7 +86,7 @@ func (p *Provider) buildConfiguration(ctx context.Context, instances []ecsInstan configurations[instanceName] = confFromLabel } - return provider.Merge(ctx, configurations) + return provider.Merge(ctx, provider.NameSortedConfigurations(configurations), provider.ResourceStrategyMerge) } func (p *Provider) buildTCPServiceConfiguration(instance ecsInstance, configuration *dynamic.TCPConfiguration) error { diff --git a/pkg/provider/file/file.go b/pkg/provider/file/file.go index 04395878e..a93d4b551 100644 --- a/pkg/provider/file/file.go +++ b/pkg/provider/file/file.go @@ -221,7 +221,12 @@ func (p *Provider) buildConfiguration() (*dynamic.Configuration, error) { ctx := log.With().Str(logs.ProviderName, providerName).Logger().WithContext(context.Background()) if len(p.Directory) > 0 { - return p.loadFileConfigFromDirectory(ctx, p.Directory, nil) + configurations, err := p.collectFileConfigs(ctx, p.Directory, "") + if err != nil { + return nil, fmt.Errorf("collecting file configs: %w", err) + } + + return provider.Merge(ctx, configurations, provider.ResourceStrategySkipDuplicates), nil } if len(p.Filename) > 0 { @@ -376,47 +381,28 @@ func (p *Provider) loadFileConfig(ctx context.Context, filename string, parseTem return configuration, nil } -func (p *Provider) loadFileConfigFromDirectory(ctx context.Context, directory string, configuration *dynamic.Configuration) (*dynamic.Configuration, error) { +// collectFileConfigs recursively collects configurations from files in the given directory. +func (p *Provider) collectFileConfigs(ctx context.Context, directory, prefix string) ([]provider.NamedConfiguration, error) { + var configurations []provider.NamedConfiguration + fileList, err := os.ReadDir(directory) if err != nil { - return configuration, fmt.Errorf("unable to read directory %s: %w", directory, err) + return nil, fmt.Errorf("reading directory %s: %w", directory, err) } - if configuration == nil { - configuration = &dynamic.Configuration{ - HTTP: &dynamic.HTTPConfiguration{ - Routers: make(map[string]*dynamic.Router), - Middlewares: make(map[string]*dynamic.Middleware), - Services: make(map[string]*dynamic.Service), - ServersTransports: make(map[string]*dynamic.ServersTransport), - }, - TCP: &dynamic.TCPConfiguration{ - Routers: make(map[string]*dynamic.TCPRouter), - Services: make(map[string]*dynamic.TCPService), - Middlewares: make(map[string]*dynamic.TCPMiddleware), - ServersTransports: make(map[string]*dynamic.TCPServersTransport), - }, - TLS: &dynamic.TLSConfiguration{ - Stores: make(map[string]tls.Store), - Options: make(map[string]tls.Options), - }, - UDP: &dynamic.UDPConfiguration{ - Routers: make(map[string]*dynamic.UDPRouter), - Services: make(map[string]*dynamic.UDPService), - }, - } - } - - configTLSMaps := make(map[*tls.CertAndStores]struct{}) - for _, item := range fileList { - logger := log.Ctx(ctx).With().Str("filename", item.Name()).Logger() + itemPath := filepath.Join(directory, item.Name()) + filename := item.Name() + if prefix != "" { + filename = filepath.Join(prefix, item.Name()) + } if item.IsDir() { - configuration, err = p.loadFileConfigFromDirectory(logger.WithContext(ctx), filepath.Join(directory, item.Name()), configuration) + sub, err := p.collectFileConfigs(ctx, itemPath, filename) if err != nil { - return configuration, fmt.Errorf("unable to load content configuration from subdirectory %s: %w", item, err) + return nil, fmt.Errorf("loading content configuration from subdirectory %s: %w", item, err) } + configurations = append(configurations, sub...) continue } @@ -427,132 +413,18 @@ func (p *Provider) loadFileConfigFromDirectory(ctx context.Context, directory st continue } - var c *dynamic.Configuration - c, err = p.loadFileConfig(logger.WithContext(ctx), filepath.Join(directory, item.Name()), true) + c, err := p.loadFileConfig(ctx, itemPath, true) if err != nil { - return configuration, fmt.Errorf("%s: %w", filepath.Join(directory, item.Name()), err) + return nil, fmt.Errorf("%s: %w", itemPath, err) } - for name, conf := range c.HTTP.Routers { - if _, exists := configuration.HTTP.Routers[name]; exists { - logger.Warn().Str(logs.RouterName, name).Msg("HTTP router already configured, skipping") - } else { - configuration.HTTP.Routers[name] = conf - } - } - - for name, conf := range c.HTTP.Middlewares { - if _, exists := configuration.HTTP.Middlewares[name]; exists { - logger.Warn().Str(logs.MiddlewareName, name).Msg("HTTP middleware already configured, skipping") - } else { - configuration.HTTP.Middlewares[name] = conf - } - } - - for name, conf := range c.HTTP.Services { - if _, exists := configuration.HTTP.Services[name]; exists { - logger.Warn().Str(logs.ServiceName, name).Msg("HTTP service already configured, skipping") - } else { - configuration.HTTP.Services[name] = conf - } - } - - for name, conf := range c.HTTP.ServersTransports { - if _, exists := configuration.HTTP.ServersTransports[name]; exists { - logger.Warn().Str(logs.ServersTransportName, name).Msg("HTTP servers transport already configured, skipping") - } else { - configuration.HTTP.ServersTransports[name] = conf - } - } - - for name, conf := range c.TCP.Routers { - if _, exists := configuration.TCP.Routers[name]; exists { - logger.Warn().Str(logs.RouterName, name).Msg("TCP router already configured, skipping") - } else { - configuration.TCP.Routers[name] = conf - } - } - - for name, conf := range c.TCP.Middlewares { - if _, exists := configuration.TCP.Middlewares[name]; exists { - logger.Warn().Str(logs.MiddlewareName, name).Msg("TCP middleware already configured, skipping") - } else { - configuration.TCP.Middlewares[name] = conf - } - } - - for name, conf := range c.TCP.Services { - if _, exists := configuration.TCP.Services[name]; exists { - logger.Warn().Str(logs.ServiceName, name).Msg("TCP service already configured, skipping") - } else { - configuration.TCP.Services[name] = conf - } - } - - for name, conf := range c.TCP.ServersTransports { - if _, exists := configuration.TCP.ServersTransports[name]; exists { - logger.Warn().Str(logs.ServersTransportName, name).Msg("TCP servers transport already configured, skipping") - } else { - configuration.TCP.ServersTransports[name] = conf - } - } - - for name, conf := range c.UDP.Routers { - if _, exists := configuration.UDP.Routers[name]; exists { - logger.Warn().Str(logs.RouterName, name).Msg("UDP router already configured, skipping") - } else { - configuration.UDP.Routers[name] = conf - } - } - - for name, conf := range c.UDP.Services { - if _, exists := configuration.UDP.Services[name]; exists { - logger.Warn().Str(logs.ServiceName, name).Msg("UDP service already configured, skipping") - } else { - configuration.UDP.Services[name] = conf - } - } - - for _, conf := range c.TLS.Certificates { - if _, exists := configTLSMaps[conf]; exists { - logger.Warn().Msgf("TLS configuration %v already configured, skipping", conf) - } else { - configTLSMaps[conf] = struct{}{} - } - } - - for name, conf := range c.TLS.Options { - if _, exists := configuration.TLS.Options[name]; exists { - logger.Warn().Msgf("TLS options %v already configured, skipping", name) - } else { - if configuration.TLS.Options == nil { - configuration.TLS.Options = map[string]tls.Options{} - } - configuration.TLS.Options[name] = conf - } - } - - for name, conf := range c.TLS.Stores { - if _, exists := configuration.TLS.Stores[name]; exists { - logger.Warn().Msgf("TLS store %v already configured, skipping", name) - } else { - if configuration.TLS.Stores == nil { - configuration.TLS.Stores = map[string]tls.Store{} - } - configuration.TLS.Stores[name] = conf - } - } + configurations = append(configurations, provider.NamedConfiguration{ + Name: filename, + Configuration: c, + }) } - if len(configTLSMaps) > 0 && configuration.TLS == nil { - configuration.TLS = &dynamic.TLSConfiguration{} - } - - for conf := range configTLSMaps { - configuration.TLS.Certificates = append(configuration.TLS.Certificates, conf) - } - - return configuration, nil + return configurations, nil } func (p *Provider) decodeConfiguration(filePath, content string) (*dynamic.Configuration, error) { diff --git a/pkg/provider/merge.go b/pkg/provider/merge.go new file mode 100644 index 000000000..760aa4bd6 --- /dev/null +++ b/pkg/provider/merge.go @@ -0,0 +1,330 @@ +package provider + +import ( + "context" + "maps" + "reflect" + "slices" + "strings" + + "github.com/huandu/xstrings" + "github.com/rs/zerolog/log" + "github.com/traefik/traefik/v3/pkg/config/dynamic" + "github.com/traefik/traefik/v3/pkg/observability/logs" + "github.com/traefik/traefik/v3/pkg/tls" +) + +type resourceMeta struct { + logField string + displayName string +} + +var resourceLogFields = map[reflect.Type]resourceMeta{ + reflect.TypeFor[dynamic.Router](): {logs.RouterName, "HTTP router"}, + reflect.TypeFor[dynamic.Service](): {logs.ServiceName, "HTTP service"}, + reflect.TypeFor[dynamic.Middleware](): {logs.MiddlewareName, "HTTP middleware"}, + reflect.TypeFor[dynamic.ServersTransport](): {logs.ServersTransportName, "HTTP servers transport"}, + reflect.TypeFor[dynamic.TCPRouter](): {logs.RouterName, "TCP router"}, + reflect.TypeFor[dynamic.TCPService](): {logs.ServiceName, "TCP service"}, + reflect.TypeFor[dynamic.TCPMiddleware](): {logs.MiddlewareName, "TCP middleware"}, + reflect.TypeFor[dynamic.TCPServersTransport](): {logs.ServersTransportName, "TCP servers transport"}, + reflect.TypeFor[dynamic.UDPRouter](): {logs.RouterName, "UDP router"}, + reflect.TypeFor[dynamic.UDPService](): {logs.ServiceName, "UDP service"}, +} + +// ResourceStrategy defines how the merge should handle resources. +type ResourceStrategy int + +const ( + // ResourceStrategyMerge tries to call the Merge method on the resource. + ResourceStrategyMerge ResourceStrategy = iota + // ResourceStrategySkipDuplicates skips duplicate resources. + ResourceStrategySkipDuplicates +) + +// NamedConfiguration is a configuration with its name. +type NamedConfiguration struct { + Name string + Configuration *dynamic.Configuration +} + +// NameSortedConfigurations returns the configurations sorted by name. +func NameSortedConfigurations(configurations map[string]*dynamic.Configuration) []NamedConfiguration { + origins := slices.Sorted(maps.Keys(configurations)) + + sorted := make([]NamedConfiguration, 0, len(origins)) + for _, origin := range origins { + sorted = append(sorted, NamedConfiguration{Name: origin, Configuration: configurations[origin]}) + } + + return sorted +} + +// Merge merges multiple configurations. +func Merge(ctx context.Context, configurations []NamedConfiguration, strategy ResourceStrategy) *dynamic.Configuration { + merged := &dynamic.Configuration{ + HTTP: &dynamic.HTTPConfiguration{ + Routers: make(map[string]*dynamic.Router), + Middlewares: make(map[string]*dynamic.Middleware), + Services: make(map[string]*dynamic.Service), + ServersTransports: make(map[string]*dynamic.ServersTransport), + }, + TCP: &dynamic.TCPConfiguration{ + Routers: make(map[string]*dynamic.TCPRouter), + Services: make(map[string]*dynamic.TCPService), + Middlewares: make(map[string]*dynamic.TCPMiddleware), + ServersTransports: make(map[string]*dynamic.TCPServersTransport), + }, + UDP: &dynamic.UDPConfiguration{ + Routers: make(map[string]*dynamic.UDPRouter), + Services: make(map[string]*dynamic.UDPService), + }, + TLS: &dynamic.TLSConfiguration{ + Stores: make(map[string]tls.Store), + }, + } + + tracker := newMergeTracker() + + for _, c := range configurations { + if c.Configuration.HTTP != nil { + mergeResourceMaps(ctx, reflect.ValueOf(merged.HTTP).Elem(), reflect.ValueOf(c.Configuration.HTTP).Elem(), c.Name, tracker, strategy) + } + if c.Configuration.TCP != nil { + mergeResourceMaps(ctx, reflect.ValueOf(merged.TCP).Elem(), reflect.ValueOf(c.Configuration.TCP).Elem(), c.Name, tracker, strategy) + } + if c.Configuration.UDP != nil { + mergeResourceMaps(ctx, reflect.ValueOf(merged.UDP).Elem(), reflect.ValueOf(c.Configuration.UDP).Elem(), c.Name, tracker, strategy) + } + if c.Configuration.TLS != nil { + mergeResourceMaps(ctx, reflect.ValueOf(merged.TLS).Elem(), reflect.ValueOf(c.Configuration.TLS).Elem(), c.Name, tracker, strategy) + + merged.TLS.Certificates = mergeCertificates(ctx, merged.TLS.Certificates, c.Configuration.TLS.Certificates, c.Name, strategy) + } + } + + deleteConflicts(ctx, tracker) + + return merged +} + +// mergeResourceMaps merges all the resource maps defined in the provided struct. +// Conflicts are recorded in the given merge tracker. +func mergeResourceMaps(ctx context.Context, dst, src reflect.Value, origin string, tracker *mergeTracker, strategy ResourceStrategy) { + dstType := dst.Type() + + for i := range dstType.NumField() { + field := dstType.Field(i) + if !field.IsExported() { + continue + } + + dstField := dst.Field(i) + srcField := src.Field(i) + + // Merge the resource maps of embedded structs. + if field.Anonymous { + mergeResourceMaps(ctx, dstField, srcField, origin, tracker, strategy) + continue + } + + if dstField.Kind() == reflect.Map { + mergeResourceMap(ctx, dstField, srcField, origin, tracker, strategy) + } + } +} + +// mergeResourceMap merges a resource map src into dst. +// New keys from src are added to dst. +// Duplicate keys are merged if the resource type implements a Merge method, otherwise +// the values must be identical. Conflicts are recorded in the given merge tracker. +func mergeResourceMap(ctx context.Context, dst, src reflect.Value, origin string, tracker *mergeTracker, strategy ResourceStrategy) { + if src.IsNil() { + return + } + + if dst.IsNil() { + dst.Set(reflect.MakeMap(dst.Type())) + } + + for _, resourceKey := range src.MapKeys() { + resourceKeyStr := resourceKey.String() + tracker.recordOrigin(dst, resourceKeyStr, origin) + + srcValue := src.MapIndex(resourceKey) + dstValue := dst.MapIndex(resourceKey) + + // Key doesn't exist in dst, add it. + if !dstValue.IsValid() { + dst.SetMapIndex(resourceKey, srcValue) + continue + } + + // Key exists, need to merge or detect conflict. + switch strategy { + case ResourceStrategyMerge: + if !tryMerge(dstValue, srcValue) { + tracker.markForDeletion(dst, resourceKeyStr, dst.Type().Elem()) + } + case ResourceStrategySkipDuplicates: + logSkippedDuplicate(ctx, dst.Type().Elem(), resourceKeyStr, origin) + } + } +} + +// tryMerge attempts to merge two resources. +// Returns true if the merge succeeds, false if values conflict. +func tryMerge(dst, src reflect.Value) bool { + if dst.Kind() != reflect.Ptr { + return reflect.DeepEqual(dst.Interface(), src.Interface()) + } + + if dst.IsNil() || src.IsNil() { + return reflect.DeepEqual(dst.Interface(), src.Interface()) + } + + // Check if the struct has the method `func (* T) Merge(other T) bool`. + // We use reflection to detect this method because Go's type system doesn't allow type assertions + // on generic interfaces (Mergeable[T]) practically. + mergeMethod := dst.MethodByName("Merge") + if mergeMethod.IsValid() { + methodType := mergeMethod.Type() + if methodType.NumIn() == 1 && methodType.NumOut() == 1 && methodType.Out(0).Kind() == reflect.Bool { + // Make sure the parameter type matches the type holding the method. + if methodType.In(0).AssignableTo(src.Type()) { + results := mergeMethod.Call([]reflect.Value{src}) + return results[0].Bool() + } + } + } + + // When Merge is not implemented, merge is not allowed; the values must be the same. + return reflect.DeepEqual(dst.Elem().Interface(), src.Elem().Interface()) +} + +// deleteConflicts removes conflicting items and logs errors. +func deleteConflicts(ctx context.Context, tracker *mergeTracker) { + logger := log.Ctx(ctx) + + for ck, info := range tracker.toDelete { + resourceNameField, resourceTypeWords := resourceLogMeta(info.resourceType) + logger.Error(). + Str(resourceNameField, ck.resourceKey). + Interface("configuration", tracker.origins[ck]). + Msgf("%s defined multiple times with different configurations", resourceTypeWords) + + info.resourceMap.SetMapIndex(reflect.ValueOf(ck.resourceKey), reflect.Value{}) + } +} + +// mergeCertificates merges multiple certificates. +func mergeCertificates(ctx context.Context, certificates []*tls.CertAndStores, newCertificates []*tls.CertAndStores, origin string, strategy ResourceStrategy) []*tls.CertAndStores { + for _, certificate := range newCertificates { + var found bool + for _, existingCertificate := range certificates { + if existingCertificate.Certificate == certificate.Certificate { + found = true + + switch strategy { + case ResourceStrategyMerge: + existingCertificate.Stores = mergeStores(existingCertificate.Stores, certificate.Stores) + case ResourceStrategySkipDuplicates: + log.Ctx(ctx).Warn(). + Str("origin", origin). + Msgf("TLS certificate %v already configured, skipping", certificate.Certificate) + } + + break + } + } + + if !found { + certificates = append(certificates, certificate) + } + } + + return certificates +} + +// mergeStores merges two store slices, deduplicating entries while. Order is preserved. +func mergeStores(existing, other []string) []string { + seen := make(map[string]struct{}, len(existing)) + for _, s := range existing { + seen[s] = struct{}{} + } + + for _, s := range other { + if _, ok := seen[s]; !ok { + existing = append(existing, s) + seen[s] = struct{}{} + } + } + + return existing +} + +// logSkippedDuplicate logs a warning when a duplicate resource is skipped. +func logSkippedDuplicate(ctx context.Context, resourceType reflect.Type, resourceKey, origin string) { + resourceNameField, resourceTypeWords := resourceLogMeta(resourceType) + + log.Ctx(ctx).Warn(). + Str("origin", origin). + Str(resourceNameField, resourceKey). + Msgf("%s already configured, skipping", resourceTypeWords) +} + +// resourceLogMeta returns the log field name and human-readable type description for the given resource element type. +func resourceLogMeta(resourceType reflect.Type) (resourceNameField, resourceTypeWords string) { + if resourceType.Kind() == reflect.Ptr { + resourceType = resourceType.Elem() + } + + meta, ok := resourceLogFields[resourceType] + if ok { + return meta.logField, meta.displayName + } + + resourceTypeName := resourceType.Name() + resourceNameField = xstrings.ToCamelCase(resourceTypeName) + "Name" + resourceTypeWords = strings.ReplaceAll(xstrings.ToKebabCase(resourceTypeName), "-", " ") + + return resourceNameField, resourceTypeWords +} + +// mergeTracker tracks item origins and items marked for deletion during merge. +type mergeTracker struct { + toDelete map[conflictKey]conflictInfo + origins map[conflictKey][]string +} + +// conflictKey uniquely identifies an entry in a map. +type conflictKey struct { + mapPtr uintptr + resourceKey string +} + +// conflictInfo stores information about a merge conflict. +type conflictInfo struct { + resourceMap reflect.Value // The map to delete from. + resourceType reflect.Type +} + +func newMergeTracker() *mergeTracker { + return &mergeTracker{ + toDelete: make(map[conflictKey]conflictInfo), + origins: make(map[conflictKey][]string), + } +} + +func (t *mergeTracker) recordOrigin(resourceMap reflect.Value, resourceKey, origin string) { + ck := conflictKey{mapPtr: resourceMap.Pointer(), resourceKey: resourceKey} + t.origins[ck] = append(t.origins[ck], origin) +} + +func (t *mergeTracker) markForDeletion(resourceMap reflect.Value, resourceKey string, resourceType reflect.Type) { + ck := conflictKey{mapPtr: resourceMap.Pointer(), resourceKey: resourceKey} + t.toDelete[ck] = conflictInfo{ + resourceMap: resourceMap, + resourceType: resourceType, + } +} diff --git a/pkg/provider/merge_test.go b/pkg/provider/merge_test.go new file mode 100644 index 000000000..2dd890dd6 --- /dev/null +++ b/pkg/provider/merge_test.go @@ -0,0 +1,557 @@ +package provider + +import ( + "context" + "reflect" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/traefik/traefik/v3/pkg/config/dynamic" + "github.com/traefik/traefik/v3/pkg/tls" +) + +// testResource is a simple type without a Merge method. +type testResource struct { + Value string +} + +// testMergeableResource implements the Merge method. +type testMergeableResource struct { + Config string + Servers []string +} + +// Merge merges another testMergeableResource into this one. +// Returns true if the merge succeeds, false if configurations conflict. +func (r *testMergeableResource) Merge(other *testMergeableResource) bool { + if r.Config != other.Config { + return false + } + r.Servers = append(r.Servers, other.Servers...) + + return true +} + +// testCollectionSet is a container with a map field. +type testCollectionSet struct { + Resources map[string]*testResource +} + +// testMergeableCollectionSet is a container with a mergeable map field. +type testMergeableCollectionSet struct { + Resources map[string]*testMergeableResource +} + +// TestEmbedded is an embedded struct for testing anonymous field handling. +// Must be exported for reflection to process it. +type TestEmbedded struct { + EmbeddedItems map[string]*testResource +} + +// testCollectionSetWithEmbedded has both embedded and direct map fields. +type testCollectionSetWithEmbedded struct { + TestEmbedded + + Items map[string]*testResource +} + +func TestMergeCollectionSet_BasicMapMerge(t *testing.T) { + dst := &testCollectionSet{ + Resources: map[string]*testResource{ + "existing": {Value: "dst"}, + }, + } + src := &testCollectionSet{ + Resources: map[string]*testResource{ + "new": {Value: "src"}, + }, + } + + tracker := newMergeTracker() + mergeResourceMaps(context.Background(), reflect.ValueOf(dst).Elem(), reflect.ValueOf(src).Elem(), "provider", tracker, ResourceStrategyMerge) + + assert.Empty(t, tracker.toDelete) + assert.Equal(t, &testCollectionSet{ + Resources: map[string]*testResource{ + "existing": {Value: "dst"}, + "new": {Value: "src"}, + }, + }, dst) +} + +func TestMergeCollectionSet_EmbeddedStruct(t *testing.T) { + dst := &testCollectionSetWithEmbedded{ + TestEmbedded: TestEmbedded{ + EmbeddedItems: map[string]*testResource{ + "embedded1": {Value: "dst-embedded"}, + }, + }, + Items: map[string]*testResource{ + "item1": {Value: "dst-item"}, + }, + } + src := &testCollectionSetWithEmbedded{ + TestEmbedded: TestEmbedded{ + EmbeddedItems: map[string]*testResource{ + "embedded2": {Value: "src-embedded"}, + }, + }, + Items: map[string]*testResource{ + "item2": {Value: "src-item"}, + }, + } + + tracker := newMergeTracker() + mergeResourceMaps(context.Background(), reflect.ValueOf(dst).Elem(), reflect.ValueOf(src).Elem(), "provider", tracker, ResourceStrategyMerge) + + assert.Empty(t, tracker.toDelete) + assert.Equal(t, &testCollectionSetWithEmbedded{ + TestEmbedded: TestEmbedded{ + EmbeddedItems: map[string]*testResource{ + "embedded1": {Value: "dst-embedded"}, + "embedded2": {Value: "src-embedded"}, + }, + }, + Items: map[string]*testResource{ + "item1": {Value: "dst-item"}, + "item2": {Value: "src-item"}, + }, + }, dst) +} + +func TestMergeCollectionSet_MergeableInterface(t *testing.T) { + dst := &testMergeableCollectionSet{ + Resources: map[string]*testMergeableResource{ + "svc1": {Config: "same", Servers: []string{"server1"}}, + }, + } + src := &testMergeableCollectionSet{ + Resources: map[string]*testMergeableResource{ + "svc1": {Config: "same", Servers: []string{"server2"}}, + }, + } + + tracker := newMergeTracker() + mergeResourceMaps(context.Background(), reflect.ValueOf(dst).Elem(), reflect.ValueOf(src).Elem(), "provider", tracker, ResourceStrategyMerge) + + assert.Empty(t, tracker.toDelete) + assert.Equal(t, &testMergeableCollectionSet{ + Resources: map[string]*testMergeableResource{ + "svc1": {Config: "same", Servers: []string{"server1", "server2"}}, + }, + }, dst) +} + +func TestMergeCollectionSet_MergeableConflict(t *testing.T) { + dst := &testMergeableCollectionSet{ + Resources: map[string]*testMergeableResource{ + "svc1": {Config: "config-A", Servers: []string{"server1"}}, + }, + } + src := &testMergeableCollectionSet{ + Resources: map[string]*testMergeableResource{ + "svc1": {Config: "config-B", Servers: []string{"server2"}}, + }, + } + + tracker := newMergeTracker() + mergeResourceMaps(context.Background(), reflect.ValueOf(dst).Elem(), reflect.ValueOf(src).Elem(), "provider1", tracker, ResourceStrategyMerge) + + // Merge() returns false due to config mismatch -> marked for deletion. + assert.Len(t, tracker.toDelete, 1) + assertMarkedForDeletion(t, tracker.toDelete, "svc1") +} + +func TestMergeCollectionSet_DeepEqualFallback(t *testing.T) { + dst := &testCollectionSet{ + Resources: map[string]*testResource{ + "res1": {Value: "same"}, + }, + } + src := &testCollectionSet{ + Resources: map[string]*testResource{ + "res1": {Value: "same"}, + }, + } + + tracker := newMergeTracker() + mergeResourceMaps(context.Background(), reflect.ValueOf(dst).Elem(), reflect.ValueOf(src).Elem(), "provider1", tracker, ResourceStrategyMerge) + + // Same values -> no conflict. + assert.Empty(t, tracker.toDelete) + assert.Equal(t, &testCollectionSet{ + Resources: map[string]*testResource{ + "res1": {Value: "same"}, + }, + }, dst) +} + +func TestMergeCollectionSet_DeepEqualConflict(t *testing.T) { + dst := &testCollectionSet{ + Resources: map[string]*testResource{ + "res1": {Value: "value-A"}, + }, + } + src := &testCollectionSet{ + Resources: map[string]*testResource{ + "res1": {Value: "value-B"}, + }, + } + + tracker := newMergeTracker() + mergeResourceMaps(context.Background(), reflect.ValueOf(dst).Elem(), reflect.ValueOf(src).Elem(), "provider1", tracker, ResourceStrategyMerge) + + // Different values, no Merge method -> conflict. + assert.Len(t, tracker.toDelete, 1) + assertMarkedForDeletion(t, tracker.toDelete, "res1") +} + +func TestMerge(t *testing.T) { + testCases := []struct { + desc string + configurations map[string]*dynamic.Configuration + strategy ResourceStrategy + expected *dynamic.Configuration + }{ + { + desc: "HTTP routers: multiple providers different routers", + configurations: map[string]*dynamic.Configuration{ + "provider1": { + HTTP: &dynamic.HTTPConfiguration{ + Routers: map[string]*dynamic.Router{ + "router1": {Rule: "Host(`example1.com`)"}, + }, + }, + }, + "provider2": { + HTTP: &dynamic.HTTPConfiguration{ + Routers: map[string]*dynamic.Router{ + "router2": {Rule: "Host(`example2.com`)"}, + }, + }, + }, + }, + strategy: ResourceStrategyMerge, + expected: buildExpectedConfiguration(func(c *dynamic.Configuration) { + c.HTTP.Routers["router1"] = &dynamic.Router{Rule: "Host(`example1.com`)"} + c.HTTP.Routers["router2"] = &dynamic.Router{Rule: "Host(`example2.com`)"} + }), + }, + { + desc: "HTTP routers: conflict multiple providers same router different config", + configurations: map[string]*dynamic.Configuration{ + "provider1": { + HTTP: &dynamic.HTTPConfiguration{ + Routers: map[string]*dynamic.Router{ + "router1": {Rule: "Host(`example1.com`)"}, + }, + }, + }, + "provider2": { + HTTP: &dynamic.HTTPConfiguration{ + Routers: map[string]*dynamic.Router{ + "router1": {Rule: "Host(`example2.com`)"}, + }, + }, + }, + }, + strategy: ResourceStrategyMerge, + expected: buildExpectedConfiguration(nil), + }, + { + desc: "HTTP services: multiple providers same service servers merged", + configurations: map[string]*dynamic.Configuration{ + "provider1": { + HTTP: &dynamic.HTTPConfiguration{ + Services: map[string]*dynamic.Service{ + "service1": { + LoadBalancer: &dynamic.ServersLoadBalancer{ + Servers: []dynamic.Server{ + {URL: "http://server1:80"}, + }, + }, + }, + }, + }, + }, + "provider2": { + HTTP: &dynamic.HTTPConfiguration{ + Services: map[string]*dynamic.Service{ + "service1": { + LoadBalancer: &dynamic.ServersLoadBalancer{ + Servers: []dynamic.Server{ + {URL: "http://server2:80"}, + }, + }, + }, + }, + }, + }, + }, + strategy: ResourceStrategyMerge, + expected: buildExpectedConfiguration(func(c *dynamic.Configuration) { + c.HTTP.Services["service1"] = &dynamic.Service{ + LoadBalancer: &dynamic.ServersLoadBalancer{ + Servers: []dynamic.Server{ + {URL: "http://server1:80"}, + {URL: "http://server2:80"}, + }, + }, + } + }), + }, + { + desc: "HTTP services: multiple providers same service duplicate servers deduplicated", + configurations: map[string]*dynamic.Configuration{ + "provider1": { + HTTP: &dynamic.HTTPConfiguration{ + Services: map[string]*dynamic.Service{ + "service1": { + LoadBalancer: &dynamic.ServersLoadBalancer{ + Servers: []dynamic.Server{ + {URL: "http://server1:80"}, + }, + }, + }, + }, + }, + }, + "provider2": { + HTTP: &dynamic.HTTPConfiguration{ + Services: map[string]*dynamic.Service{ + "service1": { + LoadBalancer: &dynamic.ServersLoadBalancer{ + Servers: []dynamic.Server{ + {URL: "http://server1:80"}, + }, + }, + }, + }, + }, + }, + }, + strategy: ResourceStrategyMerge, + expected: buildExpectedConfiguration(func(c *dynamic.Configuration) { + c.HTTP.Services["service1"] = &dynamic.Service{ + LoadBalancer: &dynamic.ServersLoadBalancer{ + Servers: []dynamic.Server{ + {URL: "http://server1:80"}, + }, + }, + } + }), + }, + { + desc: "TLS certificates: different certificates both kept", + configurations: map[string]*dynamic.Configuration{ + "provider1": { + TLS: &dynamic.TLSConfiguration{ + Certificates: []*tls.CertAndStores{ + { + Certificate: tls.Certificate{CertFile: "cert1.pem", KeyFile: "key1.pem"}, + Stores: []string{"store1"}, + }, + }, + }, + }, + "provider2": { + TLS: &dynamic.TLSConfiguration{ + Certificates: []*tls.CertAndStores{ + { + Certificate: tls.Certificate{CertFile: "cert2.pem", KeyFile: "key2.pem"}, + Stores: []string{"store2"}, + }, + }, + }, + }, + }, + strategy: ResourceStrategyMerge, + expected: buildExpectedConfiguration(func(c *dynamic.Configuration) { + c.TLS.Certificates = []*tls.CertAndStores{ + { + Certificate: tls.Certificate{CertFile: "cert1.pem", KeyFile: "key1.pem"}, + Stores: []string{"store1"}, + }, + { + Certificate: tls.Certificate{CertFile: "cert2.pem", KeyFile: "key2.pem"}, + Stores: []string{"store2"}, + }, + } + }), + }, + { + desc: "TLS certificates: same certificate stores merged with ResourceStrategyMerge", + configurations: map[string]*dynamic.Configuration{ + "provider1": { + TLS: &dynamic.TLSConfiguration{ + Certificates: []*tls.CertAndStores{ + { + Certificate: tls.Certificate{CertFile: "cert.pem", KeyFile: "key.pem"}, + Stores: []string{"store1"}, + }, + }, + }, + }, + "provider2": { + TLS: &dynamic.TLSConfiguration{ + Certificates: []*tls.CertAndStores{ + { + Certificate: tls.Certificate{CertFile: "cert.pem", KeyFile: "key.pem"}, + Stores: []string{"store2"}, + }, + }, + }, + }, + }, + strategy: ResourceStrategyMerge, + expected: buildExpectedConfiguration(func(c *dynamic.Configuration) { + c.TLS.Certificates = []*tls.CertAndStores{ + { + Certificate: tls.Certificate{CertFile: "cert.pem", KeyFile: "key.pem"}, + Stores: []string{"store1", "store2"}, + }, + } + }), + }, + { + desc: "TLS certificates: same certificate overlapping stores deduplicated", + configurations: map[string]*dynamic.Configuration{ + "provider1": { + TLS: &dynamic.TLSConfiguration{ + Certificates: []*tls.CertAndStores{ + { + Certificate: tls.Certificate{CertFile: "cert.pem", KeyFile: "key.pem"}, + Stores: []string{"store1", "store2"}, + }, + }, + }, + }, + "provider2": { + TLS: &dynamic.TLSConfiguration{ + Certificates: []*tls.CertAndStores{ + { + Certificate: tls.Certificate{CertFile: "cert.pem", KeyFile: "key.pem"}, + Stores: []string{"store2", "store3"}, + }, + }, + }, + }, + }, + strategy: ResourceStrategyMerge, + expected: buildExpectedConfiguration(func(c *dynamic.Configuration) { + c.TLS.Certificates = []*tls.CertAndStores{ + { + Certificate: tls.Certificate{CertFile: "cert.pem", KeyFile: "key.pem"}, + Stores: []string{"store1", "store2", "store3"}, + }, + } + }), + }, + { + desc: "TLS certificates: same certificate stores not merged with ResourceStrategySkipDuplicates", + configurations: map[string]*dynamic.Configuration{ + "provider1": { + TLS: &dynamic.TLSConfiguration{ + Certificates: []*tls.CertAndStores{ + { + Certificate: tls.Certificate{CertFile: "cert.pem", KeyFile: "key.pem"}, + Stores: []string{"store1"}, + }, + }, + }, + }, + "provider2": { + TLS: &dynamic.TLSConfiguration{ + Certificates: []*tls.CertAndStores{ + { + Certificate: tls.Certificate{CertFile: "cert.pem", KeyFile: "key.pem"}, + Stores: []string{"store2"}, + }, + }, + }, + }, + }, + strategy: ResourceStrategySkipDuplicates, + expected: buildExpectedConfiguration(func(c *dynamic.Configuration) { + c.TLS.Certificates = []*tls.CertAndStores{ + { + Certificate: tls.Certificate{CertFile: "cert.pem", KeyFile: "key.pem"}, + Stores: []string{"store1"}, + }, + } + }), + }, + { + desc: "nil configuration from one provider", + configurations: map[string]*dynamic.Configuration{ + "provider1": { + HTTP: &dynamic.HTTPConfiguration{ + Routers: map[string]*dynamic.Router{ + "router1": {Rule: "Host(`example.com`)"}, + }, + }, + }, + "provider2": { + // No HTTP configuration + }, + }, + strategy: ResourceStrategyMerge, + expected: buildExpectedConfiguration(func(c *dynamic.Configuration) { + c.HTTP.Routers["router1"] = &dynamic.Router{Rule: "Host(`example.com`)"} + }), + }, + { + desc: "empty configurations", + configurations: map[string]*dynamic.Configuration{}, + strategy: ResourceStrategyMerge, + expected: buildExpectedConfiguration(nil), + }, + } + + for _, test := range testCases { + t.Run(test.desc, func(t *testing.T) { + result := Merge(context.Background(), NameSortedConfigurations(test.configurations), test.strategy) + + assert.Equal(t, test.expected, result) + }) + } +} + +func buildExpectedConfiguration(modifier func(*dynamic.Configuration)) *dynamic.Configuration { + c := &dynamic.Configuration{ + HTTP: &dynamic.HTTPConfiguration{ + Routers: make(map[string]*dynamic.Router), + Middlewares: make(map[string]*dynamic.Middleware), + Services: make(map[string]*dynamic.Service), + ServersTransports: make(map[string]*dynamic.ServersTransport), + }, + TCP: &dynamic.TCPConfiguration{ + Routers: make(map[string]*dynamic.TCPRouter), + Services: make(map[string]*dynamic.TCPService), + Middlewares: make(map[string]*dynamic.TCPMiddleware), + ServersTransports: make(map[string]*dynamic.TCPServersTransport), + }, + UDP: &dynamic.UDPConfiguration{ + Routers: make(map[string]*dynamic.UDPRouter), + Services: make(map[string]*dynamic.UDPService), + }, + TLS: &dynamic.TLSConfiguration{ + Stores: make(map[string]tls.Store), + }, + } + if modifier != nil { + modifier(c) + } + return c +} + +// assertMarkedForDeletion checks that toDelete contains an entry with the given key. +func assertMarkedForDeletion(t *testing.T, toDelete map[conflictKey]conflictInfo, key string) { + t.Helper() + for ck := range toDelete { + if ck.resourceKey == key { + return + } + } + t.Errorf("toDelete does not contain key %q", key) +} diff --git a/pkg/provider/nomad/config.go b/pkg/provider/nomad/config.go index a6fb4343b..69df3dfe8 100644 --- a/pkg/provider/nomad/config.go +++ b/pkg/provider/nomad/config.go @@ -84,7 +84,7 @@ func (p *Provider) buildConfig(ctx context.Context, items []item) *dynamic.Confi configurations[svcName] = config } - return provider.Merge(ctx, configurations) + return provider.Merge(ctx, provider.NameSortedConfigurations(configurations), provider.ResourceStrategyMerge) } func (p *Provider) buildTCPConfig(i item, configuration *dynamic.TCPConfiguration) error {