From 28e655452c5ba3a8cb39f17b95cbd2c9175c55d5 Mon Sep 17 00:00:00 2001 From: Kevin Pollet Date: Fri, 24 Apr 2026 11:16:05 +0200 Subject: [PATCH] Do not require a port for ExternalName services --- .../ingresses/ingress-with-external-name.yml | 41 ++++++++ .../ingress-nginx/fixtures/services.yml | 15 +++ .../kubernetes/ingress-nginx/kubernetes.go | 60 +++++------ .../ingress-nginx/kubernetes_test.go | 99 +++++++++++++++++++ 4 files changed, 187 insertions(+), 28 deletions(-) create mode 100644 pkg/provider/kubernetes/ingress-nginx/fixtures/ingresses/ingress-with-external-name.yml diff --git a/pkg/provider/kubernetes/ingress-nginx/fixtures/ingresses/ingress-with-external-name.yml b/pkg/provider/kubernetes/ingress-nginx/fixtures/ingresses/ingress-with-external-name.yml new file mode 100644 index 0000000000..743675324d --- /dev/null +++ b/pkg/provider/kubernetes/ingress-nginx/fixtures/ingresses/ingress-with-external-name.yml @@ -0,0 +1,41 @@ +--- +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: ingress-with-external-name + namespace: default + +spec: + ingressClassName: nginx + rules: + - host: external.localhost + http: + paths: + - path: /external-with-matching-port + pathType: Exact + backend: + service: + name: external + port: + number: 80 + - path: /external-with-matching-named-port + pathType: Exact + backend: + service: + name: external + port: + name: http + - path: /external-with-non-matching-port + pathType: Exact + backend: + service: + name: external + port: + number: 3000 + - path: /external-with-non-matching-named-port + pathType: Exact + backend: + service: + name: external + port: + name: foo diff --git a/pkg/provider/kubernetes/ingress-nginx/fixtures/services.yml b/pkg/provider/kubernetes/ingress-nginx/fixtures/services.yml index b658083aaf..8cbe3b3b2d 100644 --- a/pkg/provider/kubernetes/ingress-nginx/fixtures/services.yml +++ b/pkg/provider/kubernetes/ingress-nginx/fixtures/services.yml @@ -78,3 +78,18 @@ endpoints: - 10.10.0.6 conditions: ready: true + +--- +kind: Service +apiVersion: v1 +metadata: + name: external + namespace: default + +spec: + type: ExternalName + externalName: external.com + ports: + - port: 80 + targetPort: 8080 + name: http diff --git a/pkg/provider/kubernetes/ingress-nginx/kubernetes.go b/pkg/provider/kubernetes/ingress-nginx/kubernetes.go index 918a48580a..2fcfb8a476 100644 --- a/pkg/provider/kubernetes/ingress-nginx/kubernetes.go +++ b/pkg/provider/kubernetes/ingress-nginx/kubernetes.go @@ -27,6 +27,7 @@ import ( "github.com/traefik/traefik/v3/pkg/types" corev1 "k8s.io/api/core/v1" netv1 "k8s.io/api/networking/v1" + "k8s.io/apimachinery/pkg/util/intstr" "k8s.io/utils/ptr" ) @@ -375,12 +376,7 @@ func (p *Provider) loadConfiguration(ctx context.Context) *dynamic.Configuration continue } - port := backend.Service.Port.Name - if len(backend.Service.Port.Name) == 0 { - port = strconv.Itoa(int(backend.Service.Port.Number)) - } - - serviceName := provider.Normalize(ingress.Namespace + "-" + backend.Service.Name + "-" + port) + serviceName := provider.Normalize(ingress.Namespace + "-" + backend.Service.Name + "-" + portString(backend.Service.Port)) conf.TCP.Services[serviceName] = service routerKey := strings.TrimPrefix(provider.Normalize(ingress.Namespace+"-"+ingress.Name+"-"+rule.Host), "-") @@ -449,13 +445,8 @@ func (p *Provider) loadConfiguration(ctx context.Context) *dynamic.Configuration continue } - portString := pa.Backend.Service.Port.Name - if len(pa.Backend.Service.Port.Name) == 0 { - portString = strconv.Itoa(int(pa.Backend.Service.Port.Number)) - } - // TODO: if no service, do not add middlewares and 503. - serviceName := provider.Normalize(ingress.Namespace + "-" + ingress.Name + "-" + pa.Backend.Service.Name + "-" + portString) + serviceName := provider.Normalize(ingress.Namespace + "-" + ingress.Name + "-" + pa.Backend.Service.Name + "-" + portString(pa.Backend.Service.Port)) service, err := p.buildService(ingress.Namespace, pa.Backend, ingressConfig) if err != nil { @@ -589,6 +580,24 @@ func (p *Provider) buildPassthroughService(namespace string, backend netv1.Ingre return &dynamic.TCPService{LoadBalancer: lb}, nil } +func getServicePort(service *corev1.Service, backend netv1.IngressBackend) (corev1.ServicePort, bool) { + for _, p := range service.Spec.Ports { + // A port with number 0 or an empty name is not allowed, this case is there for the default backend service. + if (backend.Service.Port.Number == 0 && backend.Service.Port.Name == "") || + (backend.Service.Port.Number == p.Port || (backend.Service.Port.Name == p.Name && len(p.Name) > 0)) { + return p, true + } + } + + // If the port is not found and the service is of type ExternalName, we return the port defined in the backend. + // If this is a named port, the port value will be 0 to be consistent with ingress-nginx. + if service.Spec.Type == corev1.ServiceTypeExternalName { + return corev1.ServicePort{TargetPort: intstr.Parse(portString(backend.Service.Port))}, true + } + + return corev1.ServicePort{}, false +} + func (p *Provider) getBackendAddresses(namespace string, backend netv1.IngressBackend, cfg ingressConfig) ([]backendAddress, error) { service, err := p.k8sClient.GetService(namespace, backend.Service.Name) if err != nil { @@ -599,30 +608,18 @@ func (p *Provider) getBackendAddresses(namespace string, backend netv1.IngressBa return nil, errors.New("externalName services not allowed") } - var portName string - var portSpec corev1.ServicePort - var match bool - for _, p := range service.Spec.Ports { - // A port with number 0 or an empty name is not allowed, this case is there for the default backend service. - if (backend.Service.Port.Number == 0 && backend.Service.Port.Name == "") || - (backend.Service.Port.Number == p.Port || (backend.Service.Port.Name == p.Name && len(p.Name) > 0)) { - portName = p.Name - portSpec = p - match = true - break - } - } + servicePort, match := getServicePort(service, backend) if !match { return nil, errors.New("service port not found") } if service.Spec.Type == corev1.ServiceTypeExternalName { - return []backendAddress{{Address: net.JoinHostPort(service.Spec.ExternalName, strconv.Itoa(int(portSpec.Port)))}}, nil + return []backendAddress{{Address: net.JoinHostPort(service.Spec.ExternalName, strconv.Itoa(servicePort.TargetPort.IntValue()))}}, nil } // When service upstream is set to true we return the service ClusterIP as the backend address. if ptr.Deref(cfg.ServiceUpstream, false) { - return []backendAddress{{Address: net.JoinHostPort(service.Spec.ClusterIP, strconv.Itoa(int(portSpec.Port)))}}, nil + return []backendAddress{{Address: net.JoinHostPort(service.Spec.ClusterIP, strconv.Itoa(int(servicePort.Port)))}}, nil } endpointSlices, err := p.k8sClient.GetEndpointSlicesForService(namespace, backend.Service.Name) @@ -635,7 +632,7 @@ func (p *Provider) getBackendAddresses(namespace string, backend netv1.IngressBa for _, endpointSlice := range endpointSlices { var port int32 for _, p := range endpointSlice.Ports { - if portName == *p.Name { + if servicePort.Name == *p.Name { port = *p.Port break } @@ -1140,3 +1137,10 @@ func throttleEvents(ctx context.Context, throttleDuration time.Duration, pool *s return eventsChanBuffered } + +func portString(port netv1.ServiceBackendPort) string { + if port.Name == "" { + return strconv.Itoa(int(port.Number)) + } + return port.Name +} diff --git a/pkg/provider/kubernetes/ingress-nginx/kubernetes_test.go b/pkg/provider/kubernetes/ingress-nginx/kubernetes_test.go index 7fcbff4a96..b411a6ec12 100644 --- a/pkg/provider/kubernetes/ingress-nginx/kubernetes_test.go +++ b/pkg/provider/kubernetes/ingress-nginx/kubernetes_test.go @@ -703,6 +703,105 @@ func TestLoadIngresses(t *testing.T) { TLS: &dynamic.TLSConfiguration{}, }, }, + { + desc: "External name service", + paths: []string{ + "services.yml", + "ingressclasses.yml", + "ingresses/ingress-with-external-name.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-ingress-with-external-name-rule-0-path-0": { + Rule: `Host("external.localhost") && Path("/external-with-matching-port")`, + RuleSyntax: "default", + Service: "default-ingress-with-external-name-external-80", + }, + "default-ingress-with-external-name-rule-0-path-1": { + Rule: `Host("external.localhost") && Path("/external-with-matching-named-port")`, + RuleSyntax: "default", + Service: "default-ingress-with-external-name-external-http", + }, + "default-ingress-with-external-name-rule-0-path-2": { + Rule: `Host("external.localhost") && Path("/external-with-non-matching-port")`, + RuleSyntax: "default", + Service: "default-ingress-with-external-name-external-3000", + }, + "default-ingress-with-external-name-rule-0-path-3": { + Rule: `Host("external.localhost") && Path("/external-with-non-matching-named-port")`, + RuleSyntax: "default", + Service: "default-ingress-with-external-name-external-foo", + }, + }, + Middlewares: map[string]*dynamic.Middleware{}, + Services: map[string]*dynamic.Service{ + "default-ingress-with-external-name-external-80": { + LoadBalancer: &dynamic.ServersLoadBalancer{ + Servers: []dynamic.Server{ + { + URL: "http://external.com:8080", + }, + }, + Strategy: "wrr", + PassHostHeader: ptr.To(true), + ResponseForwarding: &dynamic.ResponseForwarding{ + FlushInterval: dynamic.DefaultFlushInterval, + }, + }, + }, + "default-ingress-with-external-name-external-3000": { + LoadBalancer: &dynamic.ServersLoadBalancer{ + Servers: []dynamic.Server{ + { + URL: "http://external.com:3000", + }, + }, + Strategy: "wrr", + PassHostHeader: ptr.To(true), + ResponseForwarding: &dynamic.ResponseForwarding{ + FlushInterval: dynamic.DefaultFlushInterval, + }, + }, + }, + "default-ingress-with-external-name-external-http": { + LoadBalancer: &dynamic.ServersLoadBalancer{ + Servers: []dynamic.Server{ + { + URL: "http://external.com:8080", + }, + }, + Strategy: "wrr", + PassHostHeader: ptr.To(true), + ResponseForwarding: &dynamic.ResponseForwarding{ + FlushInterval: dynamic.DefaultFlushInterval, + }, + }, + }, + "default-ingress-with-external-name-external-foo": { + LoadBalancer: &dynamic.ServersLoadBalancer{ + Servers: []dynamic.Server{ + { + URL: "http://external.com:0", + }, + }, + Strategy: "wrr", + PassHostHeader: ptr.To(true), + ResponseForwarding: &dynamic.ResponseForwarding{ + FlushInterval: dynamic.DefaultFlushInterval, + }, + }, + }, + }, + ServersTransports: map[string]*dynamic.ServersTransport{}, + }, + TLS: &dynamic.TLSConfiguration{}, + }, + }, } for _, test := range testCases {