From 42e69bcd67496032ad632401e4ebfc11096d35ac Mon Sep 17 00:00:00 2001 From: "Gina A." <70909035+gndz07@users.noreply.github.com> Date: Wed, 22 Apr 2026 15:42:05 +0700 Subject: [PATCH] Handle duplicate server-alias on ingress-nginx provider --- ...gress-with-server-alias-alias-conflict.yml | 43 +++++ .../kubernetes/ingress-nginx/kubernetes.go | 64 +++++-- .../ingress-nginx/kubernetes_test.go | 168 ++++++++++++++++++ 3 files changed, 263 insertions(+), 12 deletions(-) create mode 100644 pkg/provider/kubernetes/ingress-nginx/fixtures/ingresses/ingress-with-server-alias-alias-conflict.yml diff --git a/pkg/provider/kubernetes/ingress-nginx/fixtures/ingresses/ingress-with-server-alias-alias-conflict.yml b/pkg/provider/kubernetes/ingress-nginx/fixtures/ingresses/ingress-with-server-alias-alias-conflict.yml new file mode 100644 index 0000000000..65f78b6dbd --- /dev/null +++ b/pkg/provider/kubernetes/ingress-nginx/fixtures/ingresses/ingress-with-server-alias-alias-conflict.yml @@ -0,0 +1,43 @@ +--- +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: first-ingress + namespace: default + annotations: + nginx.ingress.kubernetes.io/server-alias: "shared.localhost" +spec: + ingressClassName: nginx + rules: + - host: first.localhost + http: + paths: + - backend: + service: + name: whoami + port: + number: 80 + path: / + pathType: Prefix + +--- +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: second-ingress + namespace: default + annotations: + nginx.ingress.kubernetes.io/server-alias: "shared.localhost" +spec: + ingressClassName: nginx + rules: + - host: second.localhost + http: + paths: + - backend: + service: + name: whoami + port: + number: 80 + path: / + pathType: Prefix diff --git a/pkg/provider/kubernetes/ingress-nginx/kubernetes.go b/pkg/provider/kubernetes/ingress-nginx/kubernetes.go index cd8aa307c6..2683f9f6f8 100644 --- a/pkg/provider/kubernetes/ingress-nginx/kubernetes.go +++ b/pkg/provider/kubernetes/ingress-nginx/kubernetes.go @@ -11,6 +11,7 @@ import ( "os" "regexp" "slices" + "sort" "strconv" "strings" "time" @@ -479,11 +480,36 @@ func (p *Provider) loadConfiguration(ctx context.Context) *dynamic.Configuration canaryIngresses []ingress ) + // Sort the ingresses by creation timestamps, to help to decide when two ingresses have the same server-alias value. + listedIngresses := p.k8sClient.ListIngresses() + sort.SliceStable(listedIngresses, func(a, b int) bool { + ta, tb := listedIngresses[a].CreationTimestamp, listedIngresses[b].CreationTimestamp + + // When the timestamp is exactly the same, fallback to descending namespace/name lexicographic order. + // The same fallback and debug log with ingress-nginx. + // Ref: https://github.com/kubernetes/ingress-nginx/blob/main/internal/ingress/controller/store/store.go#L1102-L1115. + if ta.Equal(&tb) { + ia := listedIngresses[a].Namespace + "/" + listedIngresses[a].Name + ib := listedIngresses[b].Namespace + "/" + listedIngresses[b].Name + log.Ctx(ctx).Debug(). + Str("ingress_a", ia). + Str("ingress_b", ib). + Msg("Ingresses have identical CreationTimestamp, falling back to descending namespace/name lexicographic order") + return ia > ib + } + + return ta.Before(&tb) + }) + hosts := make(map[string]bool) hostsWithUseRegex := make(map[string]bool) serverSnippets := make(map[string]string) ingressPaths := make(map[string]ingressPath) // indexed by namespace+host+path+pathType. - for _, ing := range p.k8sClient.ListIngresses() { + // Build a map of claimed server-aliases: the first ingress (by creation time) to + // declare an alias owns it; any later ingress that repeats the same alias is denied. + claimedAliases := make(map[string]string) + + for _, ing := range listedIngresses { if !p.shouldProcessIngress(ing, ingressClasses) { continue } @@ -544,6 +570,13 @@ func (p *Provider) loadConfiguration(ctx context.Context) *dynamic.Configuration } } + for _, alias := range ptr.Deref(i.IngressConfig.ServerAlias, nil) { + serverAlias := strings.ToLower(alias) + if _, exist := claimedAliases[serverAlias]; !exist { + claimedAliases[serverAlias] = i.Namespace + "/" + i.Name + } + } + ingresses = append(ingresses, i) } @@ -836,7 +869,7 @@ func (p *Provider) loadConfiguration(ctx context.Context) *dynamic.Configuration rt := &dynamic.Router{ EntryPoints: p.NonTLSEntryPoints, - Rule: buildRule(ctxIngress, rule.Host, pa, ingress.IngressConfig, hosts, hostsWithUseRegex), + Rule: buildRule(ctxIngress, ingress, rule.Host, pa, hosts, hostsWithUseRegex, claimedAliases), // "default" stands for the default rule syntax in Traefik v3, i.e. the v3 syntax. RuleSyntax: "default", Service: serviceName, @@ -2267,20 +2300,27 @@ func basicAuthUsers(secret *corev1.Secret, authSecretType string) (dynamic.Users return users, nil } -func buildRule(ctx context.Context, host string, pa netv1.HTTPIngressPath, config IngressConfig, allHosts map[string]bool, hostsWithUseRegex map[string]bool) string { +func buildRule(ctx context.Context, ingress ingress, host string, pa netv1.HTTPIngressPath, allHosts map[string]bool, hostsWithUseRegex map[string]bool, claimedAliases map[string]string) string { var rules []string if host != "" { hosts := []string{host} - if config.ServerAlias != nil { - for _, alias := range *config.ServerAlias { - if _, ok := allHosts[strings.ToLower(alias)]; ok { - log.Ctx(ctx).Debug(). - Str("alias", alias). - Msg("Skipping server-alias because it is already defined as a host in another Ingress") - continue - } - hosts = append(hosts, alias) + for _, alias := range ptr.Deref(ingress.IngressConfig.ServerAlias, nil) { + serverAlias := strings.ToLower(alias) + if _, ok := allHosts[serverAlias]; ok { + log.Ctx(ctx).Debug(). + Str("alias", alias). + Msg("Skipping server-alias because it is already defined as a host in another Ingress") + continue } + ingressKey := ingress.Namespace + "/" + ingress.Name + if owner, ok := claimedAliases[serverAlias]; ok && owner != ingressKey { + log.Ctx(ctx).Debug(). + Str("alias", alias). + Str("ingress", ingressKey). + Msgf("Skipping server-alias because it is already claimed by %s Ingress", owner) + continue + } + hosts = append(hosts, alias) } var hostRules []string diff --git a/pkg/provider/kubernetes/ingress-nginx/kubernetes_test.go b/pkg/provider/kubernetes/ingress-nginx/kubernetes_test.go index 7b4abf026f..bed66e8b21 100644 --- a/pkg/provider/kubernetes/ingress-nginx/kubernetes_test.go +++ b/pkg/provider/kubernetes/ingress-nginx/kubernetes_test.go @@ -10824,6 +10824,174 @@ func TestLoadIngresses(t *testing.T) { TLS: &dynamic.TLSConfiguration{}, }, }, + { + desc: "Server Alias with Alias-Alias Conflict", + paths: []string{ + "services.yml", + "ingressclasses.yml", + "ingresses/ingress-with-server-alias-alias-conflict.yml", + }, + expected: &dynamic.Configuration{ + TCP: &dynamic.TCPConfiguration{ + Routers: map[string]*dynamic.TCPRouter{}, + Services: map[string]*dynamic.TCPService{}, + }, + HTTP: &dynamic.HTTPConfiguration{ + Routers: map[string]*dynamic.Router{ + "default-first-ingress-rule-0-path-0": { + EntryPoints: []string{"http"}, + Rule: `Host("first.localhost") && PathPrefix("/")`, + RuleSyntax: "default", + Middlewares: []string{"default-first-ingress-rule-0-path-0-retry"}, + Service: "default-first-ingress-whoami-80", + Observability: &dynamic.RouterObservabilityConfig{ + Metadata: &dynamic.ObservabilityMetadata{ + Ingress: &dynamic.KubernetesIngressMetadata{ + Namespace: "default", + IngressName: "first-ingress", + ServiceName: "whoami", + ServicePort: "80", + }, + }, + }, + }, + "default-second-ingress-rule-0-path-0": { + EntryPoints: []string{"http"}, + Rule: `(Host("second.localhost") || Host("shared.localhost")) && PathPrefix("/")`, + RuleSyntax: "default", + Middlewares: []string{"default-second-ingress-rule-0-path-0-retry"}, + Service: "default-second-ingress-whoami-80", + Observability: &dynamic.RouterObservabilityConfig{ + Metadata: &dynamic.ObservabilityMetadata{ + Ingress: &dynamic.KubernetesIngressMetadata{ + Namespace: "default", + IngressName: "second-ingress", + ServiceName: "whoami", + ServicePort: "80", + }, + }, + }, + }, + "default-first-ingress-rule-0-path-0-tls": { + EntryPoints: []string{"https"}, + Rule: `Host("first.localhost") && PathPrefix("/")`, + RuleSyntax: "default", + Middlewares: []string{"default-first-ingress-rule-0-path-0-tls-retry"}, + Service: "default-first-ingress-whoami-80", + Observability: &dynamic.RouterObservabilityConfig{ + Metadata: &dynamic.ObservabilityMetadata{ + Ingress: &dynamic.KubernetesIngressMetadata{ + Namespace: "default", + IngressName: "first-ingress", + ServiceName: "whoami", + ServicePort: "80", + }, + }, + }, + TLS: &dynamic.RouterTLSConfig{}, + }, + "default-second-ingress-rule-0-path-0-tls": { + EntryPoints: []string{"https"}, + Rule: `(Host("second.localhost") || Host("shared.localhost")) && PathPrefix("/")`, + RuleSyntax: "default", + Middlewares: []string{"default-second-ingress-rule-0-path-0-tls-retry"}, + Service: "default-second-ingress-whoami-80", + Observability: &dynamic.RouterObservabilityConfig{ + Metadata: &dynamic.ObservabilityMetadata{ + Ingress: &dynamic.KubernetesIngressMetadata{ + Namespace: "default", + IngressName: "second-ingress", + ServiceName: "whoami", + ServicePort: "80", + }, + }, + }, + TLS: &dynamic.RouterTLSConfig{}, + }, + }, + Middlewares: map[string]*dynamic.Middleware{ + "default-first-ingress-rule-0-path-0-retry": { + Retry: &dynamic.Retry{ + Attempts: 3, + }, + }, + "default-first-ingress-rule-0-path-0-tls-retry": { + Retry: &dynamic.Retry{ + Attempts: 3, + }, + }, + "default-second-ingress-rule-0-path-0-retry": { + Retry: &dynamic.Retry{ + Attempts: 3, + }, + }, + "default-second-ingress-rule-0-path-0-tls-retry": { + Retry: &dynamic.Retry{ + Attempts: 3, + }, + }, + }, + Services: map[string]*dynamic.Service{ + "unavailable-service": { + LoadBalancer: &dynamic.ServersLoadBalancer{ + Strategy: "wrr", + PassHostHeader: ptr.To(true), + ResponseForwarding: &dynamic.ResponseForwarding{ + FlushInterval: dynamic.DefaultFlushInterval, + }, + }, + }, + "default-first-ingress-whoami-80": { + LoadBalancer: &dynamic.ServersLoadBalancer{ + Servers: []dynamic.Server{ + {URL: "http://10.10.0.1:80"}, + {URL: "http://10.10.0.2:80"}, + }, + Strategy: "wrr", + PassHostHeader: ptr.To(true), + ResponseForwarding: &dynamic.ResponseForwarding{ + FlushInterval: dynamic.DefaultFlushInterval, + }, + ServersTransport: "default-first-ingress", + }, + }, + "default-second-ingress-whoami-80": { + LoadBalancer: &dynamic.ServersLoadBalancer{ + Servers: []dynamic.Server{ + {URL: "http://10.10.0.1:80"}, + {URL: "http://10.10.0.2:80"}, + }, + Strategy: "wrr", + PassHostHeader: ptr.To(true), + ResponseForwarding: &dynamic.ResponseForwarding{ + FlushInterval: dynamic.DefaultFlushInterval, + }, + ServersTransport: "default-second-ingress", + }, + }, + }, + ServersTransports: map[string]*dynamic.ServersTransport{ + "default-first-ingress": { + ForwardingTimeouts: &dynamic.ForwardingTimeouts{ + DialTimeout: ptypes.Duration(60 * time.Second), + ReadTimeout: ptypes.Duration(60 * time.Second), + WriteTimeout: ptypes.Duration(60 * time.Second), + IdleConnTimeout: ptypes.Duration(60 * time.Second), + }, + }, + "default-second-ingress": { + ForwardingTimeouts: &dynamic.ForwardingTimeouts{ + DialTimeout: ptypes.Duration(60 * time.Second), + ReadTimeout: ptypes.Duration(60 * time.Second), + WriteTimeout: ptypes.Duration(60 * time.Second), + IdleConnTimeout: ptypes.Duration(60 * time.Second), + }, + }, + }, + }, + TLS: &dynamic.TLSConfiguration{}, + }, + }, { desc: "Proxy HTTP version 1.1", paths: []string{