diff --git a/main.go b/main.go index eca48b351f..5a10719396 100644 --- a/main.go +++ b/main.go @@ -78,6 +78,7 @@ func main() { Compatibility: cfg.Compatibility, PublishInternal: cfg.PublishInternal, PublishHostIP: cfg.PublishHostIP, + PublishHostExternalIP: cfg.PublishHostExternalIP, ConnectorServer: cfg.ConnectorSourceServer, CRDSourceAPIVersion: cfg.CRDSourceAPIVersion, CRDSourceKind: cfg.CRDSourceKind, diff --git a/pkg/apis/externaldns/types.go b/pkg/apis/externaldns/types.go index c24761287a..453190314b 100644 --- a/pkg/apis/externaldns/types.go +++ b/pkg/apis/externaldns/types.go @@ -51,6 +51,7 @@ type Config struct { Compatibility string PublishInternal bool PublishHostIP bool + PublishHostExternalIP bool ConnectorSourceServer string Provider string GoogleProject string @@ -291,6 +292,7 @@ func (cfg *Config) ParseFlags(args []string) error { app.Flag("compatibility", "Process annotation semantics from legacy implementations (optional, options: mate, molecule)").Default(defaultConfig.Compatibility).EnumVar(&cfg.Compatibility, "", "mate", "molecule") 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("publish-host-external-ip", "Allow external-dns to publish external host-ip for headless services, valid only when publish-host-ip is false (optional)").BoolVar(&cfg.PublishHostExternalIP) app.Flag("connector-source-server", "The server to connect for connector source, valid only when using connector source").Default(defaultConfig.ConnectorSourceServer).StringVar(&cfg.ConnectorSourceServer) app.Flag("crd-source-apiversion", "API version of the CRD for crd source, e.g. `externaldns.k8s.io/v1alpha1`, valid only when using crd source").Default(defaultConfig.CRDSourceAPIVersion).StringVar(&cfg.CRDSourceAPIVersion) app.Flag("crd-source-kind", "Kind of the CRD for the crd source in API group and version specified by crd-source-apiversion").Default(defaultConfig.CRDSourceKind).StringVar(&cfg.CRDSourceKind) diff --git a/source/service.go b/source/service.go index ee8d7ce882..b8ad802d2f 100644 --- a/source/service.go +++ b/source/service.go @@ -57,6 +57,7 @@ type serviceSource struct { ignoreHostnameAnnotation bool publishInternal bool publishHostIP bool + publishHostExternalIP bool serviceInformer coreinformers.ServiceInformer podInformer coreinformers.PodInformer nodeInformer coreinformers.NodeInformer @@ -64,7 +65,7 @@ type serviceSource struct { } // NewServiceSource creates a new serviceSource with the given config. -func NewServiceSource(kubeClient kubernetes.Interface, namespace, annotationFilter string, fqdnTemplate string, combineFqdnAnnotation bool, compatibility string, publishInternal bool, publishHostIP bool, serviceTypeFilter []string, ignoreHostnameAnnotation bool) (Source, error) { +func NewServiceSource(kubeClient kubernetes.Interface, namespace, annotationFilter string, fqdnTemplate string, combineFqdnAnnotation bool, compatibility string, publishInternal bool, publishHostIP bool, publishHostExternalIP bool, serviceTypeFilter []string, ignoreHostnameAnnotation bool) (Source, error) { var ( tmpl *template.Template err error @@ -136,6 +137,7 @@ func NewServiceSource(kubeClient kubernetes.Interface, namespace, annotationFilt ignoreHostnameAnnotation: ignoreHostnameAnnotation, publishInternal: publishInternal, publishHostIP: publishHostIP, + publishHostExternalIP: publishHostExternalIP, serviceInformer: serviceInformer, podInformer: podInformer, nodeInformer: nodeInformer, @@ -242,6 +244,24 @@ func (sc *serviceSource) extractHeadlessEndpoints(svc *v1.Service, hostname stri } else { log.Debugf("Pod %s is not in running phase", v.Spec.Hostname) } + } else if sc.publishHostExternalIP == true { + node, err := sc.nodeInformer.Lister().Get(v.Spec.NodeName) + if err != nil { + return nil + } + var externalIPs endpoint.Targets + for _, address := range node.Status.Addresses { + if address.Type == v1.NodeExternalIP { + externalIPs = append(externalIPs, address.Address) + } + } + + log.Debugf("Generating matching endpoint %s with Host ExternalIPs %v", headlessDomain, externalIPs) + if v.Status.Phase == v1.PodRunning { + targetsByHeadlessDomain[headlessDomain] = append(targetsByHeadlessDomain[headlessDomain], externalIPs...) + } else { + log.Debugf("Pod %s is not in running phase", v.Spec.Hostname) + } } else { log.Debugf("Generating matching endpoint %s with PodIP %s", headlessDomain, v.Status.PodIP) // To reduce traffice on the DNS API only add record for running Pods. Good Idea? diff --git a/source/service_test.go b/source/service_test.go index 3fbda307b8..e56a90b786 100644 --- a/source/service_test.go +++ b/source/service_test.go @@ -51,6 +51,7 @@ func (suite *ServiceSuite) SetupTest() { "", false, false, + false, []string{}, false, ) @@ -143,6 +144,7 @@ func testServiceSourceNewServiceSource(t *testing.T) { "", false, false, + false, ti.serviceTypesFilter, false, ) @@ -1083,6 +1085,7 @@ func testServiceSourceEndpoints(t *testing.T) { tc.compatibility, false, false, + false, tc.serviceTypesFilter, tc.ignoreHostnameAnnotation, ) @@ -1253,6 +1256,7 @@ func TestClusterIpServices(t *testing.T) { tc.compatibility, true, false, + false, []string{}, tc.ignoreHostnameAnnotation, ) @@ -1584,6 +1588,7 @@ func TestNodePortServices(t *testing.T) { tc.compatibility, true, false, + false, []string{}, tc.ignoreHostnameAnnotation, ) @@ -1818,6 +1823,7 @@ func TestHeadlessServices(t *testing.T) { tc.compatibility, true, false, + false, []string{}, tc.ignoreHostnameAnnotation, ) @@ -2052,6 +2058,255 @@ func TestHeadlessServicesHostIP(t *testing.T) { tc.compatibility, true, true, + false, + []string{}, + tc.ignoreHostnameAnnotation, + ) + require.NoError(t, err) + + endpoints, err := client.Endpoints() + if tc.expectError { + require.Error(t, err) + } else { + require.NoError(t, err) + } + + // Validate returned endpoints against desired endpoints. + validateEndpoints(t, endpoints, tc.expected) + }) + } +} + +// TestHeadlessServicesExternalHostIP tests that headless services generate the correct endpoints. +func TestHeadlessServicesExternalHostIP(t *testing.T) { + for _, tc := range []struct { + title string + targetNamespace string + svcNamespace string + svcName string + svcType v1.ServiceType + compatibility string + fqdnTemplate string + ignoreHostnameAnnotation bool + labels map[string]string + annotations map[string]string + clusterIP string + hostIPs []string + selector map[string]string + lbs []string + podnames []string + hostnames []string + phases []v1.PodPhase + expected []*endpoint.Endpoint + expectError bool + }{ + { + "annotated Headless services return endpoints for each selected Pod", + "", + "testing", + "foo", + v1.ServiceTypeClusterIP, + "", + "", + false, + map[string]string{"component": "foo"}, + map[string]string{ + hostnameAnnotationKey: "service.example.org", + }, + v1.ClusterIPNone, + []string{"1.1.1.1", "1.1.1.2"}, + map[string]string{ + "component": "foo", + }, + []string{}, + []string{"foo-0", "foo-1"}, + []string{"foo-0", "foo-1"}, + []v1.PodPhase{v1.PodRunning, v1.PodRunning}, + []*endpoint.Endpoint{ + {DNSName: "foo-0.service.example.org", Targets: endpoint.Targets{"1.1.1.1"}}, + {DNSName: "foo-1.service.example.org", Targets: endpoint.Targets{"1.1.1.2"}}, + {DNSName: "service.example.org", Targets: endpoint.Targets{"1.1.1.1", "1.1.1.2"}}, + }, + false, + }, + { + "hostname annotated Headless services are ignored", + "", + "testing", + "foo", + v1.ServiceTypeClusterIP, + "", + "", + true, + map[string]string{"component": "foo"}, + map[string]string{ + hostnameAnnotationKey: "service.example.org", + }, + v1.ClusterIPNone, + []string{"1.1.1.1", "1.1.1.2"}, + map[string]string{ + "component": "foo", + }, + []string{}, + []string{"foo-0", "foo-1"}, + []string{"foo-0", "foo-1"}, + []v1.PodPhase{v1.PodRunning, v1.PodRunning}, + []*endpoint.Endpoint{}, + false, + }, + { + "annotated Headless services return endpoints with TTL for each selected Pod", + "", + "testing", + "foo", + v1.ServiceTypeClusterIP, + "", + "", + false, + map[string]string{"component": "foo"}, + map[string]string{ + hostnameAnnotationKey: "service.example.org", + ttlAnnotationKey: "1", + }, + v1.ClusterIPNone, + []string{"1.1.1.1", "1.1.1.2"}, + map[string]string{ + "component": "foo", + }, + []string{}, + []string{"foo-0", "foo-1"}, + []string{"foo-0", "foo-1"}, + []v1.PodPhase{v1.PodRunning, v1.PodRunning}, + []*endpoint.Endpoint{ + {DNSName: "foo-0.service.example.org", Targets: endpoint.Targets{"1.1.1.1"}, RecordTTL: endpoint.TTL(1)}, + {DNSName: "foo-1.service.example.org", Targets: endpoint.Targets{"1.1.1.2"}, RecordTTL: endpoint.TTL(1)}, + {DNSName: "service.example.org", Targets: endpoint.Targets{"1.1.1.1", "1.1.1.2"}, RecordTTL: endpoint.TTL(1)}, + }, + false, + }, + { + "annotated Headless services return endpoints for each selected Pod, which are in running state", + "", + "testing", + "foo", + v1.ServiceTypeClusterIP, + "", + "", + false, + map[string]string{"component": "foo"}, + map[string]string{ + hostnameAnnotationKey: "service.example.org", + }, + v1.ClusterIPNone, + []string{"1.1.1.1", "1.1.1.2"}, + map[string]string{ + "component": "foo", + }, + []string{}, + []string{"foo-0", "foo-1"}, + []string{"foo-0", "foo-1"}, + []v1.PodPhase{v1.PodRunning, v1.PodFailed}, + []*endpoint.Endpoint{ + {DNSName: "foo-0.service.example.org", Targets: endpoint.Targets{"1.1.1.1"}}, + {DNSName: "service.example.org", Targets: endpoint.Targets{"1.1.1.1"}}, + }, + false, + }, + { + "annotated Headless services return endpoints for pods missing hostname", + "", + "testing", + "foo", + v1.ServiceTypeClusterIP, + "", + "", + false, + map[string]string{"component": "foo"}, + map[string]string{ + hostnameAnnotationKey: "service.example.org", + }, + v1.ClusterIPNone, + []string{"1.1.1.1", "1.1.1.2"}, + map[string]string{ + "component": "foo", + }, + []string{}, + []string{"foo-0", "foo-1"}, + []string{"", ""}, + []v1.PodPhase{v1.PodRunning, v1.PodRunning}, + []*endpoint.Endpoint{ + {DNSName: "service.example.org", Targets: endpoint.Targets{"1.1.1.1", "1.1.1.2"}}, + }, + false, + }, + } { + t.Run(tc.title, func(t *testing.T) { + // Create a Kubernetes testing client + kubernetes := fake.NewSimpleClientset() + + service := &v1.Service{ + Spec: v1.ServiceSpec{ + Type: tc.svcType, + ClusterIP: tc.clusterIP, + Selector: tc.selector, + }, + ObjectMeta: metav1.ObjectMeta{ + Namespace: tc.svcNamespace, + Name: tc.svcName, + Labels: tc.labels, + Annotations: tc.annotations, + }, + Status: v1.ServiceStatus{}, + } + _, err := kubernetes.CoreV1().Services(service.Namespace).Create(service) + require.NoError(t, err) + + for i, podname := range tc.podnames { + node := &v1.Node{ + ObjectMeta: metav1.ObjectMeta{ + Name: "node-" + podname, + }, + Spec: v1.NodeSpec{}, + Status: v1.NodeStatus{ + Addresses: []v1.NodeAddress{{Type: v1.NodeExternalIP, Address: tc.hostIPs[i]}}, + }, + } + _, err = kubernetes.CoreV1().Nodes().Create(node) + require.NoError(t, err) + + pod := &v1.Pod{ + Spec: v1.PodSpec{ + Containers: []v1.Container{}, + Hostname: tc.hostnames[i], + NodeName: node.Name, + }, + ObjectMeta: metav1.ObjectMeta{ + Namespace: tc.svcNamespace, + Name: podname, + Labels: tc.labels, + Annotations: tc.annotations, + }, + Status: v1.PodStatus{ + HostIP: tc.hostIPs[i], + Phase: tc.phases[i], + }, + } + + _, err = kubernetes.CoreV1().Pods(tc.svcNamespace).Create(pod) + require.NoError(t, err) + } + + // Create our object under test and get the endpoints. + client, _ := NewServiceSource( + kubernetes, + tc.targetNamespace, + "", + tc.fqdnTemplate, + false, + tc.compatibility, + true, + false, + true, []string{}, tc.ignoreHostnameAnnotation, ) @@ -2156,6 +2411,7 @@ func TestExternalServices(t *testing.T) { tc.compatibility, true, false, + false, []string{}, tc.ignoreHostnameAnnotation, ) @@ -2198,7 +2454,7 @@ func BenchmarkServiceEndpoints(b *testing.B) { _, err := kubernetes.CoreV1().Services(service.Namespace).Create(service) require.NoError(b, err) - client, err := NewServiceSource(kubernetes, v1.NamespaceAll, "", "", false, "", false, false, []string{}, false) + client, err := NewServiceSource(kubernetes, v1.NamespaceAll, "", "", false, "", false, false, false, []string{}, false) require.NoError(b, err) for i := 0; i < b.N; i++ { diff --git a/source/store.go b/source/store.go index 586068068e..60c3ec39f7 100644 --- a/source/store.go +++ b/source/store.go @@ -48,6 +48,7 @@ type Config struct { Compatibility string PublishInternal bool PublishHostIP bool + PublishHostExternalIP bool ConnectorServer string CRDSourceAPIVersion string CRDSourceKind string @@ -164,7 +165,7 @@ func BuildWithConfig(source string, p ClientGenerator, cfg *Config) (Source, err if err != nil { return nil, err } - return NewServiceSource(client, cfg.Namespace, cfg.AnnotationFilter, cfg.FQDNTemplate, cfg.CombineFQDNAndAnnotation, cfg.Compatibility, cfg.PublishInternal, cfg.PublishHostIP, cfg.ServiceTypeFilter, cfg.IgnoreHostnameAnnotation) + return NewServiceSource(client, cfg.Namespace, cfg.AnnotationFilter, cfg.FQDNTemplate, cfg.CombineFQDNAndAnnotation, cfg.Compatibility, cfg.PublishInternal, cfg.PublishHostIP, cfg.PublishHostExternalIP, cfg.ServiceTypeFilter, cfg.IgnoreHostnameAnnotation) case "ingress": client, err := p.KubeClient() if err != nil {