diff --git a/source/endpoints.go b/source/endpoints.go index 99c74fd07c..107425b9ae 100644 --- a/source/endpoints.go +++ b/source/endpoints.go @@ -81,6 +81,10 @@ func EndpointsForHostname(hostname string, targets endpoint.Targets, ttl endpoin return endpoints } +// EndpointTargetsFromServices retrieves endpoint targets from services in a given namespace +// that match the specified selector. It returns external IPs or load balancer addresses. +// +// TODO: add support for service.Spec.Ports (type NodePort) and service.Spec.ClusterIPs (type ClusterIP) func EndpointTargetsFromServices(svcInformer coreinformers.ServiceInformer, namespace string, selector map[string]string) (endpoint.Targets, error) { targets := endpoint.Targets{} diff --git a/source/istio_gateway.go b/source/istio_gateway.go index 5f5a35cf93..1e57725135 100644 --- a/source/istio_gateway.go +++ b/source/istio_gateway.go @@ -168,7 +168,7 @@ func (sc *gatewaySource) Endpoints(ctx context.Context) ([]*endpoint.Endpoint, e log.Debugf("Processing gateway '%s/%s.%s' and hosts %q", gateway.Namespace, gateway.APIVersion, gateway.Name, strings.Join(gwHostnames, ",")) - gwEndpoints, err := sc.endpointsFromGateway(ctx, gwHostnames, gateway) + gwEndpoints, err := sc.endpointsFromGateway(gwHostnames, gateway) if err != nil { return nil, err } @@ -183,7 +183,7 @@ func (sc *gatewaySource) Endpoints(ctx context.Context) ([]*endpoint.Endpoint, e if err != nil { return nil, err } - return sc.endpointsFromGateway(ctx, hostnames, gateway) + return sc.endpointsFromGateway(hostnames, gateway) }, ) if err != nil { @@ -240,7 +240,7 @@ func (sc *gatewaySource) targetsFromIngress(ingressStr string, gateway *networki return targets, nil } -func (sc *gatewaySource) targetsFromGateway(ctx context.Context, gateway *networkingv1beta1.Gateway) (endpoint.Targets, error) { +func (sc *gatewaySource) targetsFromGateway(gateway *networkingv1beta1.Gateway) (endpoint.Targets, error) { targets := annotations.TargetsFromTargetAnnotation(gateway.Annotations) if len(targets) > 0 { return targets, nil @@ -255,11 +255,11 @@ func (sc *gatewaySource) targetsFromGateway(ctx context.Context, gateway *networ } // endpointsFromGatewayConfig extracts the endpoints from an Istio Gateway Config object -func (sc *gatewaySource) endpointsFromGateway(ctx context.Context, hostnames []string, gateway *networkingv1beta1.Gateway) ([]*endpoint.Endpoint, error) { +func (sc *gatewaySource) endpointsFromGateway(hostnames []string, gateway *networkingv1beta1.Gateway) ([]*endpoint.Endpoint, error) { var endpoints []*endpoint.Endpoint var err error - targets, err := sc.targetsFromGateway(ctx, gateway) + targets, err := sc.targetsFromGateway(gateway) if err != nil { return nil, err } diff --git a/source/istio_gateway_fqdn_test.go b/source/istio_gateway_fqdn_test.go new file mode 100644 index 0000000000..b4781e535f --- /dev/null +++ b/source/istio_gateway_fqdn_test.go @@ -0,0 +1,519 @@ +/* +Copyright 2026 The Kubernetes Authors. +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + http://www.apache.org/licenses/LICENSE-2.0 +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package source + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + istionetworking "istio.io/api/networking/v1beta1" + networkingv1beta1 "istio.io/client-go/pkg/apis/networking/v1beta1" + istiofake "istio.io/client-go/pkg/clientset/versioned/fake" + v1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/util/intstr" + "k8s.io/client-go/kubernetes/fake" + "sigs.k8s.io/external-dns/source/annotations" + + "sigs.k8s.io/external-dns/endpoint" +) + +func TestIstioGatewaySourceNewSourceWithFqdn(t *testing.T) { + for _, tt := range []struct { + title string + annotationFilter string + fqdnTemplate string + expectError bool + }{ + { + title: "invalid template", + expectError: true, + fqdnTemplate: "{{.Name", + }, + { + title: "valid empty template", + expectError: false, + }, + { + title: "valid template", + expectError: false, + fqdnTemplate: "{{.Name}}-{{.Namespace}}.ext-dns.test.com", + }, + { + title: "valid template with multiple hosts", + expectError: false, + fqdnTemplate: "{{.Name}}-{{.Namespace}}.ext-dns.test.com, {{.Name}}-{{.Namespace}}.ext-dna.test.com", + }, + } { + t.Run(tt.title, func(t *testing.T) { + _, err := NewIstioGatewaySource( + t.Context(), + fake.NewClientset(), + istiofake.NewSimpleClientset(), + "", + tt.annotationFilter, + tt.fqdnTemplate, + false, + false, + ) + + if tt.expectError { + assert.Error(t, err) + } else { + assert.NoError(t, err) + } + }) + } +} + +func TestIstioGatewaySourceFqdnTemplatingExamples(t *testing.T) { + for _, tt := range []struct { + title string + gateways []*networkingv1beta1.Gateway + services []*v1.Service + fqdnTemplate string + combineFqdn bool + expected []*endpoint.Endpoint + }{ + { + title: "simple templating with gateway name", + fqdnTemplate: "{{.Name}}.test.com", + expected: []*endpoint.Endpoint{ + {DNSName: "example.org", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{"1.2.3.4"}}, + {DNSName: "my-gateway.test.com", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{"1.2.3.4"}}, + }, + gateways: []*networkingv1beta1.Gateway{ + { + ObjectMeta: metav1.ObjectMeta{ + Name: "my-gateway", + Namespace: "istio-system", + }, + Spec: istionetworking.Gateway{ + Selector: map[string]string{"istio": "ingressgateway"}, + Servers: []*istionetworking.Server{ + {Hosts: []string{"example.org"}}, + }, + }, + }, + }, + services: []*v1.Service{ + { + ObjectMeta: metav1.ObjectMeta{ + Name: "istio-ingressgateway", + Namespace: "istio-system", + Labels: map[string]string{"istio": "ingressgateway"}, + }, + Spec: v1.ServiceSpec{ + Type: v1.ServiceTypeLoadBalancer, + Selector: map[string]string{"istio": "ingressgateway"}, + }, + Status: v1.ServiceStatus{ + LoadBalancer: v1.LoadBalancerStatus{ + Ingress: []v1.LoadBalancerIngress{{IP: "1.2.3.4"}}, + }, + }, + }, + }, + }, + { + title: "templating with fqdn combine disabled", + fqdnTemplate: "{{.Name}}.test.com", + expected: []*endpoint.Endpoint{ + {DNSName: "example.org", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{"1.2.3.4"}}, + }, + combineFqdn: true, + gateways: []*networkingv1beta1.Gateway{ + { + ObjectMeta: metav1.ObjectMeta{ + Name: "my-gateway", + Namespace: "istio-system", + }, + Spec: istionetworking.Gateway{ + Selector: map[string]string{"istio": "ingressgateway"}, + Servers: []*istionetworking.Server{ + {Hosts: []string{"example.org"}}, + }, + }, + }, + }, + services: []*v1.Service{ + { + ObjectMeta: metav1.ObjectMeta{ + Name: "istio-ingressgateway", + Namespace: "istio-system", + Labels: map[string]string{"istio": "ingressgateway"}, + }, + Spec: v1.ServiceSpec{ + Type: v1.ServiceTypeLoadBalancer, + Selector: map[string]string{"istio": "ingressgateway"}, + }, + Status: v1.ServiceStatus{ + LoadBalancer: v1.LoadBalancerStatus{ + Ingress: []v1.LoadBalancerIngress{{IP: "1.2.3.4"}}, + }, + }, + }, + }, + }, + { + title: "templating with namespace", + fqdnTemplate: "{{.Name}}.{{.Namespace}}.cluster.local", + expected: []*endpoint.Endpoint{ + {DNSName: "api.example.org", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{"5.6.7.8"}}, + {DNSName: "api-gateway.kube-system.cluster.local", RecordType: endpoint.RecordTypeAAAA, Targets: endpoint.Targets{"::ffff:192.1.56.10"}}, + {DNSName: "api-gateway.production.cluster.local", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{"5.6.7.8"}}, + }, + gateways: []*networkingv1beta1.Gateway{ + { + ObjectMeta: metav1.ObjectMeta{ + Name: "api-gateway", + Namespace: "production", + }, + Spec: istionetworking.Gateway{ + Selector: map[string]string{"istio": "ingressgateway"}, + Servers: []*istionetworking.Server{ + {Hosts: []string{"api.example.org"}}, + }, + }, + }, + { + ObjectMeta: metav1.ObjectMeta{ + Name: "api-gateway", + Namespace: "kube-system", + }, + Spec: istionetworking.Gateway{ + Selector: map[string]string{"istio": "ingressgateway-extra"}, + }, + }, + }, + services: []*v1.Service{ + { + ObjectMeta: metav1.ObjectMeta{ + Name: "istio-ingressgateway", + Namespace: "production", + Labels: map[string]string{"istio": "ingressgateway"}, + }, + Spec: v1.ServiceSpec{ + Type: v1.ServiceTypeLoadBalancer, + Selector: map[string]string{"istio": "ingressgateway"}, + }, + Status: v1.ServiceStatus{ + LoadBalancer: v1.LoadBalancerStatus{ + Ingress: []v1.LoadBalancerIngress{{IP: "5.6.7.8"}}, + }, + }, + }, + { + ObjectMeta: metav1.ObjectMeta{ + Name: "kube-metrics-server", + Namespace: "kube-system", + Labels: map[string]string{"istio": "ingressgateway-extra"}, + }, + Spec: v1.ServiceSpec{ + Type: v1.ServiceTypeLoadBalancer, + Selector: map[string]string{"istio": "ingressgateway-extra"}, + }, + Status: v1.ServiceStatus{ + LoadBalancer: v1.LoadBalancerStatus{ + Ingress: []v1.LoadBalancerIngress{{IP: "::ffff:192.1.56.10"}}, + }, + }, + }, + }, + }, + { + title: "templating with complex fqdn template", + fqdnTemplate: "{{.Name}}.example.com,{{.Name}}.example.org", + expected: []*endpoint.Endpoint{ + {DNSName: "multi-gateway.example.com", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{"10.0.0.1"}}, + {DNSName: "multi-gateway.example.org", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{"10.0.0.1"}}, + }, + gateways: []*networkingv1beta1.Gateway{ + { + ObjectMeta: metav1.ObjectMeta{ + Name: "multi-gateway", + Namespace: "default", + }, + Spec: istionetworking.Gateway{ + Selector: map[string]string{"istio": "ingressgateway"}, + Servers: []*istionetworking.Server{}, + }, + }, + }, + services: []*v1.Service{ + { + ObjectMeta: metav1.ObjectMeta{ + Name: "istio-ingressgateway", + Namespace: "default", + Labels: map[string]string{"istio": "ingressgateway"}, + }, + Spec: v1.ServiceSpec{ + Type: v1.ServiceTypeLoadBalancer, + Selector: map[string]string{"istio": "ingressgateway"}, + }, + Status: v1.ServiceStatus{ + LoadBalancer: v1.LoadBalancerStatus{ + Ingress: []v1.LoadBalancerIngress{{IP: "10.0.0.1"}}, + }, + }, + }, + }, + }, + { + title: "combine FQDN annotation with template", + fqdnTemplate: "{{.Name}}.internal.example.com", + expected: []*endpoint.Endpoint{ + {DNSName: "app.example.org", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{"172.16.0.1"}}, + {DNSName: "combined-gateway.internal.example.com", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{"172.16.0.1"}}, + }, + gateways: []*networkingv1beta1.Gateway{ + { + ObjectMeta: metav1.ObjectMeta{ + Name: "combined-gateway", + Namespace: "default", + }, + Spec: istionetworking.Gateway{ + Selector: map[string]string{"istio": "ingressgateway"}, + Servers: []*istionetworking.Server{ + {Hosts: []string{"app.example.org"}}, + }, + }, + }, + }, + services: []*v1.Service{ + { + ObjectMeta: metav1.ObjectMeta{ + Name: "istio-ingressgateway", + Namespace: "default", + Labels: map[string]string{ + "istio": "ingressgateway", + }, + }, + Spec: v1.ServiceSpec{ + Type: v1.ServiceTypeLoadBalancer, + Selector: map[string]string{"istio": "ingressgateway"}, + }, + Status: v1.ServiceStatus{ + LoadBalancer: v1.LoadBalancerStatus{ + Ingress: []v1.LoadBalancerIngress{{IP: "172.16.0.1"}}, + }, + }, + }, + }, + }, + { + title: "templating with labels", + gateways: []*networkingv1beta1.Gateway{ + { + ObjectMeta: metav1.ObjectMeta{ + Name: "labeled-gateway", + Namespace: "default", + Labels: map[string]string{ + "environment": "staging", + }, + Annotations: map[string]string{ + annotations.TargetKey: "203.0.113.1", + }, + }, + Spec: istionetworking.Gateway{ + Selector: map[string]string{"istio": "ingressgateway"}, + Servers: []*istionetworking.Server{}, + }, + }, + }, + fqdnTemplate: "{{.Name}}.{{.Labels.environment}}.example.com", + expected: []*endpoint.Endpoint{ + {DNSName: "labeled-gateway.staging.example.com", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{"203.0.113.1"}}, + }, + }, + { + title: "srv record with node port and cluster ip services without external ips", + fqdnTemplate: "{{.Name}}.example.com", + expected: []*endpoint.Endpoint{}, + gateways: []*networkingv1beta1.Gateway{ + { + ObjectMeta: metav1.ObjectMeta{ + Name: "labeled-gateway", + Namespace: "default", + }, + Spec: istionetworking.Gateway{ + Selector: map[string]string{"istio": "ingressgateway"}, + Servers: []*istionetworking.Server{}, + }, + }, + }, + services: []*v1.Service{ + { + ObjectMeta: metav1.ObjectMeta{ + Name: "test-node-port", + Namespace: "default", + Labels: map[string]string{ + "istio": "ingressgateway", + }, + }, + Spec: v1.ServiceSpec{ + Type: v1.ServiceTypeNodePort, + Selector: map[string]string{"istio": "ingressgateway"}, + ClusterIP: "10.96.41.133", + Ports: []v1.ServicePort{ + {Name: "dns", Port: 8082, TargetPort: intstr.FromInt32(8083), Protocol: v1.ProtocolUDP, NodePort: 30083}, + {Name: "dns-tcp", Port: 2525, TargetPort: intstr.FromInt32(25256), NodePort: 25565}, + }, + }, + Status: v1.ServiceStatus{ + LoadBalancer: v1.LoadBalancerStatus{}, + }, + }, + { + ObjectMeta: metav1.ObjectMeta{ + Name: "test-cluster-ip", + Namespace: "default", + Labels: map[string]string{"istio": "ingressgateway"}, + }, + Spec: v1.ServiceSpec{ + Type: v1.ServiceTypeClusterIP, + Selector: map[string]string{"istio": "ingressgateway"}, + ClusterIP: "10.96.41.133", + Ports: []v1.ServicePort{ + {Name: "dns", Port: 53, TargetPort: intstr.FromInt32(30053), Protocol: v1.ProtocolUDP}, + {Name: "dns-tcp", Port: 53, TargetPort: intstr.FromInt32(30054), NodePort: 25565}, + }, + }, + }, + }, + }, + { + title: "srv record with node port and cluster ip services with external ips", + fqdnTemplate: "{{.Name}}.tld.org", + expected: []*endpoint.Endpoint{ + {DNSName: "nodeport-external.tld.org", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{"192.168.132.253"}}, + }, + gateways: []*networkingv1beta1.Gateway{ + { + ObjectMeta: metav1.ObjectMeta{ + Name: "nodeport-external", + Namespace: "default", + }, + Spec: istionetworking.Gateway{ + Selector: map[string]string{"istio": "ingressgateway"}, + }, + }, + }, + services: []*v1.Service{ + { + ObjectMeta: metav1.ObjectMeta{ + Name: "test-node-port", + Namespace: "default", + Labels: map[string]string{ + "istio": "ingressgateway", + }, + }, + Spec: v1.ServiceSpec{ + Type: v1.ServiceTypeNodePort, + Selector: map[string]string{"istio": "ingressgateway"}, + ClusterIP: "10.96.41.133", + ExternalIPs: []string{"192.168.132.253"}, + Ports: []v1.ServicePort{ + {Name: "dns", Port: 8082, TargetPort: intstr.FromInt32(8083), Protocol: v1.ProtocolUDP, NodePort: 30083}, + {Name: "dns-tcp", Port: 2525, TargetPort: intstr.FromInt32(25256), NodePort: 25565}, + }, + }, + }, + }, + }, + { + title: "with host as subdomain in reversed order", + fqdnTemplate: "{{ range $server := .Spec.Servers }}{{ range $host := $server.Hosts }}{{ $host }}.{{ $server.Port.Name }}.{{ $server.Port.Number }}.tld.com,{{ end }}{{ end }}", + expected: []*endpoint.Endpoint{ + {DNSName: "www.bookinfo", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{"192.168.132.253"}}, + {DNSName: "bookinfo", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{"192.168.132.253"}}, + {DNSName: "www.bookinfo.http.443.tld.com", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{"192.168.132.253"}}, + {DNSName: "bookinfo.dns.8080.tld.com", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{"192.168.132.253"}}, + }, + gateways: []*networkingv1beta1.Gateway{ + { + ObjectMeta: metav1.ObjectMeta{ + Name: "nodeport-external", + Namespace: "default", + }, + Spec: istionetworking.Gateway{ + Selector: map[string]string{"istio": "ingressgateway"}, + Servers: []*istionetworking.Server{ + { + Hosts: []string{"www.bookinfo"}, + Name: "main", + Port: &istionetworking.Port{Number: 443, Name: "http", Protocol: "HTTPS"}, + }, + { + Hosts: []string{"bookinfo"}, + Name: "debug", + Port: &istionetworking.Port{Number: 8080, Name: "dns", Protocol: "UDP"}, + }, + }, + }, + }, + }, + services: []*v1.Service{ + { + ObjectMeta: metav1.ObjectMeta{ + Name: "test-node-port", + Namespace: "default", + Labels: map[string]string{ + "istio": "ingressgateway", + }, + }, + Spec: v1.ServiceSpec{ + Type: v1.ServiceTypeNodePort, + Selector: map[string]string{"istio": "ingressgateway"}, + ClusterIP: "10.96.41.133", + ExternalIPs: []string{"192.168.132.253"}, + }, + }, + }, + }, + } { + t.Run(tt.title, func(t *testing.T) { + kubeClient := fake.NewClientset() + istioClient := istiofake.NewSimpleClientset() + + for _, svc := range tt.services { + _, err := kubeClient.CoreV1().Services(svc.Namespace).Create(t.Context(), svc, metav1.CreateOptions{}) + require.NoError(t, err) + } + + for _, gw := range tt.gateways { + _, err := istioClient.NetworkingV1beta1().Gateways(gw.Namespace).Create(t.Context(), gw, metav1.CreateOptions{}) + require.NoError(t, err) + } + + src, err := NewIstioGatewaySource( + t.Context(), + kubeClient, + istioClient, + "", + "", + tt.fqdnTemplate, + !tt.combineFqdn, + false, + ) + require.NoError(t, err) + + endpoints, err := src.Endpoints(t.Context()) + require.NoError(t, err) + + validateEndpoints(t, endpoints, tt.expected) + }) + } +} diff --git a/source/istio_gateway_test.go b/source/istio_gateway_test.go index c17598cc0f..fb4e29d08a 100644 --- a/source/istio_gateway_test.go +++ b/source/istio_gateway_test.go @@ -506,7 +506,7 @@ func testEndpointsFromGatewayConfig(t *testing.T) { require.NoError(t, err) } else if hostnames, err := source.hostNamesFromGateway(gatewayCfg); err != nil { require.NoError(t, err) - } else if endpoints, err := source.endpointsFromGateway(context.Background(), hostnames, gatewayCfg); err != nil { + } else if endpoints, err := source.endpointsFromGateway(hostnames, gatewayCfg); err != nil { require.NoError(t, err) } else { validateEndpoints(t, endpoints, ti.expected) diff --git a/source/istio_virtualservice_fqdn_test.go b/source/istio_virtualservice_fqdn_test.go new file mode 100644 index 0000000000..bb265795e0 --- /dev/null +++ b/source/istio_virtualservice_fqdn_test.go @@ -0,0 +1,670 @@ +/* +Copyright 2025 The Kubernetes Authors. +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + http://www.apache.org/licenses/LICENSE-2.0 +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package source + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + istionetworking "istio.io/api/networking/v1beta1" + networkingv1beta1 "istio.io/client-go/pkg/apis/networking/v1beta1" + istiofake "istio.io/client-go/pkg/clientset/versioned/fake" + v1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/client-go/kubernetes/fake" + "sigs.k8s.io/external-dns/source/annotations" + + "sigs.k8s.io/external-dns/endpoint" +) + +func TestIstioVirtualServiceSourceNewSourceWithFqdn(t *testing.T) { + for _, tt := range []struct { + title string + annotationFilter string + fqdnTemplate string + expectError bool + }{ + { + title: "invalid template", + expectError: true, + fqdnTemplate: "{{.Name", + }, + { + title: "valid empty template", + expectError: false, + }, + { + title: "valid template", + expectError: false, + fqdnTemplate: "{{.Name}}-{{.Namespace}}.ext-dns.test.com", + }, + { + title: "valid template with multiple hosts", + expectError: false, + fqdnTemplate: "{{.Name}}-{{.Namespace}}.ext-dns.test.com, {{.Name}}-{{.Namespace}}.ext-dna.test.com", + }, + } { + t.Run(tt.title, func(t *testing.T) { + _, err := NewIstioVirtualServiceSource( + t.Context(), + fake.NewClientset(), + istiofake.NewSimpleClientset(), + "", + tt.annotationFilter, + tt.fqdnTemplate, + false, + false, + ) + + if tt.expectError { + assert.Error(t, err) + } else { + assert.NoError(t, err) + } + }) + } +} + +func TestIstioVirtualServiceSourceFqdnTemplatingExamples(t *testing.T) { + annotations.SetAnnotationPrefix("external-dns.alpha.kubernetes.io/") + for _, tt := range []struct { + title string + virtualServices []*networkingv1beta1.VirtualService + gateways []*networkingv1beta1.Gateway + services []*v1.Service + fqdnTemplate string + combineFqdn bool + expected []*endpoint.Endpoint + }{ + { + title: "simple templating with virtualservice name", + fqdnTemplate: "{{.Name}}.test.com", + expected: []*endpoint.Endpoint{ + {DNSName: "app.example.org", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{"1.2.3.4"}}, + {DNSName: "my-virtualservice.test.com", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{"1.2.3.4"}}, + }, + virtualServices: []*networkingv1beta1.VirtualService{ + { + ObjectMeta: metav1.ObjectMeta{ + Name: "my-virtualservice", + Namespace: "default", + }, + Spec: istionetworking.VirtualService{ + Hosts: []string{"app.example.org"}, + Gateways: []string{"my-gateway"}, + }, + }, + }, + gateways: []*networkingv1beta1.Gateway{ + { + ObjectMeta: metav1.ObjectMeta{ + Name: "my-gateway", + Namespace: "default", + }, + Spec: istionetworking.Gateway{ + Selector: map[string]string{"istio": "ingressgateway"}, + Servers: []*istionetworking.Server{ + {Hosts: []string{"*"}}, + }, + }, + }, + }, + services: []*v1.Service{ + { + ObjectMeta: metav1.ObjectMeta{ + Name: "istio-ingressgateway", + Namespace: "default", + Labels: map[string]string{"istio": "ingressgateway"}, + }, + Spec: v1.ServiceSpec{ + Type: v1.ServiceTypeLoadBalancer, + Selector: map[string]string{"istio": "ingressgateway"}, + }, + Status: v1.ServiceStatus{ + LoadBalancer: v1.LoadBalancerStatus{ + Ingress: []v1.LoadBalancerIngress{{IP: "1.2.3.4"}}, + }, + }, + }, + }, + }, + { + title: "templating with fqdn combine disabled", + fqdnTemplate: "{{.Name}}.test.com", + expected: []*endpoint.Endpoint{ + {DNSName: "app.example.org", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{"1.2.3.4"}}, + }, + combineFqdn: true, + virtualServices: []*networkingv1beta1.VirtualService{ + { + ObjectMeta: metav1.ObjectMeta{ + Name: "my-virtualservice", + Namespace: "default", + }, + Spec: istionetworking.VirtualService{ + Hosts: []string{"app.example.org"}, + Gateways: []string{"my-gateway"}, + }, + }, + }, + gateways: []*networkingv1beta1.Gateway{ + { + ObjectMeta: metav1.ObjectMeta{ + Name: "my-gateway", + Namespace: "default", + }, + Spec: istionetworking.Gateway{ + Selector: map[string]string{"istio": "ingressgateway"}, + Servers: []*istionetworking.Server{ + {Hosts: []string{"*"}}, + }, + }, + }, + }, + services: []*v1.Service{ + { + ObjectMeta: metav1.ObjectMeta{ + Name: "istio-ingressgateway", + Namespace: "default", + Labels: map[string]string{"istio": "ingressgateway"}, + }, + Spec: v1.ServiceSpec{ + Type: v1.ServiceTypeLoadBalancer, + Selector: map[string]string{"istio": "ingressgateway"}, + }, + Status: v1.ServiceStatus{ + LoadBalancer: v1.LoadBalancerStatus{ + Ingress: []v1.LoadBalancerIngress{{IP: "1.2.3.4"}}, + }, + }, + }, + }, + }, + { + title: "templating with namespace", + fqdnTemplate: "{{.Name}}.{{.Namespace}}.cluster.local", + expected: []*endpoint.Endpoint{ + {DNSName: "api.example.org", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{"5.6.7.8"}}, + {DNSName: "api-service.production.cluster.local", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{"5.6.7.8"}}, + {DNSName: "web.example.org", RecordType: endpoint.RecordTypeAAAA, Targets: endpoint.Targets{"::ffff:192.1.56.10"}}, + {DNSName: "web-service.staging.cluster.local", RecordType: endpoint.RecordTypeAAAA, Targets: endpoint.Targets{"::ffff:192.1.56.10"}}, + }, + virtualServices: []*networkingv1beta1.VirtualService{ + { + ObjectMeta: metav1.ObjectMeta{ + Name: "api-service", + Namespace: "production", + }, + Spec: istionetworking.VirtualService{ + Hosts: []string{"api.example.org"}, + Gateways: []string{"api-gateway"}, + }, + }, + { + ObjectMeta: metav1.ObjectMeta{ + Name: "web-service", + Namespace: "staging", + }, + Spec: istionetworking.VirtualService{ + Hosts: []string{"web.example.org"}, + Gateways: []string{"web-gateway"}, + }, + }, + }, + gateways: []*networkingv1beta1.Gateway{ + { + ObjectMeta: metav1.ObjectMeta{ + Name: "api-gateway", + Namespace: "production", + }, + Spec: istionetworking.Gateway{ + Selector: map[string]string{"istio": "ingressgateway"}, + Servers: []*istionetworking.Server{ + {Hosts: []string{"*"}}, + }, + }, + }, + { + ObjectMeta: metav1.ObjectMeta{ + Name: "web-gateway", + Namespace: "staging", + }, + Spec: istionetworking.Gateway{ + Selector: map[string]string{"istio": "ingressgateway-staging"}, + Servers: []*istionetworking.Server{ + {Hosts: []string{"*"}}, + }, + }, + }, + }, + services: []*v1.Service{ + { + ObjectMeta: metav1.ObjectMeta{ + Name: "istio-ingressgateway", + Namespace: "production", + Labels: map[string]string{"istio": "ingressgateway"}, + }, + Spec: v1.ServiceSpec{ + Type: v1.ServiceTypeLoadBalancer, + Selector: map[string]string{"istio": "ingressgateway"}, + }, + Status: v1.ServiceStatus{ + LoadBalancer: v1.LoadBalancerStatus{ + Ingress: []v1.LoadBalancerIngress{{IP: "5.6.7.8"}}, + }, + }, + }, + { + ObjectMeta: metav1.ObjectMeta{ + Name: "istio-ingressgateway-staging", + Namespace: "staging", + Labels: map[string]string{"istio": "ingressgateway-staging"}, + }, + Spec: v1.ServiceSpec{ + Type: v1.ServiceTypeLoadBalancer, + Selector: map[string]string{"istio": "ingressgateway-staging"}, + }, + Status: v1.ServiceStatus{ + LoadBalancer: v1.LoadBalancerStatus{ + Ingress: []v1.LoadBalancerIngress{{IP: "::ffff:192.1.56.10"}}, + }, + }, + }, + }, + }, + { + title: "templating with multiple fqdn templates", + fqdnTemplate: "{{.Name}}.example.com,{{.Name}}.example.org", + expected: []*endpoint.Endpoint{ + {DNSName: "multi-host.example.com", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{"10.0.0.1"}}, + {DNSName: "multi-host.example.org", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{"10.0.0.1"}}, + }, + virtualServices: []*networkingv1beta1.VirtualService{ + { + ObjectMeta: metav1.ObjectMeta{ + Name: "multi-host", + Namespace: "default", + }, + Spec: istionetworking.VirtualService{ + Gateways: []string{"my-gateway"}, + }, + }, + }, + gateways: []*networkingv1beta1.Gateway{ + { + ObjectMeta: metav1.ObjectMeta{ + Name: "my-gateway", + Namespace: "default", + }, + Spec: istionetworking.Gateway{ + Selector: map[string]string{"istio": "ingressgateway"}, + Servers: []*istionetworking.Server{ + {Hosts: []string{"*"}}, + }, + }, + }, + }, + services: []*v1.Service{ + { + ObjectMeta: metav1.ObjectMeta{ + Name: "istio-ingressgateway", + Namespace: "default", + Labels: map[string]string{"istio": "ingressgateway"}, + }, + Spec: v1.ServiceSpec{ + Type: v1.ServiceTypeLoadBalancer, + Selector: map[string]string{"istio": "ingressgateway"}, + }, + Status: v1.ServiceStatus{ + LoadBalancer: v1.LoadBalancerStatus{ + Ingress: []v1.LoadBalancerIngress{{IP: "10.0.0.1"}}, + }, + }, + }, + }, + }, + { + title: "combine FQDN annotation with template", + fqdnTemplate: "{{.Name}}.internal.example.com", + expected: []*endpoint.Endpoint{ + {DNSName: "app.example.org", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{"172.16.0.1"}}, + {DNSName: "combined-vs.internal.example.com", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{"172.16.0.1"}}, + }, + virtualServices: []*networkingv1beta1.VirtualService{ + { + ObjectMeta: metav1.ObjectMeta{ + Name: "combined-vs", + Namespace: "default", + }, + Spec: istionetworking.VirtualService{ + Hosts: []string{"app.example.org"}, + Gateways: []string{"my-gateway"}, + }, + }, + }, + gateways: []*networkingv1beta1.Gateway{ + { + ObjectMeta: metav1.ObjectMeta{ + Name: "my-gateway", + Namespace: "default", + }, + Spec: istionetworking.Gateway{ + Selector: map[string]string{"istio": "ingressgateway"}, + Servers: []*istionetworking.Server{ + {Hosts: []string{"*"}}, + }, + }, + }, + }, + services: []*v1.Service{ + { + ObjectMeta: metav1.ObjectMeta{ + Name: "istio-ingressgateway", + Namespace: "default", + Labels: map[string]string{"istio": "ingressgateway"}, + }, + Spec: v1.ServiceSpec{ + Type: v1.ServiceTypeLoadBalancer, + Selector: map[string]string{"istio": "ingressgateway"}, + }, + Status: v1.ServiceStatus{ + LoadBalancer: v1.LoadBalancerStatus{ + Ingress: []v1.LoadBalancerIngress{{IP: "172.16.0.1"}}, + }, + }, + }, + }, + }, + { + title: "complex templating with labels and hosts", + fqdnTemplate: "{{ if .Labels.env }}{{.Name}}.{{.Labels.env}}.ex{{ end }}", + expected: []*endpoint.Endpoint{ + {DNSName: "labeled-vs.dev.ex", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{"172.16.0.1"}}, + }, + virtualServices: []*networkingv1beta1.VirtualService{ + { + ObjectMeta: metav1.ObjectMeta{ + Name: "labeled-vs", + Namespace: "default", + Labels: map[string]string{ + "env": "dev", + }, + }, + Spec: istionetworking.VirtualService{ + Gateways: []string{"my-gateway"}, + }, + }, + { + ObjectMeta: metav1.ObjectMeta{ + Name: "no-labels", + Namespace: "default", + }, + Spec: istionetworking.VirtualService{ + Gateways: []string{"my-gateway"}, + }, + }, + }, + gateways: []*networkingv1beta1.Gateway{ + { + ObjectMeta: metav1.ObjectMeta{ + Name: "my-gateway", + Namespace: "default", + }, + Spec: istionetworking.Gateway{ + Selector: map[string]string{"istio": "ingressgateway"}, + Servers: []*istionetworking.Server{ + {Hosts: []string{"*"}}, + }, + }, + }, + }, + services: []*v1.Service{ + { + ObjectMeta: metav1.ObjectMeta{ + Name: "istio-ingressgateway", + Namespace: "default", + }, + Spec: v1.ServiceSpec{ + Type: v1.ServiceTypeLoadBalancer, + Selector: map[string]string{"istio": "ingressgateway"}, + }, + Status: v1.ServiceStatus{ + LoadBalancer: v1.LoadBalancerStatus{ + Ingress: []v1.LoadBalancerIngress{{IP: "172.16.0.1"}}, + }, + }, + }, + }, + }, + { + title: "templating with cross-namespace gateway reference", + fqdnTemplate: "{{.Name}}.{{.Namespace}}.svc.cluster.local", + expected: []*endpoint.Endpoint{ + {DNSName: "cross-ns.example.org", RecordType: endpoint.RecordTypeCNAME, Targets: endpoint.Targets{"lb.example.com"}}, + {DNSName: "cross-ns-vs.app-namespace.svc.cluster.local", RecordType: endpoint.RecordTypeCNAME, Targets: endpoint.Targets{"lb.example.com"}}, + }, + virtualServices: []*networkingv1beta1.VirtualService{ + { + ObjectMeta: metav1.ObjectMeta{ + Name: "cross-ns-vs", + Namespace: "app-namespace", + }, + Spec: istionetworking.VirtualService{ + Hosts: []string{"cross-ns.example.org"}, + Gateways: []string{"istio-system/shared-gateway"}, + }, + }, + }, + gateways: []*networkingv1beta1.Gateway{ + { + ObjectMeta: metav1.ObjectMeta{ + Name: "shared-gateway", + Namespace: "istio-system", + }, + Spec: istionetworking.Gateway{ + Selector: map[string]string{"istio": "ingressgateway"}, + Servers: []*istionetworking.Server{ + {Hosts: []string{"*"}}, + }, + }, + }, + }, + services: []*v1.Service{ + { + ObjectMeta: metav1.ObjectMeta{ + Name: "istio-ingressgateway", + Namespace: "istio-system", + Labels: map[string]string{"istio": "ingressgateway"}, + }, + Spec: v1.ServiceSpec{ + Type: v1.ServiceTypeLoadBalancer, + Selector: map[string]string{"istio": "ingressgateway"}, + }, + Status: v1.ServiceStatus{ + LoadBalancer: v1.LoadBalancerStatus{ + Ingress: []v1.LoadBalancerIngress{{Hostname: "lb.example.com"}}, + }, + }, + }, + }, + }, + { + title: "virtualservice with multiple hosts in spec", + fqdnTemplate: "{{.Name}}.internal.local", + expected: []*endpoint.Endpoint{ + {DNSName: "app1.example.org", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{"192.168.1.100"}}, + {DNSName: "app2.example.org", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{"192.168.1.100"}}, + {DNSName: "app3.example.org", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{"192.168.1.100"}}, + {DNSName: "multi-host-vs.internal.local", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{"192.168.1.100"}}, + }, + virtualServices: []*networkingv1beta1.VirtualService{ + { + ObjectMeta: metav1.ObjectMeta{ + Name: "multi-host-vs", + Namespace: "default", + }, + Spec: istionetworking.VirtualService{ + Hosts: []string{"app1.example.org", "app2.example.org", "app3.example.org"}, + Gateways: []string{"my-gateway"}, + }, + }, + }, + gateways: []*networkingv1beta1.Gateway{ + { + ObjectMeta: metav1.ObjectMeta{ + Name: "my-gateway", + Namespace: "default", + }, + Spec: istionetworking.Gateway{ + Selector: map[string]string{"istio": "ingressgateway"}, + Servers: []*istionetworking.Server{ + {Hosts: []string{"*"}}, + }, + }, + }, + }, + services: []*v1.Service{ + { + ObjectMeta: metav1.ObjectMeta{ + Name: "istio-ingressgateway", + Namespace: "default", + Labels: map[string]string{"istio": "ingressgateway"}, + }, + Spec: v1.ServiceSpec{ + Type: v1.ServiceTypeLoadBalancer, + Selector: map[string]string{"istio": "ingressgateway"}, + }, + Status: v1.ServiceStatus{ + LoadBalancer: v1.LoadBalancerStatus{ + Ingress: []v1.LoadBalancerIngress{{IP: "192.168.1.100"}}, + }, + }, + }, + }, + }, + { + title: "virtualservice with no matching gateway (no endpoints from spec)", + fqdnTemplate: "{{.Name}}.fallback.local", + expected: []*endpoint.Endpoint{ + {DNSName: "orphan.example.org", RecordType: endpoint.RecordTypeCNAME, Targets: endpoint.Targets{"fallback.local"}}, + }, + virtualServices: []*networkingv1beta1.VirtualService{ + { + ObjectMeta: metav1.ObjectMeta{ + Name: "orphan-vs", + Namespace: "default", + Annotations: map[string]string{ + annotations.TargetKey: "fallback.local", + }, + }, + Spec: istionetworking.VirtualService{ + Hosts: []string{"orphan.example.org"}, + Gateways: []string{"non-existent-gateway"}, + }, + }, + }, + }, + { + title: "templating with annotations expansion", + fqdnTemplate: `{{ index .ObjectMeta.Annotations "dns.company.com/subdomain" }}.company.local`, + expected: []*endpoint.Endpoint{ + {DNSName: "api-v2.company.local", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{"192.168.1.100"}}, + }, + virtualServices: []*networkingv1beta1.VirtualService{ + { + ObjectMeta: metav1.ObjectMeta{ + Name: "annotated-vs", + Namespace: "default", + Annotations: map[string]string{ + "dns.company.com/subdomain": "api-v2", + annotations.TargetKey: "10.20.30.40", + }, + }, + Spec: istionetworking.VirtualService{ + Gateways: []string{"my-gateway"}, + }, + }, + }, + gateways: []*networkingv1beta1.Gateway{ + { + ObjectMeta: metav1.ObjectMeta{ + Name: "my-gateway", + Namespace: "default", + }, + Spec: istionetworking.Gateway{ + Selector: map[string]string{"istio": "ingressgateway"}, + Servers: []*istionetworking.Server{ + {Hosts: []string{"*"}}, + }, + }, + }, + }, + services: []*v1.Service{ + { + ObjectMeta: metav1.ObjectMeta{ + Name: "istio", + Namespace: "default", + }, + Spec: v1.ServiceSpec{ + Type: v1.ServiceTypeLoadBalancer, + Selector: map[string]string{"istio": "ingressgateway"}, + }, + Status: v1.ServiceStatus{ + LoadBalancer: v1.LoadBalancerStatus{ + Ingress: []v1.LoadBalancerIngress{{IP: "192.168.1.100"}}, + }, + }, + }, + }, + }, + } { + t.Run(tt.title, func(t *testing.T) { + kubeClient := fake.NewClientset() + istioClient := istiofake.NewSimpleClientset() + + for _, svc := range tt.services { + _, err := kubeClient.CoreV1().Services(svc.Namespace).Create(t.Context(), svc, metav1.CreateOptions{}) + require.NoError(t, err) + } + + for _, gw := range tt.gateways { + _, err := istioClient.NetworkingV1beta1().Gateways(gw.Namespace).Create(t.Context(), gw, metav1.CreateOptions{}) + require.NoError(t, err) + } + + for _, vs := range tt.virtualServices { + _, err := istioClient.NetworkingV1beta1().VirtualServices(vs.Namespace).Create(t.Context(), vs, metav1.CreateOptions{}) + require.NoError(t, err) + } + + src, err := NewIstioVirtualServiceSource( + t.Context(), + kubeClient, + istioClient, + "", + "", + tt.fqdnTemplate, + !tt.combineFqdn, + false, + ) + require.NoError(t, err) + + endpoints, err := src.Endpoints(t.Context()) + require.NoError(t, err) + + validateEndpoints(t, endpoints, tt.expected) + }) + } +} diff --git a/source/openshift_route_fqdn_test.go b/source/openshift_route_fqdn_test.go new file mode 100644 index 0000000000..08c02462d0 --- /dev/null +++ b/source/openshift_route_fqdn_test.go @@ -0,0 +1,357 @@ +/* +Copyright 2026 The Kubernetes Authors. +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + http://www.apache.org/licenses/LICENSE-2.0 +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package source + +import ( + "context" + "testing" + + routev1 "github.com/openshift/api/route/v1" + "github.com/openshift/client-go/route/clientset/versioned/fake" + "github.com/stretchr/testify/require" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/labels" + "k8s.io/apimachinery/pkg/util/intstr" + "sigs.k8s.io/external-dns/endpoint" + "sigs.k8s.io/external-dns/source/annotations" +) + +func TestOpenShiftFqdnTemplatingExamples(t *testing.T) { + for _, tt := range []struct { + title string + ocpRoute []*routev1.Route + fqdnTemplate string + combineFqdn bool + expected []*endpoint.Endpoint + }{ + { + title: "simple templating", + fqdnTemplate: "{{.Name}}.tld.com", + expected: []*endpoint.Endpoint{ + {DNSName: "example.org", RecordType: endpoint.RecordTypeCNAME, Targets: endpoint.Targets{"router-default.example.com"}}, + {DNSName: "my-gateway.tld.com", RecordType: endpoint.RecordTypeCNAME, Targets: endpoint.Targets{"router-default.example.com"}}, + }, + ocpRoute: []*routev1.Route{ + { + ObjectMeta: metav1.ObjectMeta{ + Name: "my-gateway", + Namespace: "default", + }, + Spec: routev1.RouteSpec{ + Host: "example.org", + To: routev1.RouteTargetReference{ + Kind: "Service", + Name: "my-service", + }, + TLS: &routev1.TLSConfig{}, + }, + Status: routev1.RouteStatus{ + Ingress: []routev1.RouteIngress{ + { + Host: "example.org", + RouterCanonicalHostname: "router-default.example.com", + Conditions: []routev1.RouteIngressCondition{ + { + Type: routev1.RouteAdmitted, + Status: corev1.ConditionTrue, + }, + }, + }, + }, + }, + }, + }, + }, + { + title: "templating with fqdn combine disabled", + fqdnTemplate: "{{.Name}}.tld.com", + expected: []*endpoint.Endpoint{ + {DNSName: "example.org", RecordType: endpoint.RecordTypeCNAME, Targets: endpoint.Targets{"router-default.example.com"}}, + }, + combineFqdn: true, + ocpRoute: []*routev1.Route{ + { + ObjectMeta: metav1.ObjectMeta{ + Name: "my-gateway", + Namespace: "default", + }, + Spec: routev1.RouteSpec{}, + Status: routev1.RouteStatus{ + Ingress: []routev1.RouteIngress{ + { + Host: "example.org", + RouterCanonicalHostname: "router-default.example.com", + Conditions: []routev1.RouteIngressCondition{ + { + Type: routev1.RouteAdmitted, + Status: corev1.ConditionTrue, + }, + }, + }, + }, + }, + }, + }, + }, + { + title: "templating with namespace", + fqdnTemplate: "{{.Name}}.{{.Namespace}}.tld.com", + expected: []*endpoint.Endpoint{ + {DNSName: "my-gateway.kube-system.tld.com", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{"10.1.1.0"}}, + }, + combineFqdn: true, + ocpRoute: []*routev1.Route{ + { + ObjectMeta: metav1.ObjectMeta{ + Name: "my-gateway", + Namespace: "kube-system", + Annotations: map[string]string{ + annotations.TargetKey: "10.1.1.0", + }, + }, + }, + }, + }, + { + title: "templating with complex fqdn template", + fqdnTemplate: "{{ .Name }}.{{ .Namespace }}.tld.com,{{ if .Labels.env }}{{ .Labels.env }}.private{{ end }}", + expected: []*endpoint.Endpoint{ + {DNSName: "no-labels-route-3.default.tld.com", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{"10.1.1.3"}}, + {DNSName: "route-2.default.tld.com", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{"10.1.1.3"}}, + {DNSName: "dev.private", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{"10.1.1.3"}}, + {DNSName: "route-1.kube-system.tld.com", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{"10.1.1.0"}}, + {DNSName: "prod.private", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{"10.1.1.0"}}, + }, + combineFqdn: true, + ocpRoute: []*routev1.Route{ + { + ObjectMeta: metav1.ObjectMeta{ + Name: "route-1", + Namespace: "kube-system", + Labels: map[string]string{ + "env": "prod", + }, + Annotations: map[string]string{ + "env": "prod", + annotations.TargetKey: "10.1.1.0", + }, + }, + }, + { + ObjectMeta: metav1.ObjectMeta{ + Name: "route-2", + Namespace: "default", + Labels: map[string]string{ + "env": "dev", + }, + Annotations: map[string]string{ + annotations.TargetKey: "10.1.1.3", + }, + }, + }, + { + ObjectMeta: metav1.ObjectMeta{ + Name: "no-labels-route-3", + Namespace: "default", + Annotations: map[string]string{ + annotations.TargetKey: "10.1.1.3", + }, + }, + }, + }, + }, + { + title: "template that skips when field is missing", + fqdnTemplate: "{{ if and .Spec.Port .Spec.Port.TargetPort }}{{ .Name }}.{{ .Spec.Port.TargetPort }}.tld.com{{ end }}", + expected: []*endpoint.Endpoint{ + {DNSName: "route-1.80.tld.com", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{"10.1.1.0"}}, + }, + ocpRoute: []*routev1.Route{ + { + ObjectMeta: metav1.ObjectMeta{ + Name: "route-1", + Namespace: "kube-system", + Annotations: map[string]string{ + annotations.TargetKey: "10.1.1.0", + }, + }, + Spec: routev1.RouteSpec{ + Port: &routev1.RoutePort{ + TargetPort: intstr.FromString("80"), + }, + }, + Status: routev1.RouteStatus{}, + }, + { + ObjectMeta: metav1.ObjectMeta{ + Name: "route-2", + Namespace: "default", + Annotations: map[string]string{ + annotations.TargetKey: "10.1.1.3", + }, + }, + }, + }, + }, + { + title: "get canonical hostnames for admitted routes", + fqdnTemplate: "{{ $name := .Name }}{{ range $ingress := .Status.Ingress }}{{ range $ingress.Conditions }}{{ if and (eq .Type \"Admitted\") (eq .Status \"True\") }}{{ $ingress.Host }},{{ end }}{{ end }}{{ end }}", + expected: []*endpoint.Endpoint{ + {DNSName: "cluster.example.org", RecordType: endpoint.RecordTypeCNAME, Targets: endpoint.Targets{"router-dmz.apps.dmz.example.com"}}, + {DNSName: "cluster.example.org", RecordType: endpoint.RecordTypeCNAME, Targets: endpoint.Targets{"router-dmz.apps.dmz.example.com"}}, + {DNSName: "apps.example.org", RecordType: endpoint.RecordTypeCNAME, Targets: endpoint.Targets{"router-dmz.apps.dmz.example.com"}}, + }, + ocpRoute: []*routev1.Route{ + { + ObjectMeta: metav1.ObjectMeta{ + Name: "my-route", + Namespace: "kube-system", + Annotations: map[string]string{}, + }, + Status: routev1.RouteStatus{ + Ingress: []routev1.RouteIngress{ + { + Host: "cluster.example.org", + RouterCanonicalHostname: "router-dmz.apps.dmz.example.com", + Conditions: []routev1.RouteIngressCondition{ + { + Type: routev1.RouteAdmitted, + Status: corev1.ConditionTrue, + }, + }, + }, + { + Host: "apps.example.org", + RouterCanonicalHostname: "router-internal.apps.internal.example.com", + Conditions: []routev1.RouteIngressCondition{ + { + Type: routev1.RouteAdmitted, + Status: corev1.ConditionTrue, + }, + }, + }, + { + Host: "wrong.example.org", + RouterCanonicalHostname: "router-default.apps.cluster.example.com", + Conditions: []routev1.RouteIngressCondition{ + { + Type: routev1.RouteAdmitted, + Status: corev1.ConditionFalse, + }, + }, + }, + }, + }, + }, + { + ObjectMeta: metav1.ObjectMeta{ + Name: "route-2", + Namespace: "default", + Annotations: map[string]string{ + annotations.TargetKey: "10.1.1.3", + }, + }, + }, + }, + }, + { + title: "get canonical hostnames for admitted routes without prefix", + fqdnTemplate: "{{ $name := .Name }}{{ range $ingress := .Status.Ingress }}{{ range $ingress.Conditions }}{{ if and (eq .Type \"Admitted\") (eq .Status \"True\") }}{{ with $ingress.RouterCanonicalHostname }}{{ $name }}.{{ trimPrefix . \"router-\" }},{{ end }}{{ end }}{{ end }}{{ end }}", + expected: []*endpoint.Endpoint{ + {DNSName: "cluster.example.org", RecordType: endpoint.RecordTypeCNAME, Targets: endpoint.Targets{"router-dmz.apps.dmz.example.com"}}, + {DNSName: "my-route.dmz.apps.dmz.example.com", RecordType: endpoint.RecordTypeCNAME, Targets: endpoint.Targets{"router-dmz.apps.dmz.example.com"}}, + {DNSName: "my-route.internal.apps.internal.example.com", RecordType: endpoint.RecordTypeCNAME, Targets: endpoint.Targets{"router-dmz.apps.dmz.example.com"}}, + }, + ocpRoute: []*routev1.Route{ + { + ObjectMeta: metav1.ObjectMeta{ + Name: "my-route", + Namespace: "kube-system", + Annotations: map[string]string{}, + }, + Status: routev1.RouteStatus{ + Ingress: []routev1.RouteIngress{ + { + Host: "cluster.example.org", + RouterCanonicalHostname: "router-dmz.apps.dmz.example.com", + Conditions: []routev1.RouteIngressCondition{ + { + Type: routev1.RouteAdmitted, + Status: corev1.ConditionTrue, + }, + }, + }, + { + Host: "apps.example.org", + RouterCanonicalHostname: "router-internal.apps.internal.example.com", + Conditions: []routev1.RouteIngressCondition{ + { + Type: routev1.RouteAdmitted, + Status: corev1.ConditionTrue, + }, + }, + }, + { + Host: "wrong.example.org", + RouterCanonicalHostname: "router-default.apps.cluster.example.com", + Conditions: []routev1.RouteIngressCondition{ + { + Type: routev1.RouteAdmitted, + Status: corev1.ConditionFalse, + }, + }, + }, + }, + }, + }, + { + ObjectMeta: metav1.ObjectMeta{ + Name: "route-2", + Namespace: "default", + Annotations: map[string]string{ + annotations.TargetKey: "10.1.1.3", + }, + }, + }, + }, + }, + } { + t.Run(tt.title, func(t *testing.T) { + kubeClient := fake.NewClientset() + for _, ocp := range tt.ocpRoute { + _, err := kubeClient.RouteV1().Routes(ocp.Namespace).Create(context.Background(), ocp, metav1.CreateOptions{}) + require.NoError(t, err) + } + + src, err := NewOcpRouteSource( + t.Context(), + kubeClient, + "", + "", + tt.fqdnTemplate, + !tt.combineFqdn, + false, + labels.Everything(), + "", + ) + require.NoError(t, err) + + endpoints, err := src.Endpoints(t.Context()) + require.NoError(t, err) + + validateEndpoints(t, endpoints, tt.expected) + }) + } +}