diff --git a/pkg/apis/externaldns/types.go b/pkg/apis/externaldns/types.go index 8655dc480..6713d3f13 100644 --- a/pkg/apis/externaldns/types.go +++ b/pkg/apis/externaldns/types.go @@ -356,7 +356,7 @@ func (cfg *Config) ParseFlags(args []string) error { app.Flag("combine-fqdn-annotation", "Combine FQDN template and Annotations instead of overwriting").BoolVar(&cfg.CombineFQDNAndAnnotation) app.Flag("ignore-hostname-annotation", "Ignore hostname annotation when generating DNS names, valid only when using fqdn-template is set (optional, default: false)").BoolVar(&cfg.IgnoreHostnameAnnotation) app.Flag("ignore-ingress-tls-spec", "Ignore tls spec section in ingresses resources, applicable only for ingress sources (optional, default: false)").BoolVar(&cfg.IgnoreIngressTLSSpec) - app.Flag("compatibility", "Process annotation semantics from legacy implementations (optional, options: mate, molecule)").Default(defaultConfig.Compatibility).EnumVar(&cfg.Compatibility, "", "mate", "molecule") + app.Flag("compatibility", "Process annotation semantics from legacy implementations (optional, options: mate, molecule)").Default(defaultConfig.Compatibility).EnumVar(&cfg.Compatibility, "", "mate", "molecule", "dns-controller") app.Flag("publish-internal-services", "Allow external-dns to publish DNS records for ClusterIP services (optional)").BoolVar(&cfg.PublishInternal) app.Flag("publish-host-ip", "Allow external-dns to publish host-ip for headless services (optional)").BoolVar(&cfg.PublishHostIP) app.Flag("always-publish-not-ready-addresses", "Always publish also not ready addresses for headless services (optional)").BoolVar(&cfg.AlwaysPublishNotReadyAddresses) diff --git a/source/compatibility.go b/source/compatibility.go index aa86c1c3f..ff7f7d8f5 100644 --- a/source/compatibility.go +++ b/source/compatibility.go @@ -20,6 +20,7 @@ import ( "strings" v1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/labels" "sigs.k8s.io/external-dns/endpoint" ) @@ -27,19 +28,25 @@ import ( const ( mateAnnotationKey = "zalando.org/dnsname" moleculeAnnotationKey = "domainName" + // dnsControllerHostnameAnnotationKey is the annotation used for defining the desired hostname when DNS controller compatibility mode + dnsControllerHostnameAnnotationKey = "dns.alpha.kubernetes.io/external" + // dnsControllerInternalHostnameAnnotationKey is the annotation used for defining the desired hostname when DNS controller compatibility mode + dnsControllerInternalHostnameAnnotationKey = "dns.alpha.kubernetes.io/internal" ) // legacyEndpointsFromService tries to retrieve Endpoints from Services // annotated with legacy annotations. -func legacyEndpointsFromService(svc *v1.Service, compatibility string) []*endpoint.Endpoint { - switch compatibility { +func legacyEndpointsFromService(svc *v1.Service, sc *serviceSource) ([]*endpoint.Endpoint, error) { + switch sc.compatibility { case "mate": - return legacyEndpointsFromMateService(svc) + return legacyEndpointsFromMateService(svc), nil case "molecule": - return legacyEndpointsFromMoleculeService(svc) + return legacyEndpointsFromMoleculeService(svc), nil + case "dns-controller": + return legacyEndpointsFromDNSControllerService(svc, sc) } - return []*endpoint.Endpoint{} + return []*endpoint.Endpoint{}, nil } // legacyEndpointsFromMateService tries to retrieve Endpoints from Services @@ -98,3 +105,102 @@ func legacyEndpointsFromMoleculeService(svc *v1.Service) []*endpoint.Endpoint { return endpoints } + +// legacyEndpointsFromDNSControllerService tries to retrieve Endpoints from Services +// annotated with DNS Controller's annotation semantics*. +func legacyEndpointsFromDNSControllerService(svc *v1.Service, sc *serviceSource) ([]*endpoint.Endpoint, error) { + switch svc.Spec.Type { + case v1.ServiceTypeNodePort: + return legacyEndpointsFromDNSControllerNodePortService(svc, sc) + case v1.ServiceTypeLoadBalancer: + return legacyEndpointsFromDNSControllerLoadBalancerService(svc), nil + } + + return []*endpoint.Endpoint{}, nil +} + +// legacyEndpointsFromDNSControllerNodePortService implements DNS controller's semantics for NodePort services. +// It will use node role label to check if the node has the "node" role. This means control plane nodes and other +// roles will not be used as targets. +func legacyEndpointsFromDNSControllerNodePortService(svc *v1.Service, sc *serviceSource) ([]*endpoint.Endpoint, error) { + var endpoints []*endpoint.Endpoint + + // Get the desired hostname of the service from the annotations. + hostnameAnnotation, isExternal := svc.Annotations[dnsControllerHostnameAnnotationKey] + internalHostnameAnnotation, isInternal := svc.Annotations[dnsControllerInternalHostnameAnnotationKey] + + if !isExternal && !isInternal { + return nil, nil + } + + // if both annotations are set, we just return empty, mimicking what dns-controller does + if isInternal && isExternal { + return nil, nil + } + + nodes, err := sc.nodeInformer.Lister().List(labels.Everything()) + if err != nil { + return nil, err + } + + var hostnameList []string + if isExternal { + hostnameList = strings.Split(strings.Replace(hostnameAnnotation, " ", "", -1), ",") + } else { + hostnameList = strings.Split(strings.Replace(internalHostnameAnnotation, " ", "", -1), ",") + } + + for _, hostname := range hostnameList { + for _, node := range nodes { + _, isNode := node.Labels["node-role.kubernetes.io/node"] + if !isNode { + continue + } + for _, address := range node.Status.Addresses { + if address.Type == v1.NodeExternalIP && isExternal { + endpoints = append(endpoints, endpoint.NewEndpoint(hostname, endpoint.RecordTypeA, address.Address)) + } + if address.Type == v1.NodeInternalIP && isInternal { + endpoints = append(endpoints, endpoint.NewEndpoint(hostname, endpoint.RecordTypeA, address.Address)) + } + } + } + } + return endpoints, nil +} + +// legacyEndpointsFromDNSControllerLoadBalancerService will respect both annotations, but +// will not care if the load balancer actually is internal or not. +func legacyEndpointsFromDNSControllerLoadBalancerService(svc *v1.Service) []*endpoint.Endpoint { + var endpoints []*endpoint.Endpoint + + // Get the desired hostname of the service from the annotations. + hostnameAnnotation, hasExternal := svc.Annotations[dnsControllerHostnameAnnotationKey] + internalHostnameAnnotation, hasInternal := svc.Annotations[dnsControllerInternalHostnameAnnotationKey] + + if !hasExternal && !hasInternal { + return nil + } + + var hostnameList []string + if hasExternal { + hostnameList = append(hostnameList, strings.Split(strings.Replace(hostnameAnnotation, " ", "", -1), ",")...) + } + if hasInternal { + hostnameList = append(hostnameList, strings.Split(strings.Replace(internalHostnameAnnotation, " ", "", -1), ",")...) + } + + for _, hostname := range hostnameList { + // Create a corresponding endpoint for each configured external entrypoint. + for _, lb := range svc.Status.LoadBalancer.Ingress { + if lb.IP != "" { + endpoints = append(endpoints, endpoint.NewEndpoint(hostname, endpoint.RecordTypeA, lb.IP)) + } + if lb.Hostname != "" { + endpoints = append(endpoints, endpoint.NewEndpoint(hostname, endpoint.RecordTypeCNAME, lb.Hostname)) + } + } + } + + return endpoints +} diff --git a/source/service.go b/source/service.go index aeb9c8661..a6d258311 100644 --- a/source/service.go +++ b/source/service.go @@ -187,7 +187,10 @@ func (sc *serviceSource) Endpoints(ctx context.Context) ([]*endpoint.Endpoint, e // process legacy annotations if no endpoints were returned and compatibility mode is enabled. if len(svcEndpoints) == 0 && sc.compatibility != "" { - svcEndpoints = legacyEndpointsFromService(svc, sc.compatibility) + svcEndpoints, err = legacyEndpointsFromService(svc, sc) + if err != nil { + return nil, err + } } // apply template if none of the above is found diff --git a/source/service_test.go b/source/service_test.go index d69b88a73..2c004c543 100644 --- a/source/service_test.go +++ b/source/service_test.go @@ -868,6 +868,59 @@ func testServiceSourceEndpoints(t *testing.T) { }, false, }, + { + "load balancer services annotated with DNS Controller annotations return an endpoint with A and CNAME targets in compatibility mode", + "", + "", + "testing", + "foo", + v1.ServiceTypeLoadBalancer, + "dns-controller", + "", + false, + false, + map[string]string{}, + map[string]string{ + dnsControllerInternalHostnameAnnotationKey: "internal.foo.example.org", + }, + "", + []string{}, + []string{"1.2.3.4", "lb.example.com"}, + []string{}, + []*endpoint.Endpoint{ + {DNSName: "internal.foo.example.org", Targets: endpoint.Targets{"1.2.3.4"}}, + {DNSName: "internal.foo.example.org", Targets: endpoint.Targets{"lb.example.com"}}, + }, + false, + }, { + "load balancer services annotated with DNS Controller annotations return an endpoint with both annotations in compatibility mode", + "", + "", + "testing", + "foo", + v1.ServiceTypeLoadBalancer, + "dns-controller", + "", + false, + false, + map[string]string{}, + map[string]string{ + dnsControllerInternalHostnameAnnotationKey: "internal.foo.example.org., internal.bar.example.org", + dnsControllerHostnameAnnotationKey: "foo.example.org., bar.example.org", + }, + "", + []string{}, + []string{"1.2.3.4"}, + []string{}, + []*endpoint.Endpoint{ + {DNSName: "foo.example.org", Targets: endpoint.Targets{"1.2.3.4"}}, + {DNSName: "bar.example.org", Targets: endpoint.Targets{"1.2.3.4"}}, + {DNSName: "internal.foo.example.org", Targets: endpoint.Targets{"1.2.3.4"}}, + {DNSName: "internal.bar.example.org", Targets: endpoint.Targets{"1.2.3.4"}}, + }, + false, + }, + { "not annotated services with set fqdnTemplate return an endpoint with target IP", "", @@ -1603,7 +1656,7 @@ func TestClusterIpServices(t *testing.T) { } // testNodePortServices tests that various services generate the correct endpoints. -func TestNodePortServices(t *testing.T) { +func TestServiceSourceNodePortServices(t *testing.T) { for _, tc := range []struct { title string targetNamespace string @@ -1988,6 +2041,212 @@ func TestNodePortServices(t *testing.T) { []int{}, []v1.PodPhase{}, }, + { + "node port services annotated DNS Controller annotations return an endpoint where all targets has the node role", + "", + "", + "testing", + "foo", + v1.ServiceTypeNodePort, + v1.ServiceExternalTrafficPolicyTypeCluster, + "dns-controller", + "", + false, + map[string]string{}, + map[string]string{ + dnsControllerInternalHostnameAnnotationKey: "internal.foo.example.org., internal.bar.example.org", + }, + nil, + []*endpoint.Endpoint{ + {DNSName: "internal.foo.example.org", Targets: endpoint.Targets{"10.0.1.1"}}, + {DNSName: "internal.bar.example.org", Targets: endpoint.Targets{"10.0.1.1"}}, + }, + false, + []*v1.Node{{ + ObjectMeta: metav1.ObjectMeta{ + Name: "node1", + Labels: map[string]string{ + "node-role.kubernetes.io/node": "", + }, + }, + Status: v1.NodeStatus{ + Addresses: []v1.NodeAddress{ + {Type: v1.NodeExternalIP, Address: "54.10.11.1"}, + {Type: v1.NodeInternalIP, Address: "10.0.1.1"}, + }, + }, + }, { + ObjectMeta: metav1.ObjectMeta{ + Name: "node2", + Labels: map[string]string{ + "node-role.kubernetes.io/control-plane": "", + }, + }, + Status: v1.NodeStatus{ + Addresses: []v1.NodeAddress{ + {Type: v1.NodeExternalIP, Address: "54.10.11.2"}, + {Type: v1.NodeInternalIP, Address: "10.0.1.2"}, + }, + }, + }}, + []string{}, + []int{}, + []v1.PodPhase{}, + }, + { + "node port services annotated with internal DNS Controller annotations return an endpoint in compatibility mode", + "", + "", + "testing", + "foo", + v1.ServiceTypeNodePort, + v1.ServiceExternalTrafficPolicyTypeCluster, + "dns-controller", + "", + false, + map[string]string{}, + map[string]string{ + dnsControllerInternalHostnameAnnotationKey: "internal.foo.example.org., internal.bar.example.org", + }, + nil, + []*endpoint.Endpoint{ + {DNSName: "internal.foo.example.org", Targets: endpoint.Targets{"10.0.1.1", "10.0.1.2"}}, + {DNSName: "internal.bar.example.org", Targets: endpoint.Targets{"10.0.1.1", "10.0.1.2"}}, + }, + false, + []*v1.Node{{ + ObjectMeta: metav1.ObjectMeta{ + Name: "node1", + Labels: map[string]string{ + "node-role.kubernetes.io/node": "", + }, + }, + Status: v1.NodeStatus{ + Addresses: []v1.NodeAddress{ + {Type: v1.NodeExternalIP, Address: "54.10.11.1"}, + {Type: v1.NodeInternalIP, Address: "10.0.1.1"}, + }, + }, + }, { + ObjectMeta: metav1.ObjectMeta{ + Name: "node2", + Labels: map[string]string{ + "node-role.kubernetes.io/node": "", + }, + }, + Status: v1.NodeStatus{ + Addresses: []v1.NodeAddress{ + {Type: v1.NodeExternalIP, Address: "54.10.11.2"}, + {Type: v1.NodeInternalIP, Address: "10.0.1.2"}, + }, + }, + }}, + []string{}, + []int{}, + []v1.PodPhase{}, + }, + { + "node port services annotated with external DNS Controller annotations return an endpoint in compatibility mode", + "", + "", + "testing", + "foo", + v1.ServiceTypeNodePort, + v1.ServiceExternalTrafficPolicyTypeCluster, + "dns-controller", + "", + false, + map[string]string{}, + map[string]string{ + dnsControllerHostnameAnnotationKey: "foo.example.org., bar.example.org", + }, + nil, + []*endpoint.Endpoint{ + {DNSName: "foo.example.org", Targets: endpoint.Targets{"54.10.11.1", "54.10.11.2"}}, + {DNSName: "bar.example.org", Targets: endpoint.Targets{"54.10.11.1", "54.10.11.2"}}, + }, + false, + []*v1.Node{{ + ObjectMeta: metav1.ObjectMeta{ + Name: "node1", + Labels: map[string]string{ + "node-role.kubernetes.io/node": "", + }, + }, + Status: v1.NodeStatus{ + Addresses: []v1.NodeAddress{ + {Type: v1.NodeExternalIP, Address: "54.10.11.1"}, + {Type: v1.NodeInternalIP, Address: "10.0.1.1"}, + }, + }, + }, { + ObjectMeta: metav1.ObjectMeta{ + Name: "node2", + Labels: map[string]string{ + "node-role.kubernetes.io/node": "", + }, + }, + Status: v1.NodeStatus{ + Addresses: []v1.NodeAddress{ + {Type: v1.NodeExternalIP, Address: "54.10.11.2"}, + {Type: v1.NodeInternalIP, Address: "10.0.1.2"}, + }, + }, + }}, + []string{}, + []int{}, + []v1.PodPhase{}, + }, + { + "node port services annotated with both dns-controller annotations return an empty set of addons", + "", + "", + "testing", + "foo", + v1.ServiceTypeNodePort, + v1.ServiceExternalTrafficPolicyTypeCluster, + "dns-controller", + "", + false, + map[string]string{}, + map[string]string{ + dnsControllerInternalHostnameAnnotationKey: "internal.foo.example.org., internal.bar.example.org", + dnsControllerHostnameAnnotationKey: "foo.example.org., bar.example.org", + }, + nil, + []*endpoint.Endpoint{}, + false, + []*v1.Node{{ + ObjectMeta: metav1.ObjectMeta{ + Name: "node1", + Labels: map[string]string{ + "node-role.kubernetes.io/node": "", + }, + }, + Status: v1.NodeStatus{ + Addresses: []v1.NodeAddress{ + {Type: v1.NodeExternalIP, Address: "54.10.11.1"}, + {Type: v1.NodeInternalIP, Address: "10.0.1.1"}, + }, + }, + }, { + ObjectMeta: metav1.ObjectMeta{ + Name: "node2", + Labels: map[string]string{ + "node-role.kubernetes.io/node": "", + }, + }, + Status: v1.NodeStatus{ + Addresses: []v1.NodeAddress{ + {Type: v1.NodeExternalIP, Address: "54.10.11.2"}, + {Type: v1.NodeInternalIP, Address: "10.0.1.2"}, + }, + }, + }}, + []string{}, + []int{}, + []v1.PodPhase{}, + }, } { t.Run(tc.title, func(t *testing.T) { // Create a Kubernetes testing client