diff --git a/endpoint/endpoint.go b/endpoint/endpoint.go new file mode 100644 index 000000000..4ad5974c0 --- /dev/null +++ b/endpoint/endpoint.go @@ -0,0 +1,6 @@ +package endpoint + +type Endpoint struct { + DNSName string + Target string +} diff --git a/source/service.go b/source/service.go new file mode 100644 index 000000000..88b7afb69 --- /dev/null +++ b/source/service.go @@ -0,0 +1,63 @@ +package source + +import ( + "k8s.io/client-go/kubernetes" + "k8s.io/client-go/pkg/api/v1" + + "github.com/kubernetes-incubator/external-dns/endpoint" +) + +const ( + // The annotation used for figuring out which controller is responsible + controllerAnnotationKey = "external-dns.kubernetes.io/controller" + // The annotation used for defining the desired hostname + hostnameAnnotationKey = "external-dns.kubernetes.io/hostname" + // The value of the controller annotation so that we feel resposible + controllerAnnotationValue = "dns-controller" +) + +// 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 +// matches services' external entrypoints it will return a corresponding +// Endpoint object. +type ServiceSource struct { + Client kubernetes.Interface +} + +// Endpoints returns endpoint objects for each service that should be processed. +func (sc *ServiceSource) Endpoints() ([]endpoint.Endpoint, error) { + services, err := sc.Client.Core().Services(v1.NamespaceAll).List(v1.ListOptions{}) + if err != nil { + return nil, err + } + + endpoints := []endpoint.Endpoint{} + + for _, svc := range services.Items { + // Check controller annotation to see if we are responsible. + controller, exists := svc.Annotations[controllerAnnotationKey] + if exists && controller != controllerAnnotationValue { + continue + } + + // Get the desired hostname of the service from the annotation. + hostname, exists := svc.Annotations[hostnameAnnotationKey] + if !exists { + continue + } + + // Create an endpoint matching the desired hostname. + endpoint := endpoint.Endpoint{ + DNSName: hostname, + } + + // Create a corresponding endpoint for each configured external entrypoint. + for _, lb := range svc.Status.LoadBalancer.Ingress { + endpoint.Target = lb.IP + endpoints = append(endpoints, endpoint) + } + } + + return endpoints, nil +} diff --git a/source/service_test.go b/source/service_test.go new file mode 100644 index 000000000..c9f2acf0a --- /dev/null +++ b/source/service_test.go @@ -0,0 +1,201 @@ +package source + +import ( + "testing" + + "k8s.io/client-go/kubernetes/fake" + "k8s.io/client-go/pkg/api/v1" + + "github.com/kubernetes-incubator/external-dns/endpoint" +) + +// TestEndpoints tests that various services generate the correct endpoints. +func TestEndpoints(t *testing.T) { + for _, tc := range []struct { + namespace string + name string + annotations map[string]string + lbs []string + expected []endpoint.Endpoint + }{ + // Completely opted-out: no endpoints returned. + { + "testing", + "foo", + map[string]string{}, + []string{"1.2.3.4"}, + []endpoint.Endpoint{}, + }, + // Opt-in by setting desired hostname. + { + "testing", + "foo", + map[string]string{ + "external-dns.kubernetes.io/hostname": "foo.example.org", + }, + []string{"1.2.3.4"}, + []endpoint.Endpoint{ + {DNSName: "foo.example.org", Target: "1.2.3.4"}, + }, + }, + // Opt-in by setting desired hostname and this controller. + { + "testing", + "foo", + map[string]string{ + "external-dns.kubernetes.io/controller": "dns-controller", + "external-dns.kubernetes.io/hostname": "foo.example.org", + }, + []string{"1.2.3.4"}, + []endpoint.Endpoint{ + {DNSName: "foo.example.org", Target: "1.2.3.4"}, + }, + }, + // Opt-out by setting a different controller. + { + "testing", + "foo", + map[string]string{ + "external-dns.kubernetes.io/controller": "some-other-tool", + "external-dns.kubernetes.io/hostname": "foo.example.org", + }, + []string{"1.2.3.4"}, + []endpoint.Endpoint{}, + }, + // Make sure services are found in all namespaces. + { + "other-testing", + "foo", + map[string]string{ + "external-dns.kubernetes.io/hostname": "foo.example.org", + }, + []string{"1.2.3.4"}, + []endpoint.Endpoint{ + {DNSName: "foo.example.org", Target: "1.2.3.4"}, + }, + }, + // No external entrypoints lead to no endpoints. + { + "testing", + "foo", + map[string]string{ + "external-dns.kubernetes.io/hostname": "foo.example.org", + }, + []string{}, + []endpoint.Endpoint{}, + }, + // Multiple external entrypoints lead to multiple endpoints. + { + "testing", + "foo", + map[string]string{ + "external-dns.kubernetes.io/hostname": "foo.example.org", + }, + []string{"1.2.3.4", "8.8.8.8"}, + []endpoint.Endpoint{ + {DNSName: "foo.example.org", Target: "1.2.3.4"}, + {DNSName: "foo.example.org", Target: "8.8.8.8"}, + }, + }, + } { + // Create a Kubernetes testing client + kubernetes := fake.NewSimpleClientset() + + // Create a service to test against + ingresses := []v1.LoadBalancerIngress{} + for _, lb := range tc.lbs { + ingresses = append(ingresses, v1.LoadBalancerIngress{IP: lb}) + } + + service := &v1.Service{ + ObjectMeta: v1.ObjectMeta{ + Namespace: tc.namespace, + Name: tc.name, + Annotations: tc.annotations, + }, + Status: v1.ServiceStatus{ + LoadBalancer: v1.LoadBalancerStatus{ + Ingress: ingresses, + }, + }, + } + + _, err := kubernetes.Core().Services(service.Namespace).Create(service) + if err != nil { + t.Fatal(err) + } + + // Create our object under test and get the endpoints. + client := &ServiceSource{ + Client: kubernetes, + } + + endpoints, err := client.Endpoints() + if err != nil { + t.Fatal(err) + } + + // Validate returned endpoints against desired endpoints. + validateEndpoints(t, endpoints, tc.expected) + } +} + +func BenchmarkEndpoints(b *testing.B) { + kubernetes := fake.NewSimpleClientset() + + service := &v1.Service{ + ObjectMeta: v1.ObjectMeta{ + Namespace: "testing", + Name: "foo", + Annotations: map[string]string{ + "external-dns.kubernetes.io/hostname": "foo.example.org", + }, + }, + Status: v1.ServiceStatus{ + LoadBalancer: v1.LoadBalancerStatus{ + Ingress: []v1.LoadBalancerIngress{ + {IP: "1.2.3.4"}, + {IP: "8.8.8.8"}, + }, + }, + }, + } + + _, err := kubernetes.Core().Services(service.Namespace).Create(service) + if err != nil { + b.Fatal(err) + } + + client := &ServiceSource{ + Client: kubernetes, + } + + for i := 0; i < b.N; i++ { + _, err := client.Endpoints() + if err != nil { + b.Fatal(err) + } + } +} + +// test helper functions + +func validateEndpoints(t *testing.T, endpoints, expected []endpoint.Endpoint) { + if len(endpoints) != len(expected) { + t.Fatalf("expected %d endpoints, got %d", len(expected), len(endpoints)) + } + + for i := range endpoints { + validateEndpoint(t, endpoints[i], expected[i]) + } +} + +func validateEndpoint(t *testing.T, endpoint, expected endpoint.Endpoint) { + if endpoint.DNSName != expected.DNSName { + t.Errorf("expected %s, got %s", expected.DNSName, endpoint.DNSName) + } + + if endpoint.Target != expected.Target { + t.Errorf("expected %s, got %s", expected.Target, endpoint.Target) + } +}