From 9c3a9802fa414567e2607c28e8dec4e02a330c05 Mon Sep 17 00:00:00 2001 From: Justin Nauman Date: Thu, 17 Aug 2017 15:39:06 -0500 Subject: [PATCH] ClusterIp Service support (#278) * ClusterIP service support - First pass at addresssing #187 by allowing services with type ClusterIP to be directly supported * Getting existing tests to pass * Adjusting formatting for gofmt/govet * Adding in guard logic around publishing of ClusterIP sources * Addressing PR feedback * Adding in CHANGELOG entry * Adding in Headless service test --- .gitignore | 1 + CHANGELOG.md | 3 + main.go | 7 +- pkg/apis/externaldns/types.go | 3 + source/service.go | 86 +++++++++------ source/service_test.go | 194 +++++++++++++++++++++++++++++++++- source/store.go | 9 +- 7 files changed, 264 insertions(+), 39 deletions(-) diff --git a/.gitignore b/.gitignore index c3a2831efa..56abe3c3ea 100644 --- a/.gitignore +++ b/.gitignore @@ -42,3 +42,4 @@ cscope.* # coverage output cover.out *.coverprofile +external-dns diff --git a/CHANGELOG.md b/CHANGELOG.md index e407599158..05b1546cf4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,9 @@ + - ExternalDNS now services of type `ClusterIP` with the use of the `--publish-internal-services`. Enabling this will now create the apprioriate A records for the given service's internal ip. @jrnt30 + ## v0.4.3 - 2017-08-17 - Fix to have external target annotations on ingress resources replace existing endpoints instead of appending to them (#318) + ## v0.4.2 - 2017-08-03 - Fix to support multiple hostnames for Molecule Software's [route53-kubernetes](https://github.com/wearemolecule/route53-kubernetes) compatibility (#301) diff --git a/main.go b/main.go index 37526dc84f..45a6176eff 100644 --- a/main.go +++ b/main.go @@ -65,9 +65,10 @@ func main() { // Create a source.Config from the flags passed by the user. sourceCfg := &source.Config{ - Namespace: cfg.Namespace, - FQDNTemplate: cfg.FQDNTemplate, - Compatibility: cfg.Compatibility, + Namespace: cfg.Namespace, + FQDNTemplate: cfg.FQDNTemplate, + Compatibility: cfg.Compatibility, + PublishInternal: cfg.PublishInternal, } // Lookup all the selected sources by names and pass them the desired configuration. diff --git a/pkg/apis/externaldns/types.go b/pkg/apis/externaldns/types.go index 26c941de21..9e2d2bf73b 100644 --- a/pkg/apis/externaldns/types.go +++ b/pkg/apis/externaldns/types.go @@ -34,6 +34,7 @@ type Config struct { Namespace string FQDNTemplate string Compatibility string + PublishInternal bool Provider string GoogleProject string DomainFilter []string @@ -58,6 +59,7 @@ var defaultConfig = &Config{ Namespace: "", FQDNTemplate: "", Compatibility: "", + PublishInternal: false, Provider: "", GoogleProject: "", DomainFilter: []string{}, @@ -95,6 +97,7 @@ func (cfg *Config) ParseFlags(args []string) error { app.Flag("namespace", "Limit sources of endpoints to a specific namespace (default: all namespaces)").Default(defaultConfig.Namespace).StringVar(&cfg.Namespace) app.Flag("fqdn-template", "A templated string that's used to generate DNS names from sources that don't define a hostname themselves, or to add a hostname suffix when paired with the fake source (optional)").Default(defaultConfig.FQDNTemplate).StringVar(&cfg.FQDNTemplate) 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) // Flags related to providers app.Flag("provider", "The DNS provider where the DNS records will be created (required, options: aws, google, azure, cloudflare, digitalocean, inmemory)").Required().PlaceHolder("provider").EnumVar(&cfg.Provider, "aws", "google", "azure", "cloudflare", "digitalocean", "inmemory") diff --git a/source/service.go b/source/service.go index 6bf77c19d2..cd2743d3e7 100644 --- a/source/service.go +++ b/source/service.go @@ -34,18 +34,19 @@ import ( // serviceSource is an implementation of Source for Kubernetes service objects. // It will find all services that are under our jurisdiction, i.e. annotated // desired hostname and matching or no controller annotation. For each of the -// matched services' external entrypoints it will return a corresponding +// matched services' entrypoints it will return a corresponding // Endpoint object. type serviceSource struct { client kubernetes.Interface namespace string // process Services with legacy annotations - compatibility string - fqdnTemplate *template.Template + compatibility string + fqdnTemplate *template.Template + publishInternal bool } // NewServiceSource creates a new serviceSource with the given config. -func NewServiceSource(kubeClient kubernetes.Interface, namespace, fqdnTemplate, compatibility string) (Source, error) { +func NewServiceSource(kubeClient kubernetes.Interface, namespace, fqdnTemplate, compatibility string, publishInternal bool) (Source, error) { var ( tmpl *template.Template err error @@ -60,10 +61,11 @@ func NewServiceSource(kubeClient kubernetes.Interface, namespace, fqdnTemplate, } return &serviceSource{ - client: kubeClient, - namespace: namespace, - compatibility: compatibility, - fqdnTemplate: tmpl, + client: kubeClient, + namespace: namespace, + compatibility: compatibility, + fqdnTemplate: tmpl, + publishInternal: publishInternal, }, nil } @@ -85,7 +87,7 @@ func (sc *serviceSource) Endpoints() ([]*endpoint.Endpoint, error) { continue } - svcEndpoints := endpointsFromService(&svc) + svcEndpoints := sc.endpoints(&svc) // process legacy annotations if no endpoints were returned and compatibility mode is enabled. if len(svcEndpoints) == 0 && sc.compatibility != "" { @@ -122,21 +124,13 @@ func (sc *serviceSource) endpointsFromTemplate(svc *v1.Service) ([]*endpoint.End } hostname := buf.String() - for _, lb := range svc.Status.LoadBalancer.Ingress { - if lb.IP != "" { - //TODO(ideahitme): consider retrieving record type from resource annotation instead of empty - endpoints = append(endpoints, endpoint.NewEndpoint(hostname, lb.IP, "")) - } - if lb.Hostname != "" { - endpoints = append(endpoints, endpoint.NewEndpoint(hostname, lb.Hostname, "")) - } - } + endpoints = sc.generateEndpoints(svc, hostname) return endpoints, nil } // endpointsFromService extracts the endpoints from a service object -func endpointsFromService(svc *v1.Service) []*endpoint.Endpoint { +func (sc *serviceSource) endpoints(svc *v1.Service) []*endpoint.Endpoint { var endpoints []*endpoint.Endpoint // Get the desired hostname of the service from the annotation. @@ -145,20 +139,50 @@ func endpointsFromService(svc *v1.Service) []*endpoint.Endpoint { return nil } - // splits the hostname annotation and removes the trailing periods hostnameList := strings.Split(strings.Replace(hostnameAnnotation, " ", "", -1), ",") - for _, hostname := range hostnameList { - hostname = strings.TrimSuffix(hostname, ".") - // Create a corresponding endpoint for each configured external entrypoint. - for _, lb := range svc.Status.LoadBalancer.Ingress { - if lb.IP != "" { - //TODO(ideahitme): consider retrieving record type from resource annotation instead of empty - endpoints = append(endpoints, endpoint.NewEndpoint(hostname, lb.IP, "")) - } - if lb.Hostname != "" { - endpoints = append(endpoints, endpoint.NewEndpoint(hostname, lb.Hostname, "")) - } + endpoints = append(endpoints, sc.generateEndpoints(svc, hostname)...) + } + + return endpoints +} + +func (sc *serviceSource) generateEndpoints(svc *v1.Service, hostname string) []*endpoint.Endpoint { + var endpoints []*endpoint.Endpoint + + hostname = strings.TrimSuffix(hostname, ".") + switch svc.Spec.Type { + case v1.ServiceTypeLoadBalancer: + endpoints = append(endpoints, extractLoadBalancerEndpoints(svc, hostname)...) + case v1.ServiceTypeClusterIP: + if sc.publishInternal { + endpoints = append(endpoints, extractServiceIps(svc, hostname)...) + } + } + + return endpoints +} + +func extractServiceIps(svc *v1.Service, hostname string) []*endpoint.Endpoint { + if svc.Spec.ClusterIP == v1.ClusterIPNone { + log.Debugf("Unable to associate %s headless service with a Cluster IP", svc.Name) + return []*endpoint.Endpoint{} + } + + return []*endpoint.Endpoint{endpoint.NewEndpoint(hostname, svc.Spec.ClusterIP, "")} +} + +func extractLoadBalancerEndpoints(svc *v1.Service, hostname string) []*endpoint.Endpoint { + var endpoints []*endpoint.Endpoint + + // Create a corresponding endpoint for each configured external entrypoint. + for _, lb := range svc.Status.LoadBalancer.Ingress { + if lb.IP != "" { + //TODO(ideahitme): consider retrieving record type from resource annotation instead of empty + endpoints = append(endpoints, endpoint.NewEndpoint(hostname, lb.IP, "")) + } + if lb.Hostname != "" { + endpoints = append(endpoints, endpoint.NewEndpoint(hostname, lb.Hostname, "")) } } diff --git a/source/service_test.go b/source/service_test.go index 0d63716de4..f4ef658782 100644 --- a/source/service_test.go +++ b/source/service_test.go @@ -69,6 +69,7 @@ func testServiceSourceNewServiceSource(t *testing.T) { "", ti.fqdnTemplate, "", + false, ) if ti.expectError { @@ -87,10 +88,12 @@ func testServiceSourceEndpoints(t *testing.T) { targetNamespace string svcNamespace string svcName string + svcType v1.ServiceType compatibility string fqdnTemplate string labels map[string]string annotations map[string]string + clusterIP string lbs []string expected []*endpoint.Endpoint expectError bool @@ -100,10 +103,12 @@ func testServiceSourceEndpoints(t *testing.T) { "", "testing", "foo", + v1.ServiceTypeLoadBalancer, "", "", map[string]string{}, map[string]string{}, + "", []string{"1.2.3.4"}, []*endpoint.Endpoint{}, false, @@ -113,29 +118,50 @@ func testServiceSourceEndpoints(t *testing.T) { "", "testing", "foo", + v1.ServiceTypeLoadBalancer, "", "", map[string]string{}, map[string]string{ hostnameAnnotationKey: "foo.example.org.", }, + "", []string{"1.2.3.4"}, []*endpoint.Endpoint{ {DNSName: "foo.example.org", Target: "1.2.3.4"}, }, false, }, + { + "annotated ClusterIp aren't processed without explicit authorization", + "", + "testing", + "foo", + v1.ServiceTypeClusterIP, + "", + "", + map[string]string{}, + map[string]string{ + hostnameAnnotationKey: "foo.example.org.", + }, + "1.2.3.4", + []string{}, + []*endpoint.Endpoint{}, + false, + }, { "annotated services with multiple hostnames return an endpoint with target IP", "", "testing", "foo", + v1.ServiceTypeLoadBalancer, "", "", map[string]string{}, map[string]string{ hostnameAnnotationKey: "foo.example.org., bar.example.org.", }, + "", []string{"1.2.3.4"}, []*endpoint.Endpoint{ {DNSName: "foo.example.org", Target: "1.2.3.4"}, @@ -148,12 +174,14 @@ func testServiceSourceEndpoints(t *testing.T) { "", "testing", "foo", + v1.ServiceTypeLoadBalancer, "", "", map[string]string{}, map[string]string{ hostnameAnnotationKey: "foo.example.org, bar.example.org", }, + "", []string{"1.2.3.4"}, []*endpoint.Endpoint{ {DNSName: "foo.example.org", Target: "1.2.3.4"}, @@ -166,12 +194,14 @@ func testServiceSourceEndpoints(t *testing.T) { "", "testing", "foo", + v1.ServiceTypeLoadBalancer, "", "", map[string]string{}, map[string]string{ hostnameAnnotationKey: "foo.example.org.", }, + "", []string{"lb.example.com"}, // Kubernetes omits the trailing dot []*endpoint.Endpoint{ {DNSName: "foo.example.org", Target: "lb.example.com"}, @@ -183,12 +213,14 @@ func testServiceSourceEndpoints(t *testing.T) { "", "testing", "foo", + v1.ServiceTypeLoadBalancer, "", "", map[string]string{}, map[string]string{ hostnameAnnotationKey: "foo.example.org", // Trailing dot is omitted }, + "", []string{"1.2.3.4", "lb.example.com"}, // Kubernetes omits the trailing dot []*endpoint.Endpoint{ {DNSName: "foo.example.org", Target: "1.2.3.4"}, @@ -201,6 +233,7 @@ func testServiceSourceEndpoints(t *testing.T) { "", "testing", "foo", + v1.ServiceTypeLoadBalancer, "", "", map[string]string{}, @@ -208,6 +241,7 @@ func testServiceSourceEndpoints(t *testing.T) { controllerAnnotationKey: controllerAnnotationValue, hostnameAnnotationKey: "foo.example.org.", }, + "", []string{"1.2.3.4"}, []*endpoint.Endpoint{ {DNSName: "foo.example.org", Target: "1.2.3.4"}, @@ -219,6 +253,7 @@ func testServiceSourceEndpoints(t *testing.T) { "", "testing", "foo", + v1.ServiceTypeLoadBalancer, "", "{{.Name}}.ext-dns.test.com", map[string]string{}, @@ -226,6 +261,7 @@ func testServiceSourceEndpoints(t *testing.T) { controllerAnnotationKey: "some-other-tool", hostnameAnnotationKey: "foo.example.org.", }, + "", []string{"1.2.3.4"}, []*endpoint.Endpoint{}, false, @@ -235,12 +271,14 @@ func testServiceSourceEndpoints(t *testing.T) { "testing", "testing", "foo", + v1.ServiceTypeLoadBalancer, "", "", map[string]string{}, map[string]string{ hostnameAnnotationKey: "foo.example.org.", }, + "", []string{"1.2.3.4"}, []*endpoint.Endpoint{ {DNSName: "foo.example.org", Target: "1.2.3.4"}, @@ -252,12 +290,14 @@ func testServiceSourceEndpoints(t *testing.T) { "testing", "other-testing", "foo", + v1.ServiceTypeLoadBalancer, "", "", map[string]string{}, map[string]string{ hostnameAnnotationKey: "foo.example.org.", }, + "", []string{"1.2.3.4"}, []*endpoint.Endpoint{}, false, @@ -267,12 +307,14 @@ func testServiceSourceEndpoints(t *testing.T) { "", "other-testing", "foo", + v1.ServiceTypeLoadBalancer, "", "", map[string]string{}, map[string]string{ hostnameAnnotationKey: "foo.example.org.", }, + "", []string{"1.2.3.4"}, []*endpoint.Endpoint{ {DNSName: "foo.example.org", Target: "1.2.3.4"}, @@ -284,12 +326,14 @@ func testServiceSourceEndpoints(t *testing.T) { "", "testing", "foo", + v1.ServiceTypeLoadBalancer, "", "", map[string]string{}, map[string]string{ hostnameAnnotationKey: "foo.example.org.", }, + "", []string{}, []*endpoint.Endpoint{}, false, @@ -299,12 +343,14 @@ func testServiceSourceEndpoints(t *testing.T) { "", "testing", "foo", + v1.ServiceTypeLoadBalancer, "", "", map[string]string{}, map[string]string{ hostnameAnnotationKey: "foo.example.org.", }, + "", []string{"1.2.3.4", "8.8.8.8"}, []*endpoint.Endpoint{ {DNSName: "foo.example.org", Target: "1.2.3.4"}, @@ -317,12 +363,14 @@ func testServiceSourceEndpoints(t *testing.T) { "", "testing", "foo", + v1.ServiceTypeLoadBalancer, "", "", map[string]string{}, map[string]string{ "zalando.org/dnsname": "foo.example.org.", }, + "", []string{"1.2.3.4"}, []*endpoint.Endpoint{}, false, @@ -332,12 +380,14 @@ func testServiceSourceEndpoints(t *testing.T) { "", "testing", "foo", + v1.ServiceTypeLoadBalancer, "mate", "", map[string]string{}, map[string]string{ "zalando.org/dnsname": "foo.example.org.", }, + "", []string{"1.2.3.4"}, []*endpoint.Endpoint{ {DNSName: "foo.example.org", Target: "1.2.3.4"}, @@ -349,6 +399,7 @@ func testServiceSourceEndpoints(t *testing.T) { "", "testing", "foo", + v1.ServiceTypeLoadBalancer, "molecule", "", map[string]string{ @@ -357,6 +408,7 @@ func testServiceSourceEndpoints(t *testing.T) { map[string]string{ "domainName": "foo.example.org., bar.example.org", }, + "", []string{"1.2.3.4"}, []*endpoint.Endpoint{ {DNSName: "foo.example.org", Target: "1.2.3.4"}, @@ -369,10 +421,12 @@ func testServiceSourceEndpoints(t *testing.T) { "", "testing", "foo", + v1.ServiceTypeLoadBalancer, "", "{{.Name}}.bar.example.com", map[string]string{}, map[string]string{}, + "", []string{"1.2.3.4", "elb.com"}, []*endpoint.Endpoint{ {DNSName: "foo.bar.example.com", Target: "1.2.3.4"}, @@ -385,12 +439,14 @@ func testServiceSourceEndpoints(t *testing.T) { "", "testing", "foo", + v1.ServiceTypeLoadBalancer, "", "{{.Name}}.bar.example.com", map[string]string{}, map[string]string{ hostnameAnnotationKey: "foo.example.org.", }, + "", []string{"1.2.3.4", "elb.com"}, []*endpoint.Endpoint{ {DNSName: "foo.example.org", Target: "1.2.3.4"}, @@ -403,12 +459,14 @@ func testServiceSourceEndpoints(t *testing.T) { "", "testing", "foo", + v1.ServiceTypeLoadBalancer, "mate", "{{.Name}}.bar.example.com", map[string]string{}, map[string]string{ "zalando.org/dnsname": "mate.example.org.", }, + "", []string{"1.2.3.4"}, []*endpoint.Endpoint{ {DNSName: "mate.example.org", Target: "1.2.3.4"}, @@ -420,10 +478,12 @@ func testServiceSourceEndpoints(t *testing.T) { "", "testing", "foo", + v1.ServiceTypeLoadBalancer, "", "{{.Calibre}}.bar.example.com", map[string]string{}, map[string]string{}, + "", []string{"1.2.3.4"}, []*endpoint.Endpoint{}, true, @@ -444,6 +504,137 @@ func testServiceSourceEndpoints(t *testing.T) { } service := &v1.Service{ + Spec: v1.ServiceSpec{ + Type: tc.svcType, + ClusterIP: tc.clusterIP, + }, + ObjectMeta: metav1.ObjectMeta{ + Namespace: tc.svcNamespace, + Name: tc.svcName, + Labels: tc.labels, + Annotations: tc.annotations, + }, + Status: v1.ServiceStatus{ + LoadBalancer: v1.LoadBalancerStatus{ + Ingress: ingresses, + }, + }, + } + + _, err := kubernetes.CoreV1().Services(service.Namespace).Create(service) + require.NoError(t, err) + + // Create our object under test and get the endpoints. + client, _ := NewServiceSource( + kubernetes, + tc.targetNamespace, + tc.fqdnTemplate, + tc.compatibility, + false, + ) + 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) + }) + } +} + +// testServiceSourceEndpoints tests that various services generate the correct endpoints. +func TestClusterIpServices(t *testing.T) { + for _, tc := range []struct { + title string + targetNamespace string + svcNamespace string + svcName string + svcType v1.ServiceType + compatibility string + fqdnTemplate string + labels map[string]string + annotations map[string]string + clusterIP string + lbs []string + expected []*endpoint.Endpoint + expectError bool + }{ + { + "annotated ClusterIp services return an endpoint with Cluster IP", + "", + "testing", + "foo", + v1.ServiceTypeClusterIP, + "", + "", + map[string]string{}, + map[string]string{ + hostnameAnnotationKey: "foo.example.org.", + }, + "1.2.3.4", + []string{}, + []*endpoint.Endpoint{ + {DNSName: "foo.example.org", Target: "1.2.3.4"}, + }, + false, + }, + { + "non-annotated ClusterIp services with set fqdnTemplate return an endpoint with target IP", + "", + "testing", + "foo", + v1.ServiceTypeClusterIP, + "", + "{{.Name}}.bar.example.com", + map[string]string{}, + map[string]string{}, + "4.5.6.7", + []string{}, + []*endpoint.Endpoint{ + {DNSName: "foo.bar.example.com", Target: "4.5.6.7"}, + }, + false, + }, + { + "Headless services do not generate endpoints", + "", + "testing", + "foo", + v1.ServiceTypeClusterIP, + "", + "", + map[string]string{}, + map[string]string{}, + v1.ClusterIPNone, + []string{}, + []*endpoint.Endpoint{}, + false, + }, + } { + t.Run(tc.title, func(t *testing.T) { + // Create a Kubernetes testing client + kubernetes := fake.NewSimpleClientset() + + // Create a service to test against + ingresses := []v1.LoadBalancerIngress{} + for _, lb := range tc.lbs { + if net.ParseIP(lb) != nil { + ingresses = append(ingresses, v1.LoadBalancerIngress{IP: lb}) + } else { + ingresses = append(ingresses, v1.LoadBalancerIngress{Hostname: lb}) + } + } + + service := &v1.Service{ + Spec: v1.ServiceSpec{ + Type: tc.svcType, + ClusterIP: tc.clusterIP, + }, ObjectMeta: metav1.ObjectMeta{ Namespace: tc.svcNamespace, Name: tc.svcName, @@ -466,6 +657,7 @@ func testServiceSourceEndpoints(t *testing.T) { tc.targetNamespace, tc.fqdnTemplate, tc.compatibility, + true, ) require.NoError(t, err) @@ -506,7 +698,7 @@ func BenchmarkServiceEndpoints(b *testing.B) { _, err := kubernetes.CoreV1().Services(service.Namespace).Create(service) require.NoError(b, err) - client, err := NewServiceSource(kubernetes, v1.NamespaceAll, "", "") + client, err := NewServiceSource(kubernetes, v1.NamespaceAll, "", "", false) require.NoError(b, err) for i := 0; i < b.N; i++ { diff --git a/source/store.go b/source/store.go index 90068d6418..efac5a4dc2 100644 --- a/source/store.go +++ b/source/store.go @@ -35,9 +35,10 @@ var ErrSourceNotFound = errors.New("source not found") // Config holds shared configuration options for all Sources. type Config struct { - Namespace string - FQDNTemplate string - Compatibility string + Namespace string + FQDNTemplate string + Compatibility string + PublishInternal bool } // ClientGenerator provides clients @@ -85,7 +86,7 @@ func BuildWithConfig(source string, p ClientGenerator, cfg *Config) (Source, err if err != nil { return nil, err } - return NewServiceSource(client, cfg.Namespace, cfg.FQDNTemplate, cfg.Compatibility) + return NewServiceSource(client, cfg.Namespace, cfg.FQDNTemplate, cfg.Compatibility, cfg.PublishInternal) case "ingress": client, err := p.KubeClient() if err != nil {