From 5221b1d5257820d2721c98ef5c8d319220b49023 Mon Sep 17 00:00:00 2001 From: Hugome Date: Wed, 29 Jul 2020 03:25:23 +0200 Subject: [PATCH 01/13] feat: add gloo proxy source --- docs/tutorials/gloo-proxy.md | 101 +++++++++ main.go | 1 + pkg/apis/externaldns/types.go | 8 +- pkg/apis/externaldns/types_test.go | 4 + source/gloo.go | 200 ++++++++++++++++++ source/gloo_test.go | 320 +++++++++++++++++++++++++++++ source/store.go | 11 + 7 files changed, 643 insertions(+), 2 deletions(-) create mode 100644 docs/tutorials/gloo-proxy.md create mode 100644 source/gloo.go create mode 100644 source/gloo_test.go diff --git a/docs/tutorials/gloo-proxy.md b/docs/tutorials/gloo-proxy.md new file mode 100644 index 000000000..4938feb21 --- /dev/null +++ b/docs/tutorials/gloo-proxy.md @@ -0,0 +1,101 @@ +# Configuring ExternalDNS to use the Gloo Proxy Source +This tutorial describes how to configure ExternalDNS to use the Gloo Proxy source. +It is meant to supplement the other provider-specific setup tutorials. + +### Manifest (for clusters without RBAC enabled) +```yaml +apiVersion: apps/v1 +kind: Deployment +metadata: + name: external-dns +spec: + strategy: + type: Recreate + selector: + matchLabels: + app: external-dns + template: + metadata: + labels: + app: external-dns + spec: + containers: + - name: external-dns + # update this to the desired external-dns version + image: k8s.gcr.io/external-dns/external-dns:v0.7.6 + args: + - --source=gloo-proxy + - --gloo-namespace=custom-gloo-system # gloo system namespace. Omit to use the default (gloo-system) + - --provider=aws + - --registry=txt + - --txt-owner-id=my-identifier +``` + +### Manifest (for clusters with RBAC enabled) +Could be change if you have mulitple sources + +```yaml +apiVersion: v1 +kind: ServiceAccount +metadata: + name: external-dns +--- +apiVersion: rbac.authorization.k8s.io/v1beta1 +kind: ClusterRole +metadata: + name: external-dns +rules: +- apiGroups: [""] + resources: ["services","endpoints","pods"] + verbs: ["get","watch","list"] +- apiGroups: [""] + resources: ["nodes"] + verbs: ["list","watch"] +- apiGroups: ["gloo.solo.io"] + resources: ["proxies"] + verbs: ["get","watch","list"] +- apiGroups: ["gateway.solo.io"] + resources: ["virtualservices"] + verbs: ["get", "list", "watch"] +--- +apiVersion: rbac.authorization.k8s.io/v1beta1 +kind: ClusterRoleBinding +metadata: + name: external-dns-viewer +roleRef: + apiGroup: rbac.authorization.k8s.io + kind: ClusterRole + name: external-dns +subjects: +- kind: ServiceAccount + name: external-dns + namespace: default +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + name: external-dns +spec: + strategy: + type: Recreate + selector: + matchLabels: + app: external-dns + template: + metadata: + labels: + app: external-dns + spec: + serviceAccountName: external-dns + containers: + - name: external-dns + # update this to the desired external-dns version + image: k8s.gcr.io/external-dns/external-dns:v0.7.6 + args: + - --source=gloo-proxy + - --gloo-namespace=custom-gloo-system # gloo system namespace. Omit to use the default (gloo-system) + - --provider=aws + - --registry=txt + - --txt-owner-id=my-identifier +``` + diff --git a/main.go b/main.go index 09860c08d..75dc33868 100644 --- a/main.go +++ b/main.go @@ -120,6 +120,7 @@ func main() { CFUsername: cfg.CFUsername, CFPassword: cfg.CFPassword, ContourLoadBalancerService: cfg.ContourLoadBalancerService, + GlooNamespace: cfg.GlooNamespace, SkipperRouteGroupVersion: cfg.SkipperRouteGroupVersion, RequestTimeout: cfg.RequestTimeout, } diff --git a/pkg/apis/externaldns/types.go b/pkg/apis/externaldns/types.go index b8d43f462..1384730d6 100644 --- a/pkg/apis/externaldns/types.go +++ b/pkg/apis/externaldns/types.go @@ -45,6 +45,7 @@ type Config struct { KubeConfig string RequestTimeout time.Duration ContourLoadBalancerService string + GlooNamespace string SkipperRouteGroupVersion string Sources []string Namespace string @@ -167,6 +168,7 @@ var defaultConfig = &Config{ KubeConfig: "", RequestTimeout: time.Second * 30, ContourLoadBalancerService: "heptio-contour/contour", + GlooNamespace: "gloo-system", SkipperRouteGroupVersion: "zalando.org/v1", Sources: nil, Namespace: "", @@ -328,12 +330,14 @@ func (cfg *Config) ParseFlags(args []string) error { // Flags related to Contour app.Flag("contour-load-balancer", "The fully-qualified name of the Contour load balancer service. (default: heptio-contour/contour)").Default("heptio-contour/contour").StringVar(&cfg.ContourLoadBalancerService) + // Flags related to Gloo + app.Flag("gloo-namespace", "Gloo namespace. (default: gloo-system)").Default("gloo-system").StringVar(&cfg.GlooNamespace) + // Flags related to Skipper RouteGroup app.Flag("skipper-routegroup-groupversion", "The resource version for skipper routegroup").Default(source.DefaultRoutegroupVersion).StringVar(&cfg.SkipperRouteGroupVersion) // Flags related to processing sources - app.Flag("source", "The resource types that are queried for endpoints; specify multiple times for multiple sources (required, options: service, ingress, node, fake, connector, istio-gateway, istio-virtualservice, cloudfoundry, contour-ingressroute, contour-httpproxy, crd, empty, skipper-routegroup, openshift-route, ambassador-host)").Required().PlaceHolder("source").EnumsVar(&cfg.Sources, "service", "ingress", "node", "istio-gateway", "istio-virtualservice", "cloudfoundry", "contour-ingressroute", "contour-httpproxy", "fake", "connector", "crd", "empty", "skipper-routegroup", "openshift-route", "ambassador-host") - + app.Flag("source", "The resource types that are queried for endpoints; specify multiple times for multiple sources (required, options: service, ingress, node, fake, connector, istio-gateway, istio-virtualservice, cloudfoundry, contour-ingressroute, contour-httpproxy, gloo-proxy, crd, empty, skipper-routegroup, openshift-route, ambassador-host)").Required().PlaceHolder("source").EnumsVar(&cfg.Sources, "service", "ingress", "node", "istio-gateway", "istio-virtualservice", "cloudfoundry", "contour-ingressroute", "contour-httpproxy", "gloo-proxy", "fake", "connector", "crd", "empty", "skipper-routegroup", "openshift-route", "ambassador-host") app.Flag("namespace", "Limit sources of endpoints to a specific namespace (default: all namespaces)").Default(defaultConfig.Namespace).StringVar(&cfg.Namespace) app.Flag("annotation-filter", "Filter sources managed by external-dns via annotation using label selector semantics (default: all sources)").Default(defaultConfig.AnnotationFilter).StringVar(&cfg.AnnotationFilter) app.Flag("label-filter", "Filter sources managed by external-dns via label selector when listing all resources; currently only supported by source CRD").Default(defaultConfig.LabelFilter).StringVar(&cfg.LabelFilter) diff --git a/pkg/apis/externaldns/types_test.go b/pkg/apis/externaldns/types_test.go index df37180d4..83f853692 100644 --- a/pkg/apis/externaldns/types_test.go +++ b/pkg/apis/externaldns/types_test.go @@ -35,6 +35,7 @@ var ( KubeConfig: "", RequestTimeout: time.Second * 30, ContourLoadBalancerService: "heptio-contour/contour", + GlooNamespace: "gloo-system", SkipperRouteGroupVersion: "zalando.org/v1", Sources: []string{"service"}, Namespace: "", @@ -114,6 +115,7 @@ var ( KubeConfig: "/some/path", RequestTimeout: time.Second * 77, ContourLoadBalancerService: "heptio-contour-other/contour-other", + GlooNamespace: "gloo-not-system", SkipperRouteGroupVersion: "zalando.org/v2", Sources: []string{"service", "ingress", "connector"}, Namespace: "namespace", @@ -220,6 +222,7 @@ func TestParseFlags(t *testing.T) { "--kubeconfig=/some/path", "--request-timeout=77s", "--contour-load-balancer=heptio-contour-other/contour-other", + "--gloo-namespace=gloo-not-system", "--skipper-routegroup-groupversion=zalando.org/v2", "--source=service", "--source=ingress", @@ -317,6 +320,7 @@ func TestParseFlags(t *testing.T) { "EXTERNAL_DNS_KUBECONFIG": "/some/path", "EXTERNAL_DNS_REQUEST_TIMEOUT": "77s", "EXTERNAL_DNS_CONTOUR_LOAD_BALANCER": "heptio-contour-other/contour-other", + "EXTERNAL_DNS_GLOO_NAMESPACE": "gloo-not-system", "EXTERNAL_DNS_SKIPPER_ROUTEGROUP_GROUPVERSION": "zalando.org/v2", "EXTERNAL_DNS_SOURCE": "service\ningress\nconnector", "EXTERNAL_DNS_NAMESPACE": "namespace", diff --git a/source/gloo.go b/source/gloo.go new file mode 100644 index 000000000..845ccdee0 --- /dev/null +++ b/source/gloo.go @@ -0,0 +1,200 @@ +/* +Copyright 2020n 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" + "encoding/json" + "strings" + + log "github.com/sirupsen/logrus" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime/schema" + "k8s.io/client-go/dynamic" + "k8s.io/client-go/kubernetes" + + "sigs.k8s.io/external-dns/endpoint" +) + +var ( + proxyGVR = schema.GroupVersionResource{ + Group: "gloo.solo.io", + Version: "v1", + Resource: "proxies", + } + virtualServiceGVR = schema.GroupVersionResource{ + Group: "gateway.solo.io", + Version: "v1", + Resource: "virtualservices", + } +) + +// Basic redefinition of "Proxy" CRD : https://github.com/solo-io/gloo/blob/v1.4.6/projects/gloo/pkg/api/v1/proxy.pb.go +type proxy struct { + metav1.TypeMeta `json:",inline"` + Metadata metav1.ObjectMeta `json:"metadata,omitempty"` + Spec proxySpec `json:"spec,omitempty"` +} + +type proxySpec struct { + Listeners []proxySpecListener `json:"listeners,omitempty"` +} + +type proxySpecListener struct { + HTTPListener proxySpecHTTPListener `json:"httpListener,omitempty"` +} + +type proxySpecHTTPListener struct { + VirtualHosts []proxyVirtualHost `json:"virtualHosts,omitempty"` +} + +type proxyVirtualHost struct { + Domains []string `json:"domains,omitempty"` + Metadata proxyVirtualHostMetadata `json:"metadata,omitempty"` +} + +type proxyVirtualHostMetadata struct { + Source []proxyVirtualHostMetadataSource `json:"sources,omitempty"` +} + +type proxyVirtualHostMetadataSource struct { + Kind string `json:"kind,omitempty"` + Name string `json:"name,omitempty"` + Namespace string `json:"namespace,omitempty"` +} + +type glooSource struct { + dynamicKubeClient dynamic.Interface + kubeClient kubernetes.Interface + glooNamespace string +} + +// NewGlooSource creates a new glooSource with the given config +func NewGlooSource(dynamicKubeClient dynamic.Interface, kubeClient kubernetes.Interface, glooNamespace string) (Source, error) { + return &glooSource{ + dynamicKubeClient, + kubeClient, + glooNamespace, + }, nil +} + +func (gs *glooSource) AddEventHandler(ctx context.Context, handler func()) { +} + +// Endpoints returns endpoint objects +func (gs *glooSource) Endpoints(ctx context.Context) ([]*endpoint.Endpoint, error) { + endpoints := []*endpoint.Endpoint{} + + proxies, err := gs.dynamicKubeClient.Resource(proxyGVR).Namespace(gs.glooNamespace).List(ctx, metav1.ListOptions{}) + if err != nil { + return nil, err + } + for _, obj := range proxies.Items { + proxy := proxy{} + jsonString, err := obj.MarshalJSON() + if err != nil { + return nil, err + } + err = json.Unmarshal(jsonString, &proxy) + if err != nil { + return nil, err + } + log.Debugf("Gloo: Find %s proxy", proxy.Metadata.Name) + proxyTargets, err := gs.proxyTargets(ctx, proxy.Metadata.Name) + if err != nil { + return nil, err + } + log.Debugf("Gloo[%s]: Find %d target(s) (%+v)", proxy.Metadata.Name, len(proxyTargets), proxyTargets) + proxyEndpoints, err := gs.generateEndpointsFromProxy(ctx, &proxy, proxyTargets) + if err != nil { + return nil, err + } + log.Debugf("Gloo[%s]: Generate %d endpoint(s)", proxy.Metadata.Name, len(proxyEndpoints)) + endpoints = append(endpoints, proxyEndpoints...) + } + return endpoints, nil +} + +func (gs *glooSource) generateEndpointsFromProxy(ctx context.Context, proxy *proxy, targets endpoint.Targets) ([]*endpoint.Endpoint, error) { + endpoints := []*endpoint.Endpoint{} + for _, listener := range proxy.Spec.Listeners { + for _, virtualHost := range listener.HTTPListener.VirtualHosts { + annotations, err := gs.annotationsFromProxySource(ctx, virtualHost) + if err != nil { + return nil, err + } + ttl, err := getTTLFromAnnotations(annotations) + if err != nil { + return nil, err + } + providerSpecific, setIdentifier := getProviderSpecificAnnotations(annotations) + for _, domain := range virtualHost.Domains { + endpoints = append(endpoints, endpointsForHostname(strings.TrimSuffix(domain, "."), targets, ttl, providerSpecific, setIdentifier)...) + } + } + } + return endpoints, nil +} + +func (gs *glooSource) annotationsFromProxySource(ctx context.Context, virtualHost proxyVirtualHost) (map[string]string, error) { + annotations := map[string]string{} + for _, src := range virtualHost.Metadata.Source { + kind := sourceKind(src.Kind) + if kind != nil { + source, err := gs.dynamicKubeClient.Resource(*kind).Namespace(src.Namespace).Get(ctx, src.Name, metav1.GetOptions{}) + if err != nil { + return nil, err + } + for key, value := range source.GetAnnotations() { + annotations[key] = value + } + } + } + return annotations, nil +} + +func (gs *glooSource) proxyTargets(ctx context.Context, name string) (endpoint.Targets, error) { + svc, err := gs.kubeClient.CoreV1().Services(gs.glooNamespace).Get(ctx, name, metav1.GetOptions{}) + if err != nil { + return nil, err + } + + var targets endpoint.Targets + switch svc.Spec.Type { + case corev1.ServiceTypeLoadBalancer: + for _, lb := range svc.Status.LoadBalancer.Ingress { + if lb.IP != "" { + targets = append(targets, lb.IP) + } + if lb.Hostname != "" { + targets = append(targets, lb.Hostname) + } + } + default: + log.WithField("gateway", name).WithField("service", svc).Warn("Gloo: Proxy service type not supported") + } + return targets, nil +} + +func sourceKind(kind string) *schema.GroupVersionResource { + switch kind { + case "*v1.VirtualService": + return &virtualServiceGVR + } + return nil +} diff --git a/source/gloo_test.go b/source/gloo_test.go new file mode 100644 index 000000000..dfc4d9436 --- /dev/null +++ b/source/gloo_test.go @@ -0,0 +1,320 @@ +/* +Copyright 2020n 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" + "encoding/json" + "testing" + + "github.com/stretchr/testify/assert" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/runtime" + fakeDynamic "k8s.io/client-go/dynamic/fake" + fakeKube "k8s.io/client-go/kubernetes/fake" + "sigs.k8s.io/external-dns/endpoint" +) + +// This is a compile-time validation that glooSource is a Source. +var _ Source = &glooSource{} + +const defaultGlooNamespace = "gloo-system" + +// Internal proxy test +var internalProxy = proxy{ + TypeMeta: metav1.TypeMeta{ + APIVersion: proxyGVR.GroupVersion().String(), + Kind: "Proxy", + }, + Metadata: metav1.ObjectMeta{ + Name: "internal", + Namespace: defaultGlooNamespace, + }, + Spec: proxySpec{ + Listeners: []proxySpecListener{ + { + HTTPListener: proxySpecHTTPListener{ + VirtualHosts: []proxyVirtualHost{ + { + Domains: []string{"a.test", "b.test"}, + Metadata: proxyVirtualHostMetadata{ + Source: []proxyVirtualHostMetadataSource{ + { + Kind: "*v1.Unknown", + Name: "my-unknown-svc", + Namespace: "unknown", + }, + }, + }, + }, + { + Domains: []string{"c.test"}, + Metadata: proxyVirtualHostMetadata{ + Source: []proxyVirtualHostMetadataSource{ + { + Kind: "*v1.VirtualService", + Name: "my-internal-svc", + Namespace: "internal", + }, + }, + }, + }, + }, + }, + }, + }, + }, +} + +var internalProxySvc = corev1.Service{ + ObjectMeta: metav1.ObjectMeta{ + Name: internalProxy.Metadata.Name, + Namespace: internalProxy.Metadata.Namespace, + }, + Spec: corev1.ServiceSpec{ + Type: corev1.ServiceTypeLoadBalancer, + }, + Status: corev1.ServiceStatus{ + LoadBalancer: corev1.LoadBalancerStatus{ + Ingress: []corev1.LoadBalancerIngress{ + corev1.LoadBalancerIngress{ + IP: "203.0.113.1", + }, + corev1.LoadBalancerIngress{ + IP: "203.0.113.2", + }, + corev1.LoadBalancerIngress{ + IP: "203.0.113.3", + }, + }, + }, + }, +} + +var internalProxySource = metav1.PartialObjectMetadata{ + TypeMeta: metav1.TypeMeta{ + APIVersion: virtualServiceGVR.GroupVersion().String(), + Kind: "VirtualService", + }, + ObjectMeta: metav1.ObjectMeta{ + Name: internalProxy.Spec.Listeners[0].HTTPListener.VirtualHosts[1].Metadata.Source[0].Name, + Namespace: internalProxy.Spec.Listeners[0].HTTPListener.VirtualHosts[1].Metadata.Source[0].Namespace, + Annotations: map[string]string{ + "external-dns.alpha.kubernetes.io/ttl": "42", + "external-dns.alpha.kubernetes.io/aws-geolocation-country-code": "LU", + "external-dns.alpha.kubernetes.io/set-identifier": "identifier", + }, + }, +} + +// External proxy test +var externalProxy = proxy{ + TypeMeta: metav1.TypeMeta{ + APIVersion: proxyGVR.GroupVersion().String(), + Kind: "Proxy", + }, + Metadata: metav1.ObjectMeta{ + Name: "external", + Namespace: defaultGlooNamespace, + }, + Spec: proxySpec{ + Listeners: []proxySpecListener{ + { + HTTPListener: proxySpecHTTPListener{ + VirtualHosts: []proxyVirtualHost{ + { + Domains: []string{"d.test"}, + Metadata: proxyVirtualHostMetadata{ + Source: []proxyVirtualHostMetadataSource{ + { + Kind: "*v1.Unknown", + Name: "my-unknown-svc", + Namespace: "unknown", + }, + }, + }, + }, + { + Domains: []string{"e.test"}, + Metadata: proxyVirtualHostMetadata{ + Source: []proxyVirtualHostMetadataSource{ + { + Kind: "*v1.VirtualService", + Name: "my-external-svc", + Namespace: "external", + }, + }, + }, + }, + }, + }, + }, + }, + }, +} + +var externalProxySvc = corev1.Service{ + ObjectMeta: metav1.ObjectMeta{ + Name: externalProxy.Metadata.Name, + Namespace: externalProxy.Metadata.Namespace, + }, + Spec: corev1.ServiceSpec{ + Type: corev1.ServiceTypeLoadBalancer, + }, + Status: corev1.ServiceStatus{ + LoadBalancer: corev1.LoadBalancerStatus{ + Ingress: []corev1.LoadBalancerIngress{ + corev1.LoadBalancerIngress{ + Hostname: "a.example.org", + }, + corev1.LoadBalancerIngress{ + Hostname: "b.example.org", + }, + corev1.LoadBalancerIngress{ + Hostname: "c.example.org", + }, + }, + }, + }, +} + +var externalProxySource = metav1.PartialObjectMetadata{ + TypeMeta: metav1.TypeMeta{ + APIVersion: virtualServiceGVR.GroupVersion().String(), + Kind: "VirtualService", + }, + ObjectMeta: metav1.ObjectMeta{ + Name: externalProxy.Spec.Listeners[0].HTTPListener.VirtualHosts[1].Metadata.Source[0].Name, + Namespace: externalProxy.Spec.Listeners[0].HTTPListener.VirtualHosts[1].Metadata.Source[0].Namespace, + Annotations: map[string]string{ + "external-dns.alpha.kubernetes.io/ttl": "24", + "external-dns.alpha.kubernetes.io/aws-geolocation-country-code": "JP", + "external-dns.alpha.kubernetes.io/set-identifier": "identifier-external", + }, + }, +} + +func TestGlooSource(t *testing.T) { + fakeKubernetesClient := fakeKube.NewSimpleClientset() + fakeDynamicClient := fakeDynamic.NewSimpleDynamicClient(runtime.NewScheme()) + + source, err := NewGlooSource(fakeDynamicClient, fakeKubernetesClient, defaultGlooNamespace) + assert.NoError(t, err) + assert.NotNil(t, source) + + internalProxyUnstructured := unstructured.Unstructured{} + externalProxyUnstructured := unstructured.Unstructured{} + + internalProxySourceUnstructured := unstructured.Unstructured{} + externalProxySourceUnstructured := unstructured.Unstructured{} + + internalProxyAsJSON, err := json.Marshal(internalProxy) + assert.NoError(t, err) + + externalProxyAsJSON, err := json.Marshal(externalProxy) + assert.NoError(t, err) + + internalProxySvcAsJSON, err := json.Marshal(internalProxySource) + assert.NoError(t, err) + + externalProxySvcAsJSON, err := json.Marshal(externalProxySource) + assert.NoError(t, err) + + assert.NoError(t, internalProxyUnstructured.UnmarshalJSON(internalProxyAsJSON)) + assert.NoError(t, externalProxyUnstructured.UnmarshalJSON(externalProxyAsJSON)) + + assert.NoError(t, internalProxySourceUnstructured.UnmarshalJSON(internalProxySvcAsJSON)) + assert.NoError(t, externalProxySourceUnstructured.UnmarshalJSON(externalProxySvcAsJSON)) + + // Create proxy resources + _, err = fakeDynamicClient.Resource(proxyGVR).Namespace(defaultGlooNamespace).Create(context.Background(), &internalProxyUnstructured, metav1.CreateOptions{}) + assert.NoError(t, err) + _, err = fakeDynamicClient.Resource(proxyGVR).Namespace(defaultGlooNamespace).Create(context.Background(), &externalProxyUnstructured, metav1.CreateOptions{}) + assert.NoError(t, err) + + // Create proxy source + _, err = fakeDynamicClient.Resource(virtualServiceGVR).Namespace(internalProxySource.Namespace).Create(context.Background(), &internalProxySourceUnstructured, metav1.CreateOptions{}) + assert.NoError(t, err) + _, err = fakeDynamicClient.Resource(virtualServiceGVR).Namespace(externalProxySource.Namespace).Create(context.Background(), &externalProxySourceUnstructured, metav1.CreateOptions{}) + assert.NoError(t, err) + + // Create proxy service resources + _, err = fakeKubernetesClient.CoreV1().Services(internalProxySvc.GetNamespace()).Create(context.Background(), &internalProxySvc, metav1.CreateOptions{}) + assert.NoError(t, err) + _, err = fakeKubernetesClient.CoreV1().Services(externalProxySvc.GetNamespace()).Create(context.Background(), &externalProxySvc, metav1.CreateOptions{}) + assert.NoError(t, err) + + endpoints, err := source.Endpoints(context.Background()) + assert.NoError(t, err) + assert.Len(t, endpoints, 5) + assert.Equal(t, endpoints, []*endpoint.Endpoint{ + &endpoint.Endpoint{ + DNSName: "a.test", + Targets: []string{internalProxySvc.Status.LoadBalancer.Ingress[0].IP, internalProxySvc.Status.LoadBalancer.Ingress[1].IP, internalProxySvc.Status.LoadBalancer.Ingress[2].IP}, + RecordType: endpoint.RecordTypeA, + RecordTTL: 0, + Labels: endpoint.Labels{}, + ProviderSpecific: endpoint.ProviderSpecific{}, + }, + &endpoint.Endpoint{ + DNSName: "b.test", + Targets: []string{internalProxySvc.Status.LoadBalancer.Ingress[0].IP, internalProxySvc.Status.LoadBalancer.Ingress[1].IP, internalProxySvc.Status.LoadBalancer.Ingress[2].IP}, + RecordType: endpoint.RecordTypeA, + RecordTTL: 0, + Labels: endpoint.Labels{}, + ProviderSpecific: endpoint.ProviderSpecific{}, + }, + &endpoint.Endpoint{ + DNSName: "c.test", + Targets: []string{internalProxySvc.Status.LoadBalancer.Ingress[0].IP, internalProxySvc.Status.LoadBalancer.Ingress[1].IP, internalProxySvc.Status.LoadBalancer.Ingress[2].IP}, + RecordType: endpoint.RecordTypeA, + SetIdentifier: "identifier", + RecordTTL: 42, + Labels: endpoint.Labels{}, + ProviderSpecific: endpoint.ProviderSpecific{ + endpoint.ProviderSpecificProperty{ + Name: "aws/geolocation-country-code", + Value: "LU", + }, + }, + }, + &endpoint.Endpoint{ + DNSName: "d.test", + Targets: []string{externalProxySvc.Status.LoadBalancer.Ingress[0].Hostname, externalProxySvc.Status.LoadBalancer.Ingress[1].Hostname, externalProxySvc.Status.LoadBalancer.Ingress[2].Hostname}, + RecordType: endpoint.RecordTypeCNAME, + RecordTTL: 0, + Labels: endpoint.Labels{}, + ProviderSpecific: endpoint.ProviderSpecific{}, + }, + &endpoint.Endpoint{ + DNSName: "e.test", + Targets: []string{externalProxySvc.Status.LoadBalancer.Ingress[0].Hostname, externalProxySvc.Status.LoadBalancer.Ingress[1].Hostname, externalProxySvc.Status.LoadBalancer.Ingress[2].Hostname}, + RecordType: endpoint.RecordTypeCNAME, + SetIdentifier: "identifier-external", + RecordTTL: 24, + Labels: endpoint.Labels{}, + ProviderSpecific: endpoint.ProviderSpecific{ + endpoint.ProviderSpecificProperty{ + Name: "aws/geolocation-country-code", + Value: "JP", + }, + }, + }, + }) +} diff --git a/source/store.go b/source/store.go index 0cb6641fb..0c934c407 100644 --- a/source/store.go +++ b/source/store.go @@ -61,6 +61,7 @@ type Config struct { CFUsername string CFPassword string ContourLoadBalancerService string + GlooNamespace string SkipperRouteGroupVersion string RequestTimeout time.Duration } @@ -239,6 +240,16 @@ func BuildWithConfig(source string, p ClientGenerator, cfg *Config) (Source, err return nil, err } return NewContourHTTPProxySource(dynamicClient, cfg.Namespace, cfg.AnnotationFilter, cfg.FQDNTemplate, cfg.CombineFQDNAndAnnotation, cfg.IgnoreHostnameAnnotation) + case "gloo-proxy": + kubernetesClient, err := p.KubeClient() + if err != nil { + return nil, err + } + dynamicClient, err := p.DynamicKubernetesClient() + if err != nil { + return nil, err + } + return NewGlooSource(dynamicClient, kubernetesClient, cfg.GlooNamespace) case "openshift-route": ocpClient, err := p.OpenShiftClient() if err != nil { From df6ae8c15fa4a4aa73cc422a32efe9022e6c043c Mon Sep 17 00:00:00 2001 From: Nick Nellis Date: Mon, 8 Mar 2021 10:47:08 -0600 Subject: [PATCH 02/13] updated to latest Istio 1.7 libs --- go.mod | 4 ++-- go.sum | 4 ++++ 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/go.mod b/go.mod index c28b520bf..feff79fa2 100644 --- a/go.mod +++ b/go.mod @@ -64,8 +64,8 @@ require ( gopkg.in/ns1/ns1-go.v2 v2.0.0-20190322154155-0dafb5275fd1 gopkg.in/yaml.v2 v2.3.0 honnef.co/go/tools v0.0.1-2020.1.4 // indirect - istio.io/api v0.0.0-20200529165953-72dad51d4ffc - istio.io/client-go v0.0.0-20200529172309-31c16ea3f751 + istio.io/api v0.0.0-20210128181506-0c4b8e54850f + istio.io/client-go v0.0.0-20210128182905-ee2edd059e02 k8s.io/api v0.18.8 k8s.io/apimachinery v0.18.8 k8s.io/client-go v0.18.8 diff --git a/go.sum b/go.sum index c0c41bd25..23127e8a7 100644 --- a/go.sum +++ b/go.sum @@ -1227,8 +1227,12 @@ honnef.co/go/tools v0.0.1-2020.1.4 h1:UoveltGrhghAA7ePc+e+QYDHXrBps2PqFZiHkGR/xK honnef.co/go/tools v0.0.1-2020.1.4/go.mod h1:X/FiERA/W4tHapMX5mGpAtMSVEeEUOyHaw9vFzvIQ3k= istio.io/api v0.0.0-20200529165953-72dad51d4ffc h1:cR9GmbIBAz3FnY3tgs1SRn/uiznhtvG+mZBfD1p2vIA= istio.io/api v0.0.0-20200529165953-72dad51d4ffc/go.mod h1:kyq3g5w42zl/AKlbzDGppYpGMQYMYMyZKeq0/eexML8= +istio.io/api v0.0.0-20210128181506-0c4b8e54850f h1:zUFsawgPj5oI9p5cf91YCExRlxLIVsEkIunN9ODUSJs= +istio.io/api v0.0.0-20210128181506-0c4b8e54850f/go.mod h1:88HN3o1fSD1jo+Z1WTLlJfMm9biopur6Ct9BFKjiB64= istio.io/client-go v0.0.0-20200529172309-31c16ea3f751 h1:yH62fTmV+5l1XVTWcomsc1jjH/oH9u/tTgn5NVmdIac= istio.io/client-go v0.0.0-20200529172309-31c16ea3f751/go.mod h1:4SGvmmus5HNFdqQsIL+uQO1PbAhjQKtSjMTqwsvYHlg= +istio.io/client-go v0.0.0-20210128182905-ee2edd059e02 h1:ZA8Y2gKkKtEeYuKfqlEzIBDfU4IE5uIAdsXDeD41T9w= +istio.io/client-go v0.0.0-20210128182905-ee2edd059e02/go.mod h1:oXMjFUWhxlReUSbg4i3GjKgOhSX1WgD68ZNlHQEcmQg= istio.io/gogo-genproto v0.0.0-20190904133402-ee07f2785480/go.mod h1:uKtbae4K9k2rjjX4ToV0l6etglbc1i7gqQ94XdkshzY= istio.io/gogo-genproto v0.0.0-20190930162913-45029607206a h1:w7zILua2dnYo9CxImhpNW4NE/8ZxEoc/wfBfHrhUhrE= istio.io/gogo-genproto v0.0.0-20190930162913-45029607206a/go.mod h1:OzpAts7jljZceG4Vqi5/zXy/pOg1b209T3jb7Nv5wIs= From a143a6cd59177f1f72e2f173bc5cfdbe8d723157 Mon Sep 17 00:00:00 2001 From: Dinar Valeev Date: Mon, 8 Mar 2021 19:57:24 +0100 Subject: [PATCH 03/13] Generate CRD with controller-gen Signed-off-by: Dinar Valeev --- Makefile | 22 ++++++++++ .../contributing/crd-source/crd-manifest.yaml | 40 +++++++++++++++++-- endpoint/endpoint.go | 5 +++ 3 files changed, 63 insertions(+), 4 deletions(-) diff --git a/Makefile b/Makefile index 0f2b274e8..1818baac1 100644 --- a/Makefile +++ b/Makefile @@ -27,6 +27,23 @@ cover: cover-html: cover go tool cover -html cover.out +# find or download controller-gen +# download controller-gen if necessary +controller-gen: +ifeq (, $(shell which controller-gen)) + @{ \ + set -e ;\ + CONTROLLER_GEN_TMP_DIR=$$(mktemp -d) ;\ + cd $$CONTROLLER_GEN_TMP_DIR ;\ + go mod init tmp ;\ + go get sigs.k8s.io/controller-tools/cmd/controller-gen@v0.5.0 ;\ + rm -rf $$CONTROLLER_GEN_TMP_DIR ;\ + } +CONTROLLER_GEN=$(GOBIN)/controller-gen +else +CONTROLLER_GEN=$(shell which controller-gen) +endif + .PHONY: go-lint # Run the golangci-lint tool @@ -51,6 +68,11 @@ licensecheck: # Run all the linters lint: licensecheck go-lint +.PHONY: crd + +# generates CRD using controller-gen +crd: controller-gen + ${CONTROLLER_GEN} crd:crdVersions=v1beta1 paths="./endpoint/..." output:crd:stdout > docs/contributing/crd-source/crd-manifest.yaml # The verify target runs tasks similar to the CI tasks, but without code coverage .PHONY: verify test diff --git a/docs/contributing/crd-source/crd-manifest.yaml b/docs/contributing/crd-source/crd-manifest.yaml index 842928690..ea801b11b 100644 --- a/docs/contributing/crd-source/crd-manifest.yaml +++ b/docs/contributing/crd-source/crd-manifest.yaml @@ -1,16 +1,19 @@ + +--- apiVersion: apiextensions.k8s.io/v1beta1 kind: CustomResourceDefinition metadata: + annotations: + controller-gen.kubebuilder.io/version: v0.5.0 creationTimestamp: null - labels: - api: externaldns - kubebuilder.k8s.io: 1.0.0 name: dnsendpoints.externaldns.k8s.io spec: group: externaldns.k8s.io names: kind: DNSEndpoint + listKind: DNSEndpointList plural: dnsendpoints + singular: dnsendpoint scope: Namespaced subresources: status: {} @@ -18,35 +21,51 @@ spec: openAPIV3Schema: properties: apiVersion: + description: 'APIVersion defines the versioned schema of this representation of an object. Servers should convert recognized schemas to the latest internal value, and may reject unrecognized values. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources' type: string kind: + description: 'Kind is a string value representing the REST resource this object represents. Servers may infer this from the endpoint the client submits requests to. Cannot be updated. In CamelCase. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds' type: string metadata: type: object spec: + description: DNSEndpointSpec defines the desired state of DNSEndpoint properties: endpoints: items: + description: Endpoint is a high-level way of a connection between a service and an IP properties: dnsName: + description: The hostname of the DNS record type: string labels: + additionalProperties: + type: string + description: Labels stores labels defined for the Endpoint type: object providerSpecific: + description: ProviderSpecific stores provider specific config items: + description: ProviderSpecificProperty holds the name and value of a configuration which is specific to individual DNS providers properties: - name: + name: type: string value: type: string type: object type: array recordTTL: + description: TTL for the record format: int64 type: integer recordType: + description: RecordType type of record, e.g. CNAME, A, SRV, TXT etc + type: string + setIdentifier: + description: Identifier to distinguish multiple records with the same name and type (e.g. Route53 records with routing policies other than 'simple') type: string targets: + description: The targets the DNS record points to items: type: string type: array @@ -54,9 +73,22 @@ spec: type: array type: object status: + description: DNSEndpointStatus defines the observed state of DNSEndpoint properties: observedGeneration: + description: The generation observed by the external-dns controller. format: int64 type: integer type: object + type: object version: v1alpha1 + versions: + - name: v1alpha1 + served: true + storage: true +status: + acceptedNames: + kind: "" + plural: "" + conditions: [] + storedVersions: [] diff --git a/endpoint/endpoint.go b/endpoint/endpoint.go index 07bd3ea4b..7d7be8e00 100644 --- a/endpoint/endpoint.go +++ b/endpoint/endpoint.go @@ -213,8 +213,12 @@ type DNSEndpointStatus struct { // DNSEndpoint is a contract that a user-specified CRD must implement to be used as a source for external-dns. // The user-specified CRD should also have the status sub-resource. // +k8s:openapi-gen=true +// +groupName=externaldns.k8s.io // +kubebuilder:resource:path=dnsendpoints +// +kubebuilder:object:root=true // +kubebuilder:subresource:status +// +versionName=v1alpha1 + type DNSEndpoint struct { metav1.TypeMeta `json:",inline"` metav1.ObjectMeta `json:"metadata,omitempty"` @@ -223,6 +227,7 @@ type DNSEndpoint struct { Status DNSEndpointStatus `json:"status,omitempty"` } +// +kubebuilder:object:root=true // DNSEndpointList is a list of DNSEndpoint objects type DNSEndpointList struct { metav1.TypeMeta `json:",inline"` From 544d245464bfda2d61b986c4e9a5cb8bca0e2056 Mon Sep 17 00:00:00 2001 From: Dinar Valeev Date: Mon, 8 Mar 2021 20:04:09 +0100 Subject: [PATCH 04/13] Update apiextentions to v1 v1 is available since kubernetes 1.16, since then v1beta1 is deprecated and will be dropped in kubernetes 1.22 Signed-off-by: Dinar Valeev --- Makefile | 2 +- .../contributing/crd-source/crd-manifest.yaml | 135 +++++++++--------- 2 files changed, 68 insertions(+), 69 deletions(-) diff --git a/Makefile b/Makefile index 1818baac1..fa6f2bd85 100644 --- a/Makefile +++ b/Makefile @@ -72,7 +72,7 @@ lint: licensecheck go-lint # generates CRD using controller-gen crd: controller-gen - ${CONTROLLER_GEN} crd:crdVersions=v1beta1 paths="./endpoint/..." output:crd:stdout > docs/contributing/crd-source/crd-manifest.yaml + ${CONTROLLER_GEN} crd:crdVersions=v1 paths="./endpoint/..." output:crd:stdout > docs/contributing/crd-source/crd-manifest.yaml # The verify target runs tasks similar to the CI tasks, but without code coverage .PHONY: verify test diff --git a/docs/contributing/crd-source/crd-manifest.yaml b/docs/contributing/crd-source/crd-manifest.yaml index ea801b11b..4fb16468a 100644 --- a/docs/contributing/crd-source/crd-manifest.yaml +++ b/docs/contributing/crd-source/crd-manifest.yaml @@ -1,6 +1,6 @@ --- -apiVersion: apiextensions.k8s.io/v1beta1 +apiVersion: apiextensions.k8s.io/v1 kind: CustomResourceDefinition metadata: annotations: @@ -15,77 +15,76 @@ spec: plural: dnsendpoints singular: dnsendpoint scope: Namespaced - subresources: - status: {} - validation: - openAPIV3Schema: - properties: - apiVersion: - description: 'APIVersion defines the versioned schema of this representation of an object. Servers should convert recognized schemas to the latest internal value, and may reject unrecognized values. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources' - type: string - kind: - description: 'Kind is a string value representing the REST resource this object represents. Servers may infer this from the endpoint the client submits requests to. Cannot be updated. In CamelCase. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds' - type: string - metadata: - type: object - spec: - description: DNSEndpointSpec defines the desired state of DNSEndpoint - properties: - endpoints: - items: - description: Endpoint is a high-level way of a connection between a service and an IP - properties: - dnsName: - description: The hostname of the DNS record - type: string - labels: - additionalProperties: - type: string - description: Labels stores labels defined for the Endpoint - type: object - providerSpecific: - description: ProviderSpecific stores provider specific config - items: - description: ProviderSpecificProperty holds the name and value of a configuration which is specific to individual DNS providers - properties: - name: - type: string - value: - type: string - type: object - type: array - recordTTL: - description: TTL for the record - format: int64 - type: integer - recordType: - description: RecordType type of record, e.g. CNAME, A, SRV, TXT etc - type: string - setIdentifier: - description: Identifier to distinguish multiple records with the same name and type (e.g. Route53 records with routing policies other than 'simple') - type: string - targets: - description: The targets the DNS record points to - items: - type: string - type: array - type: object - type: array - type: object - status: - description: DNSEndpointStatus defines the observed state of DNSEndpoint - properties: - observedGeneration: - description: The generation observed by the external-dns controller. - format: int64 - type: integer - type: object - type: object - version: v1alpha1 versions: - name: v1alpha1 + schema: + openAPIV3Schema: + properties: + apiVersion: + description: 'APIVersion defines the versioned schema of this representation of an object. Servers should convert recognized schemas to the latest internal value, and may reject unrecognized values. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources' + type: string + kind: + description: 'Kind is a string value representing the REST resource this object represents. Servers may infer this from the endpoint the client submits requests to. Cannot be updated. In CamelCase. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds' + type: string + metadata: + type: object + spec: + description: DNSEndpointSpec defines the desired state of DNSEndpoint + properties: + endpoints: + items: + description: Endpoint is a high-level way of a connection between a service and an IP + properties: + dnsName: + description: The hostname of the DNS record + type: string + labels: + additionalProperties: + type: string + description: Labels stores labels defined for the Endpoint + type: object + providerSpecific: + description: ProviderSpecific stores provider specific config + items: + description: ProviderSpecificProperty holds the name and value of a configuration which is specific to individual DNS providers + properties: + name: + type: string + value: + type: string + type: object + type: array + recordTTL: + description: TTL for the record + format: int64 + type: integer + recordType: + description: RecordType type of record, e.g. CNAME, A, SRV, TXT etc + type: string + setIdentifier: + description: Identifier to distinguish multiple records with the same name and type (e.g. Route53 records with routing policies other than 'simple') + type: string + targets: + description: The targets the DNS record points to + items: + type: string + type: array + type: object + type: array + type: object + status: + description: DNSEndpointStatus defines the observed state of DNSEndpoint + properties: + observedGeneration: + description: The generation observed by the external-dns controller. + format: int64 + type: integer + type: object + type: object served: true storage: true + subresources: + status: {} status: acceptedNames: kind: "" From 75bfb2c86ecc76de2ec7a03953ad15b0487ea6cb Mon Sep 17 00:00:00 2001 From: Thomas Stig Jacobsen Date: Mon, 8 Mar 2021 20:35:13 +0100 Subject: [PATCH 05/13] Bump alpine base image to 3.13.2 --- Dockerfile | 2 +- Makefile | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Dockerfile b/Dockerfile index 8e71170b0..48e243f41 100644 --- a/Dockerfile +++ b/Dockerfile @@ -27,7 +27,7 @@ COPY . . RUN make test build.$ARCH # final image -FROM $ARCH/alpine:3.12 +FROM $ARCH/alpine:3.13.2 COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/ca-certificates.crt COPY --from=builder /sigs.k8s.io/external-dns/build/external-dns /bin/external-dns diff --git a/Makefile b/Makefile index 0f2b274e8..1a8c2932a 100644 --- a/Makefile +++ b/Makefile @@ -82,7 +82,7 @@ build.push/multiarch: for arch in $(ARCHS); do \ image="$(IMAGE):$(VERSION)-$${arch}" ;\ # pre-pull due to https://github.com/kubernetes-sigs/cluster-addons/pull/84/files ;\ - docker pull $${arch}/alpine:3.12 ;\ + docker pull $${arch}/alpine:3.13.2 ;\ DOCKER_BUILDKIT=1 docker build --rm --tag $${image} --build-arg VERSION="$(VERSION)" --build-arg ARCH="$${arch}" . ;\ docker push $${image} ;\ arch_specific_tags+=( "--amend $${image}" ) ;\ From c97614906e18368e624cd0734c9a82758d4ebfe0 Mon Sep 17 00:00:00 2001 From: Thomas Stig Jacobsen Date: Tue, 9 Mar 2021 09:31:16 +0100 Subject: [PATCH 06/13] Using the minor version instead of the patch version --- Dockerfile | 2 +- Makefile | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Dockerfile b/Dockerfile index 48e243f41..27715b433 100644 --- a/Dockerfile +++ b/Dockerfile @@ -27,7 +27,7 @@ COPY . . RUN make test build.$ARCH # final image -FROM $ARCH/alpine:3.13.2 +FROM $ARCH/alpine:3.13 COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/ca-certificates.crt COPY --from=builder /sigs.k8s.io/external-dns/build/external-dns /bin/external-dns diff --git a/Makefile b/Makefile index 1a8c2932a..af56de8d1 100644 --- a/Makefile +++ b/Makefile @@ -82,7 +82,7 @@ build.push/multiarch: for arch in $(ARCHS); do \ image="$(IMAGE):$(VERSION)-$${arch}" ;\ # pre-pull due to https://github.com/kubernetes-sigs/cluster-addons/pull/84/files ;\ - docker pull $${arch}/alpine:3.13.2 ;\ + docker pull $${arch}/alpine:3.13 ;\ DOCKER_BUILDKIT=1 docker build --rm --tag $${image} --build-arg VERSION="$(VERSION)" --build-arg ARCH="$${arch}" . ;\ docker push $${image} ;\ arch_specific_tags+=( "--amend $${image}" ) ;\ From 6adbd4ec25d81f94afe6a9407197e1993818d971 Mon Sep 17 00:00:00 2001 From: Kundan Kumar Date: Tue, 9 Mar 2021 17:42:30 +0530 Subject: [PATCH 07/13] external-dns configuration update --- docs/tutorials/rfc2136.md | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/docs/tutorials/rfc2136.md b/docs/tutorials/rfc2136.md index 10210dd91..923477813 100644 --- a/docs/tutorials/rfc2136.md +++ b/docs/tutorials/rfc2136.md @@ -376,7 +376,6 @@ You'll want to configure `external-dns` similarly to the following: ```text ... - --provider=rfc2136 - - --rfc2136-gss-tsig - --rfc2136-host=123.123.123.123 - --rfc2136-port=53 - --rfc2136-zone=your-domain.com @@ -384,4 +383,4 @@ You'll want to configure `external-dns` similarly to the following: - --rfc2136-kerberos-password=your-domain-password - --rfc2136-tsig-axfr # needed to enable zone transfers, which is required for deletion of records. ... -``` \ No newline at end of file +``` From add7a4102adc6d4bf8ce6ead81611f4cd60b7e91 Mon Sep 17 00:00:00 2001 From: Dinar Valeev Date: Wed, 10 Mar 2021 11:27:40 +0100 Subject: [PATCH 08/13] Approve crd v1 All CRDs under k8s.io and kubernetes.io API groups should go through API approval. Signed-off-by: Dinar Valeev --- docs/contributing/crd-source/crd-manifest.yaml | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/contributing/crd-source/crd-manifest.yaml b/docs/contributing/crd-source/crd-manifest.yaml index 4fb16468a..dc1a1a6f1 100644 --- a/docs/contributing/crd-source/crd-manifest.yaml +++ b/docs/contributing/crd-source/crd-manifest.yaml @@ -5,6 +5,7 @@ kind: CustomResourceDefinition metadata: annotations: controller-gen.kubebuilder.io/version: v0.5.0 + api-approved.kubernetes.io: "https://github.com/kubernetes-sigs/external-dns/pull/2007" creationTimestamp: null name: dnsendpoints.externaldns.k8s.io spec: From 2476b3bbe10c27a5661381f094c6fbc58e7fa5bf Mon Sep 17 00:00:00 2001 From: Morre Date: Sat, 13 Mar 2021 00:41:23 +0100 Subject: [PATCH 09/13] remove outdated provider list from FAQ, link to list in README --- docs/faq.md | 25 ++++--------------------- 1 file changed, 4 insertions(+), 21 deletions(-) diff --git a/docs/faq.md b/docs/faq.md index b53c24995..db309b385 100644 --- a/docs/faq.md +++ b/docs/faq.md @@ -28,24 +28,7 @@ ExternalDNS can solve this for you as well. ### Which DNS providers are supported? -Currently, the following providers are supported: - -- Google Cloud DNS -- AWS Route 53 -- AzureDNS -- CloudFlare -- DigitalOcean -- DNSimple -- Infoblox -- Dyn -- OpenStack Designate -- PowerDNS -- CoreDNS -- Exoscale -- Oracle Cloud Infrastructure DNS -- Linode DNS -- RFC2136 -- TransIP +Please check the [provider status table](https://github.com/kubernetes-sigs/external-dns#status-of-providers) for the list of supported providers and their status. As stated in the README, we are currently looking for stable maintainers for those providers, to ensure that bugfixes and new features will be available for all of those. @@ -279,11 +262,11 @@ If you need to filter only one specific source you have to run a separated exter ### How do I specify that I want the DNS record to point to either the Node's public or private IP when it has both? -If your Nodes have both public and private IP addresses, you might want to write DNS records with one or the other. +If your Nodes have both public and private IP addresses, you might want to write DNS records with one or the other. For example, you may want to write a DNS record in a private zone that resolves to your Nodes' private IPs so that traffic never leaves your private network. -To accomplish this, set this annotation on your service: `external-dns.alpha.kubernetes.io/access=private` -Conversely, to force the public IP: `external-dns.alpha.kubernetes.io/access=public` +To accomplish this, set this annotation on your service: `external-dns.alpha.kubernetes.io/access=private` +Conversely, to force the public IP: `external-dns.alpha.kubernetes.io/access=public` If this annotation is not set, and the node has both public and private IP addresses, then the public IP will be used by default. From e1cf5f88a79bda93ca6416f4d876b7d3c04d9d87 Mon Sep 17 00:00:00 2001 From: Kundan Kumar Date: Tue, 16 Mar 2021 12:42:30 +0530 Subject: [PATCH 10/13] updated ingress apiVersion --- docs/tutorials/alibabacloud.md | 2 +- docs/tutorials/aws.md | 2 +- docs/tutorials/azure-private-dns.md | 2 +- docs/tutorials/azure.md | 2 +- docs/tutorials/coredns.md | 2 +- docs/tutorials/dyn.md | 2 +- docs/tutorials/exoscale.md | 2 +- docs/tutorials/gke.md | 4 ++-- docs/tutorials/kube-ingress-aws.md | 8 ++++---- docs/tutorials/nginx-ingress.md | 4 ++-- docs/tutorials/public-private-route53.md | 8 ++++---- docs/tutorials/rdns.md | 2 +- docs/tutorials/rfc2136.md | 2 +- 13 files changed, 21 insertions(+), 21 deletions(-) diff --git a/docs/tutorials/alibabacloud.md b/docs/tutorials/alibabacloud.md index cbe774572..de9ad9365 100644 --- a/docs/tutorials/alibabacloud.md +++ b/docs/tutorials/alibabacloud.md @@ -229,7 +229,7 @@ Create an ingress resource manifest file. > For ingress objects ExternalDNS will create a DNS record based on the host specified for the ingress object. ```yaml -apiVersion: networking.k8s.io/v1beta1 +apiVersion: networking.k8s.io/v1 kind: Ingress metadata: name: foo diff --git a/docs/tutorials/aws.md b/docs/tutorials/aws.md index 648e3c290..3d9d7ea51 100644 --- a/docs/tutorials/aws.md +++ b/docs/tutorials/aws.md @@ -253,7 +253,7 @@ Create an ingress resource manifest file. > For ingress objects ExternalDNS will create a DNS record based on the host specified for the ingress object. ```yaml -apiVersion: networking.k8s.io/v1beta1 +apiVersion: networking.k8s.io/v1 kind: Ingress metadata: name: foo diff --git a/docs/tutorials/azure-private-dns.md b/docs/tutorials/azure-private-dns.md index 43f4ac734..ea7cfb8de 100644 --- a/docs/tutorials/azure-private-dns.md +++ b/docs/tutorials/azure-private-dns.md @@ -375,7 +375,7 @@ spec: type: ClusterIP --- -apiVersion: networking.k8s.io/v1beta1 +apiVersion: networking.k8s.io/v1 kind: Ingress metadata: name: nginx diff --git a/docs/tutorials/azure.md b/docs/tutorials/azure.md index fd925d154..102699bfc 100644 --- a/docs/tutorials/azure.md +++ b/docs/tutorials/azure.md @@ -392,7 +392,7 @@ spec: type: ClusterIP --- -apiVersion: networking.k8s.io/v1beta1 +apiVersion: networking.k8s.io/v1 kind: Ingress metadata: name: nginx diff --git a/docs/tutorials/coredns.md b/docs/tutorials/coredns.md index b9c1282ca..b07edd930 100644 --- a/docs/tutorials/coredns.md +++ b/docs/tutorials/coredns.md @@ -194,7 +194,7 @@ minikube addons enable ingress ## Testing ingress example ``` $ cat ingress.yaml -apiVersion: networking.k8s.io/v1beta1 +apiVersion: networking.k8s.io/v1 kind: Ingress metadata: name: nginx diff --git a/docs/tutorials/dyn.md b/docs/tutorials/dyn.md index fcee842f2..18fb7bbd8 100644 --- a/docs/tutorials/dyn.md +++ b/docs/tutorials/dyn.md @@ -111,7 +111,7 @@ Having `--dry-run=true` and `--log-level=debug` is a great way to see _exactly_ Create a file called 'test-ingress.yaml' with the following contents: ```yaml -apiVersion: networking.k8s.io/v1beta1 +apiVersion: networking.k8s.io/v1 kind: Ingress metadata: name: test-ingress diff --git a/docs/tutorials/exoscale.md b/docs/tutorials/exoscale.md index d3d8e92d4..faf8b70eb 100644 --- a/docs/tutorials/exoscale.md +++ b/docs/tutorials/exoscale.md @@ -104,7 +104,7 @@ subjects: Spin up a simple nginx HTTP server with the following spec (`kubectl apply -f`): ```yaml -apiVersion: networking.k8s.io/v1beta1 +apiVersion: networking.k8s.io/v1 kind: Ingress metadata: name: nginx diff --git a/docs/tutorials/gke.md b/docs/tutorials/gke.md index 7e978cb8b..a573f9d51 100644 --- a/docs/tutorials/gke.md +++ b/docs/tutorials/gke.md @@ -211,7 +211,7 @@ $ curl nginx.external-dns-test.gcp.zalan.do Let's check that Ingress works as well. Create the following Ingress. ```yaml -apiVersion: networking.k8s.io/v1beta1 +apiVersion: networking.k8s.io/v1 kind: Ingress metadata: name: nginx @@ -460,7 +460,7 @@ $ kubectl annotate serviceaccount --namespace=external-dns external-dns \ Create the following sample application to test that ExternalDNS works. ```yaml -apiVersion: networking.k8s.io/v1beta1 +apiVersion: networking.k8s.io/v1 kind: Ingress metadata: name: nginx diff --git a/docs/tutorials/kube-ingress-aws.md b/docs/tutorials/kube-ingress-aws.md index 99cd07714..df63e9829 100644 --- a/docs/tutorials/kube-ingress-aws.md +++ b/docs/tutorials/kube-ingress-aws.md @@ -138,7 +138,7 @@ default. Create the following Ingress to expose the echoserver application to the Internet. ```yaml -apiVersion: networking.k8s.io/v1beta1 +apiVersion: networking.k8s.io/v1 kind: Ingress metadata: annotations: @@ -172,7 +172,7 @@ this Ingress object will only be fronting one backend Service, we might instead create the following: ```yaml -apiVersion: networking.k8s.io/v1beta1 +apiVersion: networking.k8s.io/v1 kind: Ingress metadata: annotations: @@ -205,7 +205,7 @@ and one AAAA record) for each hostname associated with the Ingress object. Example: ```yaml -apiVersion: networking.k8s.io/v1beta1 +apiVersion: networking.k8s.io/v1 kind: Ingress metadata: annotations: @@ -239,7 +239,7 @@ set to `nlb` then ExternalDNS will create an NLB instead of an ALB. Example: ```yaml -apiVersion: networking.k8s.io/v1beta1 +apiVersion: networking.k8s.io/v1 kind: Ingress metadata: annotations: diff --git a/docs/tutorials/nginx-ingress.md b/docs/tutorials/nginx-ingress.md index 28a29d1f2..01e451dcf 100644 --- a/docs/tutorials/nginx-ingress.md +++ b/docs/tutorials/nginx-ingress.md @@ -290,7 +290,7 @@ Use `--dry-run` if you want to be extra careful on the first run. Note, that you Create the following sample application to test that ExternalDNS works. ```yaml -apiVersion: networking.k8s.io/v1beta1 +apiVersion: networking.k8s.io/v1 kind: Ingress metadata: name: nginx @@ -586,7 +586,7 @@ $ kubectl annotate serviceaccount --namespace=external-dns external-dns \ Create the following sample application to test that ExternalDNS works. ```yaml -apiVersion: networking.k8s.io/v1beta1 +apiVersion: networking.k8s.io/v1 kind: Ingress metadata: name: nginx diff --git a/docs/tutorials/public-private-route53.md b/docs/tutorials/public-private-route53.md index 15933bc4b..5a6bc65ca 100644 --- a/docs/tutorials/public-private-route53.md +++ b/docs/tutorials/public-private-route53.md @@ -292,7 +292,7 @@ For this setup to work, you've to create two Service definitions for your applic At first, create public Service definition: ```yaml -apiVersion: networking.k8s.io/v1beta1 +apiVersion: networking.k8s.io/v1 kind: Ingress metadata: annotations: @@ -313,7 +313,7 @@ spec: Then create private Service definition: ```yaml -apiVersion: networking.k8s.io/v1beta1 +apiVersion: networking.k8s.io/v1 kind: Ingress metadata: annotations: @@ -334,7 +334,7 @@ spec: Additionally, you may leverage [cert-manager](https://github.com/jetstack/cert-manager) to automatically issue SSL certificates from [Let's Encrypt](https://letsencrypt.org/). To do that, request a certificate in public service definition: ```yaml -apiVersion: networking.k8s.io/v1beta1 +apiVersion: networking.k8s.io/v1 kind: Ingress metadata: annotations: @@ -363,7 +363,7 @@ spec: And reuse the requested certificate in private Service definition: ```yaml -apiVersion: networking.k8s.io/v1beta1 +apiVersion: networking.k8s.io/v1 kind: Ingress metadata: annotations: diff --git a/docs/tutorials/rdns.md b/docs/tutorials/rdns.md index 3a0fc85fd..cb2d2aede 100644 --- a/docs/tutorials/rdns.md +++ b/docs/tutorials/rdns.md @@ -138,7 +138,7 @@ spec: ## Testing ingress example ``` $ cat ingress.yaml -apiVersion: networking.k8s.io/v1beta1 +apiVersion: networking.k8s.io/v1 kind: Ingress metadata: name: nginx diff --git a/docs/tutorials/rfc2136.md b/docs/tutorials/rfc2136.md index 923477813..8dd923ef3 100644 --- a/docs/tutorials/rfc2136.md +++ b/docs/tutorials/rfc2136.md @@ -94,7 +94,7 @@ spec: selector: app: nginx --- -apiVersion: networking.k8s.io/v1beta1 +apiVersion: networking.k8s.io/v1 kind: Ingress metadata: name: my-ingress From e20aea4d5fb88f0761ca7a6f56c437567e33b52b Mon Sep 17 00:00:00 2001 From: Tim Curless Date: Thu, 2 Jan 2020 13:55:59 -0600 Subject: [PATCH 11/13] Add Initial BlueCat Provider Support The new BlueCat provider uses the BlueCat API Gateway(REST API). Not the legacy XML based BlueCat API. https://github.com/bluecatlabs/gateway-workflows --- README.md | 2 + docs/tutorials/bluecat.md | 64 ++ go.sum | 20 +- main.go | 3 + pkg/apis/externaldns/types.go | 5 +- pkg/apis/externaldns/types_test.go | 4 + provider/bluecat/OWNERS | 6 + provider/bluecat/bluecat.go | 969 +++++++++++++++++++++++++++++ provider/bluecat/bluecat_test.go | 390 ++++++++++++ 9 files changed, 1446 insertions(+), 17 deletions(-) create mode 100644 docs/tutorials/bluecat.md create mode 100644 provider/bluecat/OWNERS create mode 100644 provider/bluecat/bluecat.go create mode 100644 provider/bluecat/bluecat_test.go diff --git a/README.md b/README.md index b56a395ba..0155aa335 100644 --- a/README.md +++ b/README.md @@ -28,6 +28,7 @@ ExternalDNS' current release is `v0.7`. This version allows you to keep selected * [AWS Route 53](https://aws.amazon.com/route53/) * [AWS Cloud Map](https://docs.aws.amazon.com/cloud-map/) * [AzureDNS](https://azure.microsoft.com/en-us/services/dns) +* [BlueCat](https://bluecatnetworks.com) * [CloudFlare](https://www.cloudflare.com/dns) * [RcodeZero](https://www.rcodezero.at/) * [DigitalOcean](https://www.digitalocean.com/products/networking) @@ -82,6 +83,7 @@ The following table clarifies the current status of the providers according to t | AWS Cloud Map | Beta | | | Akamai Edge DNS | Beta | | | AzureDNS | Beta | | +| BlueCat | Alpha | @seanmalloy @vinny-sabatini | | CloudFlare | Beta | | | RcodeZero | Alpha | | | DigitalOcean | Alpha | | diff --git a/docs/tutorials/bluecat.md b/docs/tutorials/bluecat.md new file mode 100644 index 000000000..b2150b9b7 --- /dev/null +++ b/docs/tutorials/bluecat.md @@ -0,0 +1,64 @@ +# Setting up external-dns for BlueCat + +## Prerequisites +Install the BlueCat Gateway product and deploy the [community gateway workflows](https://github.com/bluecatlabs/gateway-workflows). + +## Deploy +Setup configuration file as k8s `Secret`. +``` +cat << EOF > ~/bluecat.json +{ + "gatewayHost": "https://bluecatgw.example.com", + "gatewayUsername": "user", + "GatewayPassword": "pass", + "dnsConfiguration": "Example", + "dnsView": "Internal", + "rootZone": "example.com" +} +EOF +kubectl create secret generic bluecatconfig --from-file ~/bluecat.json -n bluecat-example +``` + +Setup up deployment/service account: +``` +apiVersion: v1 +kind: ServiceAccount +metadata: + name: external-dns + namespace: bluecat-example +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + name: external-dns + namespace: bluecat-example +spec: + selector: + matchLabels: + app: external-dns + strategy: + type: Recreate + template: + metadata: + labels: + app: external-dns + spec: + serviceAccountName: external-dns + volumes: + - name: bluecatconfig + secret: + secretName: bluecatconfig + containers: + - name: external-dns + image: k8s.gcr.io/external-dns/external-dns:$TAG # no released versions include the bluecat provider yet + volumeMounts: + - name: bluecatconfig + mountPath: "/etc/external-dns/" + readOnly: true + args: + - --log-level=debug + - --source=service + - --provider=bluecat + - --txt-owner-id=bluecat-example + - --bluecat-config-file=/etc/external-dns/bluecat.json +``` diff --git a/go.sum b/go.sum index 23127e8a7..8a5a6e67b 100644 --- a/go.sum +++ b/go.sum @@ -92,6 +92,7 @@ github.com/alecthomas/template v0.0.0-20190718012654-fb15b899a751/go.mod h1:LOuy github.com/alecthomas/units v0.0.0-20151022065526-2efee857e7cf/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0= github.com/alecthomas/units v0.0.0-20190717042225-c3de453c63f4 h1:Hs82Z41s6SdL1CELW+XaDYmOH4hkBN4/N9og/AsOv7E= github.com/alecthomas/units v0.0.0-20190717042225-c3de453c63f4/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0= +github.com/alexbrainman/sspi v0.0.0-20180613141037-e580b900e9f5 h1:P5U+E4x5OkVEKQDklVPmzs71WM56RTTRqV4OrDC//Y4= github.com/alexbrainman/sspi v0.0.0-20180613141037-e580b900e9f5/go.mod h1:976q2ETgjT2snVCf2ZaBnyBbVoPERGjUz+0sofzEfro= github.com/aliyun/alibaba-cloud-sdk-go v1.61.357 h1:3ynCSeUh9OtJLd/OzLapM1DLDv2g+0yyDdkLqSfZCaQ= github.com/aliyun/alibaba-cloud-sdk-go v1.61.357/go.mod h1:pUKYbK5JQ+1Dfxk80P0qxGqe5dkxDoabbZS7zOcouyA= @@ -127,8 +128,6 @@ github.com/blang/semver v3.5.0+incompatible/go.mod h1:kRBLl5iJ+tD4TcOOxsy/0fnweb github.com/bmizerany/assert v0.0.0-20160611221934-b7ed37b82869/go.mod h1:Ekp36dRnpXw/yCqJaO+ZrUyxD+3VXMFFr56k5XYrpB4= github.com/bodgit/tsig v0.0.2 h1:seNt23SrPW8dkWoyRYzdeuqFEzr+lDc0dAJvo94xB8U= github.com/bodgit/tsig v0.0.2/go.mod h1:0mYe0t9it36SOvDQyeFekc7bLtvljFz7H9vHS/nYbgc= -github.com/bodgit/tsig v1.1.1 h1:SViReRa8KyaweqdJ3ojdYqIE3xDyJlR3G+6wAsSbLCo= -github.com/bodgit/tsig v1.1.1/go.mod h1:8LZ3Mn7AVZHH8GN2ArvzB7msHfLjoptWsdPEJRSw/uo= github.com/bshuster-repo/logrus-logstash-hook v0.4.1/go.mod h1:zsTqEiSzDgAa/8GZR7E1qaXrhYNDKBYy5/dWPTIflbk= github.com/bugsnag/bugsnag-go v0.0.0-20141110184014-b1d153021fcd/go.mod h1:2oa8nejYd4cQ/b0hMIopN0lCRxU0bueqREvZLWFrtK8= github.com/bugsnag/osext v0.0.0-20130617224835-0dd3f918b21b/go.mod h1:obH5gd0BsqsP2LwDJ9aOkm/6J86V6lyAXCoQWGw3K50= @@ -427,7 +426,9 @@ github.com/gorilla/mux v1.7.2/go.mod h1:1lud6UwP+6orDFRuTfBEV8e9/aOM/c4fVVCaMa2z github.com/gorilla/mux v1.7.3/go.mod h1:1lud6UwP+6orDFRuTfBEV8e9/aOM/c4fVVCaMa2zaAs= github.com/gorilla/mux v1.7.4 h1:VuZ8uybHlWmqV03+zRzdwKL4tUnIp1MAQtp1mIFE1bc= github.com/gorilla/mux v1.7.4/go.mod h1:DVbg23sWSpFRCP0SfiEN6jmj59UnW/n46BH5rLB71So= +github.com/gorilla/securecookie v1.1.1 h1:miw7JPhV+b/lAHSXz4qd/nN9jRiAFV5FwjeKyCS8BvQ= github.com/gorilla/securecookie v1.1.1/go.mod h1:ra0sb63/xPlUeL+yeDciTfxMRAA+MP+HVt/4epWDjd4= +github.com/gorilla/sessions v1.2.0 h1:S7P+1Hm5V/AT9cjEcUD5uDaQSX0OE577aCXgoaKpYbQ= github.com/gorilla/sessions v1.2.0/go.mod h1:dk2InVEVJ0sfLlnXv9EAgkf6ecYs/i80K/zI+bUmuGM= github.com/gorilla/websocket v0.0.0-20170926233335-4201258b820c/go.mod h1:E7qHFY5m1UJ88s3WnNqhKjPHQ0heANvMoAMk2YaljkQ= github.com/gorilla/websocket v1.4.0/go.mod h1:E7qHFY5m1UJ88s3WnNqhKjPHQ0heANvMoAMk2YaljkQ= @@ -510,8 +511,6 @@ github.com/jcmturner/gokrb5/v8 v8.4.1/go.mod h1:T1hnNppQsBtxW0tCHMHTkAt8n/sABdzZ github.com/jcmturner/rpc/v2 v2.0.2 h1:gMB4IwRXYsWw4Bc6o/az2HJgFUA1ffSh90i26ZJ6Xl0= github.com/jcmturner/rpc/v2 v2.0.2/go.mod h1:VUJYCIDm3PVOEHw8sgt091/20OJjskO/YJki3ELg/Hc= github.com/jessevdk/go-flags v1.4.0/go.mod h1:4FA24M0QyGHXBuZZK/XkWh8h0e1EYbRYJSGM75WSRxI= -github.com/jinzhu/copier v0.1.0 h1:Vh8xALtH3rrKGB/XIRe5d0yCTHPZFauWPLvdpDAbi88= -github.com/jinzhu/copier v0.1.0/go.mod h1:24xnZezI2Yqac9J61UC6/dG/k76ttpq0DdJI3QmUvro= github.com/jmespath/go-jmespath v0.0.0-20160202185014-0b12d6b521d8/go.mod h1:Nht3zPeWKUH0NzdCt2Blrr5ys8VGpn0CEB0cQHVjt7k= github.com/jmespath/go-jmespath v0.0.0-20160803190731-bd40a432e4c7/go.mod h1:Nht3zPeWKUH0NzdCt2Blrr5ys8VGpn0CEB0cQHVjt7k= github.com/jmespath/go-jmespath v0.0.0-20180206201540-c2b33e8439af/go.mod h1:Nht3zPeWKUH0NzdCt2Blrr5ys8VGpn0CEB0cQHVjt7k= @@ -599,8 +598,6 @@ github.com/maxatome/go-testdeep v1.4.0/go.mod h1:011SgQ6efzZYAen6fDn4BqQ+lUR72ys github.com/mholt/archiver/v3 v3.3.0/go.mod h1:YnQtqsp+94Rwd0D/rk5cnLrxusUBUXg+08Ebtr1Mqao= github.com/miekg/dns v1.0.14/go.mod h1:W1PPwlIAgtquWBMBEV9nkV9Cazfe8ScdGz/Lj7v3Nrg= github.com/miekg/dns v1.1.6/go.mod h1:W1PPwlIAgtquWBMBEV9nkV9Cazfe8ScdGz/Lj7v3Nrg= -github.com/miekg/dns v1.1.30 h1:Qww6FseFn8PRfw07jueqIXqodm0JKiiKuK0DeXSqfyo= -github.com/miekg/dns v1.1.30/go.mod h1:KNUDUusw/aVsxyTYZM1oqvCicbwhgbNgztCETuNZ7xM= github.com/miekg/dns v1.1.31/go.mod h1:KNUDUusw/aVsxyTYZM1oqvCicbwhgbNgztCETuNZ7xM= github.com/miekg/dns v1.1.36-0.20210109083720-731b191cabd1 h1:kZZmnTeY2r+88mDNCVV/uCXL2gG3rkVPTN9jcYfGQcI= github.com/miekg/dns v1.1.36-0.20210109083720-731b191cabd1/go.mod h1:KNUDUusw/aVsxyTYZM1oqvCicbwhgbNgztCETuNZ7xM= @@ -690,6 +687,7 @@ github.com/openshift/api v0.0.0-20200605231317-fb2a6ca106ae/go.mod h1:l6TGeqJ92D github.com/openshift/build-machinery-go v0.0.0-20200424080330-082bf86082cc/go.mod h1:1CkcsT3aVebzRBzVTSbiKSkJMsC/CASqxesfqEMfJEc= github.com/openshift/client-go v0.0.0-20200608144219-584632b8fc73 h1:JePLt9EpNLF/30KsSsArrzxGWPaUIvYUt8Fwnw9wlgM= github.com/openshift/client-go v0.0.0-20200608144219-584632b8fc73/go.mod h1:+66gk3dEqw9e+WoiXjJFzWlS1KGhj9ZRHi/RI/YG/ZM= +github.com/openshift/gssapi v0.0.0-20161010215902-5fb4217df13b h1:it0YPE/evO6/m8t8wxis9KFI2F/aleOKsI6d9uz0cEk= github.com/openshift/gssapi v0.0.0-20161010215902-5fb4217df13b/go.mod h1:tNrEB5k8SI+g5kOlsCmL2ELASfpqEofI0+FLBgBdN08= github.com/opentracing-contrib/go-observer v0.0.0-20170622124052-a52f23424492/go.mod h1:Ngi6UdF0k5OKD5t5wlmGhe/EDKPoUM3BXZSSfIuJbis= github.com/opentracing/basictracer-go v1.0.0/go.mod h1:QfBfYuafItcjQuMwinw9GhYKwFXS9KnPs5lxoYwgW74= @@ -948,8 +946,6 @@ golang.org/x/crypto v0.0.0-20200414173820-0848c9571904/go.mod h1:LzIPMQfyMNhhGPh golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= golang.org/x/crypto v0.0.0-20201002170205-7f63de1d35b0 h1:hb9wdF1z5waM+dSIICn1l0DkLVDT3hqhhQsDNUmHPRE= golang.org/x/crypto v0.0.0-20201002170205-7f63de1d35b0/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= -golang.org/x/crypto v0.0.0-20201221181555-eec23a3978ad h1:DN0cp81fZ3njFcrLCytUHRSUkqBjfTo4Tx9RJTWs0EY= -golang.org/x/crypto v0.0.0-20201221181555-eec23a3978ad/go.mod h1:jdWPYTVW3xRLrWPugEBEK3UY2ZEsg3UU495nc5E+M+I= golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= golang.org/x/exp v0.0.0-20190125153040-c74c464bbbf2/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= golang.org/x/exp v0.0.0-20190306152737-a1d7652674e8/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= @@ -1062,7 +1058,6 @@ golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7w golang.org/x/sys v0.0.0-20190924154521-2837fb4f24fe/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20191005200804-aed5e4c7ecf9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20191022100944-742c48ecaeb7/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20191105231009-c1f44814a5cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20191120155948-bd437916bb0e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20191220142924-d4481acd189f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= @@ -1073,9 +1068,6 @@ golang.org/x/sys v0.0.0-20200615200032-f1bc736245b1 h1:ogLJMz+qpzav7lGMh10LMvAkM golang.org/x/sys v0.0.0-20200615200032-f1bc736245b1/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201119102817-f84b799fce68 h1:nxC68pudNYkKU6jWhgrqdreuFiOQWj1Fs7T3VrH4Pjw= golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20210113181707-4bcb84eeeb78 h1:nVuTkr9L6Bq62qpUqKo/RnZCFfzDBL0bYo6w9OJUqZY= -golang.org/x/sys v0.0.0-20210113181707-4bcb84eeeb78/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/term v0.0.0-20201117132131-f5c789dd3221/go.mod h1:Nr5EML6q2oocZ2LXRh80K7BxOlk5/8JxuGnuhpl+muw= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1 h1:v+OssWQX+hTHEmOBgwxdZxK4zHq3yOs8F9J7mk0PY8E= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/text v0.0.0-20160726164857-2910a502d2bf/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= @@ -1225,12 +1217,8 @@ honnef.co/go/tools v0.0.0-20190418001031-e561f6794a2a/go.mod h1:rf3lG4BRIbNafJWh honnef.co/go/tools v0.0.1-2019.2.3/go.mod h1:a3bituU0lyd329TUQxRnasdCoJDkEUEAqEt0JzvZhAg= honnef.co/go/tools v0.0.1-2020.1.4 h1:UoveltGrhghAA7ePc+e+QYDHXrBps2PqFZiHkGR/xK8= honnef.co/go/tools v0.0.1-2020.1.4/go.mod h1:X/FiERA/W4tHapMX5mGpAtMSVEeEUOyHaw9vFzvIQ3k= -istio.io/api v0.0.0-20200529165953-72dad51d4ffc h1:cR9GmbIBAz3FnY3tgs1SRn/uiznhtvG+mZBfD1p2vIA= -istio.io/api v0.0.0-20200529165953-72dad51d4ffc/go.mod h1:kyq3g5w42zl/AKlbzDGppYpGMQYMYMyZKeq0/eexML8= istio.io/api v0.0.0-20210128181506-0c4b8e54850f h1:zUFsawgPj5oI9p5cf91YCExRlxLIVsEkIunN9ODUSJs= istio.io/api v0.0.0-20210128181506-0c4b8e54850f/go.mod h1:88HN3o1fSD1jo+Z1WTLlJfMm9biopur6Ct9BFKjiB64= -istio.io/client-go v0.0.0-20200529172309-31c16ea3f751 h1:yH62fTmV+5l1XVTWcomsc1jjH/oH9u/tTgn5NVmdIac= -istio.io/client-go v0.0.0-20200529172309-31c16ea3f751/go.mod h1:4SGvmmus5HNFdqQsIL+uQO1PbAhjQKtSjMTqwsvYHlg= istio.io/client-go v0.0.0-20210128182905-ee2edd059e02 h1:ZA8Y2gKkKtEeYuKfqlEzIBDfU4IE5uIAdsXDeD41T9w= istio.io/client-go v0.0.0-20210128182905-ee2edd059e02/go.mod h1:oXMjFUWhxlReUSbg4i3GjKgOhSX1WgD68ZNlHQEcmQg= istio.io/gogo-genproto v0.0.0-20190904133402-ee07f2785480/go.mod h1:uKtbae4K9k2rjjX4ToV0l6etglbc1i7gqQ94XdkshzY= diff --git a/main.go b/main.go index 09860c08d..9e0837a46 100644 --- a/main.go +++ b/main.go @@ -39,6 +39,7 @@ import ( "sigs.k8s.io/external-dns/provider/aws" "sigs.k8s.io/external-dns/provider/awssd" "sigs.k8s.io/external-dns/provider/azure" + "sigs.k8s.io/external-dns/provider/bluecat" "sigs.k8s.io/external-dns/provider/cloudflare" "sigs.k8s.io/external-dns/provider/coredns" "sigs.k8s.io/external-dns/provider/designate" @@ -194,6 +195,8 @@ func main() { p, err = azure.NewAzureProvider(cfg.AzureConfigFile, domainFilter, zoneNameFilter, zoneIDFilter, cfg.AzureResourceGroup, cfg.AzureUserAssignedIdentityClientID, cfg.DryRun) case "azure-private-dns": p, err = azure.NewAzurePrivateDNSProvider(cfg.AzureConfigFile, domainFilter, zoneIDFilter, cfg.AzureResourceGroup, cfg.AzureUserAssignedIdentityClientID, cfg.DryRun) + case "bluecat": + p, err = bluecat.NewBluecatProvider(cfg.BluecatConfigFile, domainFilter, zoneIDFilter, cfg.DryRun) case "vinyldns": p, err = vinyldns.NewVinylDNSProvider(domainFilter, zoneIDFilter, cfg.DryRun) case "vultr": diff --git a/pkg/apis/externaldns/types.go b/pkg/apis/externaldns/types.go index b8d43f462..dfda5bd91 100644 --- a/pkg/apis/externaldns/types.go +++ b/pkg/apis/externaldns/types.go @@ -82,6 +82,7 @@ type Config struct { AzureResourceGroup string AzureSubscriptionID string AzureUserAssignedIdentityClientID string + BluecatConfigFile string CloudflareProxied bool CloudflareZonesPerPage int CoreDNSPrefix string @@ -199,6 +200,7 @@ var defaultConfig = &Config{ AzureConfigFile: "/etc/kubernetes/azure.json", AzureResourceGroup: "", AzureSubscriptionID: "", + BluecatConfigFile: "/etc/kubernetes/bluecat.json", CloudflareProxied: false, CloudflareZonesPerPage: 50, CoreDNSPrefix: "/skydns/", @@ -352,7 +354,7 @@ func (cfg *Config) ParseFlags(args []string) error { app.Flag("managed-record-types", "Comma separated list of record types to manage (default: A, CNAME) (supported records: CNAME, A, NS").Default("A", "CNAME").StringsVar(&cfg.ManagedDNSRecordTypes) // Flags related to providers - app.Flag("provider", "The DNS provider where the DNS records will be created (required, options: aws, aws-sd, godaddy, google, azure, azure-dns, azure-private-dns, cloudflare, rcodezero, digitalocean, hetzner, dnsimple, akamai, infoblox, dyn, designate, coredns, skydns, inmemory, ovh, pdns, oci, exoscale, linode, rfc2136, ns1, transip, vinyldns, rdns, scaleway, vultr, ultradns)").Required().PlaceHolder("provider").EnumVar(&cfg.Provider, "aws", "aws-sd", "google", "azure", "azure-dns", "hetzner", "azure-private-dns", "alibabacloud", "cloudflare", "rcodezero", "digitalocean", "dnsimple", "akamai", "infoblox", "dyn", "designate", "coredns", "skydns", "inmemory", "ovh", "pdns", "oci", "exoscale", "linode", "rfc2136", "ns1", "transip", "vinyldns", "rdns", "scaleway", "vultr", "ultradns", "godaddy") + app.Flag("provider", "The DNS provider where the DNS records will be created (required, options: aws, aws-sd, godaddy, google, azure, azure-dns, azure-private-dns, bluecat, cloudflare, rcodezero, digitalocean, hetzner, dnsimple, akamai, infoblox, dyn, designate, coredns, skydns, inmemory, ovh, pdns, oci, exoscale, linode, rfc2136, ns1, transip, vinyldns, rdns, scaleway, vultr, ultradns)").Required().PlaceHolder("provider").EnumVar(&cfg.Provider, "aws", "aws-sd", "google", "azure", "azure-dns", "hetzner", "azure-private-dns", "alibabacloud", "cloudflare", "rcodezero", "digitalocean", "dnsimple", "akamai", "infoblox", "dyn", "designate", "coredns", "skydns", "inmemory", "ovh", "pdns", "oci", "exoscale", "linode", "rfc2136", "ns1", "transip", "vinyldns", "rdns", "scaleway", "vultr", "ultradns", "godaddy") app.Flag("domain-filter", "Limit possible target zones by a domain suffix; specify multiple times for multiple domains (optional)").Default("").StringsVar(&cfg.DomainFilter) app.Flag("exclude-domains", "Exclude subdomains (optional)").Default("").StringsVar(&cfg.ExcludeDomains) app.Flag("zone-name-filter", "Filter target zones by zone domain (For now, only AzureDNS provider is using this flag); specify multiple times for multiple zones (optional)").Default("").StringsVar(&cfg.ZoneNameFilter) @@ -375,6 +377,7 @@ func (cfg *Config) ParseFlags(args []string) error { app.Flag("azure-resource-group", "When using the Azure provider, override the Azure resource group to use (required when --provider=azure-private-dns)").Default(defaultConfig.AzureResourceGroup).StringVar(&cfg.AzureResourceGroup) app.Flag("azure-subscription-id", "When using the Azure provider, specify the Azure configuration file (required when --provider=azure-private-dns)").Default(defaultConfig.AzureSubscriptionID).StringVar(&cfg.AzureSubscriptionID) app.Flag("azure-user-assigned-identity-client-id", "When using the Azure provider, override the client id of user assigned identity in config file (optional)").Default("").StringVar(&cfg.AzureUserAssignedIdentityClientID) + app.Flag("bluecat-config-file", "When using the Bluecat provider, specify the Bluecat configuration file (required when --provider=bluecat").Default(defaultConfig.BluecatConfigFile).StringVar(&cfg.BluecatConfigFile) app.Flag("cloudflare-proxied", "When using the Cloudflare provider, specify if the proxy mode must be enabled (default: disabled)").BoolVar(&cfg.CloudflareProxied) app.Flag("cloudflare-zones-per-page", "When using the Cloudflare provider, specify how many zones per page listed, max. possible 50 (default: 50)").Default(strconv.Itoa(defaultConfig.CloudflareZonesPerPage)).IntVar(&cfg.CloudflareZonesPerPage) app.Flag("coredns-prefix", "When using the CoreDNS provider, specify the prefix name").Default(defaultConfig.CoreDNSPrefix).StringVar(&cfg.CoreDNSPrefix) diff --git a/pkg/apis/externaldns/types_test.go b/pkg/apis/externaldns/types_test.go index df37180d4..6c2b1f553 100644 --- a/pkg/apis/externaldns/types_test.go +++ b/pkg/apis/externaldns/types_test.go @@ -61,6 +61,7 @@ var ( AzureConfigFile: "/etc/kubernetes/azure.json", AzureResourceGroup: "", AzureSubscriptionID: "", + BluecatConfigFile: "/etc/kubernetes/bluecat.json", CloudflareProxied: false, CloudflareZonesPerPage: 50, CoreDNSPrefix: "/skydns/", @@ -142,6 +143,7 @@ var ( AzureConfigFile: "azure.json", AzureResourceGroup: "arg", AzureSubscriptionID: "arg", + BluecatConfigFile: "bluecat.json", CloudflareProxied: true, CloudflareZonesPerPage: 20, CoreDNSPrefix: "/coredns/", @@ -236,6 +238,7 @@ func TestParseFlags(t *testing.T) { "--azure-config-file=azure.json", "--azure-resource-group=arg", "--azure-subscription-id=arg", + "--bluecat-config-file=bluecat.json", "--cloudflare-proxied", "--cloudflare-zones-per-page=20", "--coredns-prefix=/coredns/", @@ -331,6 +334,7 @@ func TestParseFlags(t *testing.T) { "EXTERNAL_DNS_AZURE_CONFIG_FILE": "azure.json", "EXTERNAL_DNS_AZURE_RESOURCE_GROUP": "arg", "EXTERNAL_DNS_AZURE_SUBSCRIPTION_ID": "arg", + "EXTERNAL_DNS_BLUECAT_CONFIG_FILE": "bluecat.json", "EXTERNAL_DNS_CLOUDFLARE_PROXIED": "1", "EXTERNAL_DNS_CLOUDFLARE_ZONES_PER_PAGE": "20", "EXTERNAL_DNS_COREDNS_PREFIX": "/coredns/", diff --git a/provider/bluecat/OWNERS b/provider/bluecat/OWNERS new file mode 100644 index 000000000..58a6b3a17 --- /dev/null +++ b/provider/bluecat/OWNERS @@ -0,0 +1,6 @@ +approvers: +- seanmalloy +- vinny-sabatini +reviewers: +- seanmalloy +- vinny-sabatini diff --git a/provider/bluecat/bluecat.go b/provider/bluecat/bluecat.go new file mode 100644 index 000000000..2205dcc0c --- /dev/null +++ b/provider/bluecat/bluecat.go @@ -0,0 +1,969 @@ +/* +Copyright 2020 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. +*/ + +// TODO: Ensure we have proper error handling/logging for API calls to Bluecat. getBluecatGatewayToken has a good example of this + +package bluecat + +import ( + "bytes" + "context" + "crypto/tls" + "encoding/json" + "io" + "io/ioutil" + "net/http" + "strconv" + "strings" + + "github.com/pkg/errors" + log "github.com/sirupsen/logrus" + + "sigs.k8s.io/external-dns/endpoint" + "sigs.k8s.io/external-dns/plan" + "sigs.k8s.io/external-dns/provider" +) + +type bluecatConfig struct { + GatewayHost string `json:"gatewayHost"` + GatewayUsername string `json:"gatewayUsername"` + GatewayPassword string `json:"gatewayPassword"` + DNSConfiguration string `json:"dnsConfiguration"` + View string `json:"dnsView"` + RootZone string `json:"rootZone"` +} + +// BluecatProvider implements the DNS provider for Bluecat DNS +type BluecatProvider struct { + provider.BaseProvider + domainFilter endpoint.DomainFilter + zoneIDFilter provider.ZoneIDFilter + dryRun bool + RootZone string + DNSConfiguration string + View string + gatewayClient GatewayClient +} + +type GatewayClient interface { + getBluecatZones(zoneName string) ([]BluecatZone, error) + getHostRecords(zone string, records *[]BluecatHostRecord) error + getCNAMERecords(zone string, records *[]BluecatCNAMERecord) error + getHostRecord(name string, record *BluecatHostRecord) error + getCNAMERecord(name string, record *BluecatCNAMERecord) error + createHostRecord(zone string, req *bluecatCreateHostRecordRequest) (res interface{}, err error) + createCNAMERecord(zone string, req *bluecatCreateCNAMERecordRequest) (res interface{}, err error) + deleteHostRecord(name string) (err error) + deleteCNAMERecord(name string) (err error) + buildHTTPRequest(method, url string, body io.Reader) (*http.Request, error) + getTXTRecords(zone string, records *[]BluecatTXTRecord) error + getTXTRecord(name string, record *BluecatTXTRecord) error + createTXTRecord(zone string, req *bluecatCreateTXTRecordRequest) (res interface{}, err error) + deleteTXTRecord(name string) error +} + +// GatewayClientConfig defines new client on bluecat gateway +type GatewayClientConfig struct { + Cookie http.Cookie + Token string + Host string + DNSConfiguration string + View string + RootZone string +} + +// BluecatZone defines a zone to hold records +type BluecatZone struct { + ID int `json:"id"` + Name string `json:"name"` + Properties string `json:"properties"` + Type string `json:"type"` +} + +// BluecatHostRecord defines dns Host record +type BluecatHostRecord struct { + ID int `json:"id"` + Name string `json:"name"` + Properties string `json:"properties"` + Type string `json:"type"` +} + +// BluecatCNAMERecord defines dns CNAME record +type BluecatCNAMERecord struct { + ID int `json:"id"` + Name string `json:"name"` + Properties string `json:"properties"` + Type string `json:"type"` +} + +// BluecatTXTRecord defines dns TXT record +type BluecatTXTRecord struct { + ID int `json:"id"` + Name string `json:"name"` + Text string `json:"text"` +} + +type bluecatRecordSet struct { + obj interface{} + res interface{} +} + +type bluecatCreateHostRecordRequest struct { + AbsoluteName string `json:"absolute_name"` + IP4Address string `json:"ip4_address"` + TTL int `json:"ttl"` + Properties string `json:"properties"` +} + +type bluecatCreateCNAMERecordRequest struct { + AbsoluteName string `json:"absolute_name"` + LinkedRecord string `json:"linked_record"` + TTL int `json:"ttl"` + Properties string `json:"properties"` +} + +type bluecatCreateTXTRecordRequest struct { + AbsoluteName string `json:"absolute_name"` + Text string `json:"txt"` +} + +// NewBluecatProvider creates a new Bluecat provider. +// +// Returns a pointer to the provider or an error if a provider could not be created. +func NewBluecatProvider(configFile string, domainFilter endpoint.DomainFilter, zoneIDFilter provider.ZoneIDFilter, dryRun bool) (*BluecatProvider, error) { + contents, err := ioutil.ReadFile(configFile) + if err != nil { + return nil, errors.Wrapf(err, "failed to read Bluecat config file %v", configFile) + } + + cfg := bluecatConfig{} + err = json.Unmarshal(contents, &cfg) + if err != nil { + return nil, errors.Wrapf(err, "failed to read Bluecat config file %v", configFile) + } + + token, cookie, err := getBluecatGatewayToken(cfg) + if err != nil { + return nil, errors.Wrap(err, "failed to get API token from Bluecat Gateway") + } + gatewayClient := NewGatewayClient(cookie, token, cfg.GatewayHost, cfg.DNSConfiguration, cfg.View, cfg.RootZone) + + provider := &BluecatProvider{ + domainFilter: domainFilter, + zoneIDFilter: zoneIDFilter, + dryRun: dryRun, + gatewayClient: gatewayClient, + DNSConfiguration: cfg.DNSConfiguration, + View: cfg.View, + RootZone: cfg.RootZone, + } + return provider, nil +} + +// NewGatewayClient creates and returns a new Bluecat gateway client +func NewGatewayClient(cookie http.Cookie, token, gatewayHost, dnsConfiguration, view, rootZone string) GatewayClientConfig { + // Right now the Bluecat gateway doesn't seem to have a way to get the root zone from the API. If the user + // doesn't provide one via the config file we'll assume it's 'com' + if rootZone == "" { + rootZone = "com" + } + return GatewayClientConfig{ + Cookie: cookie, + Token: token, + Host: gatewayHost, + DNSConfiguration: dnsConfiguration, + View: view, + RootZone: rootZone, + } +} + +// Records fetches Host, CNAME, and TXT records from bluecat gateway +func (p *BluecatProvider) Records(ctx context.Context) (endpoints []*endpoint.Endpoint, err error) { + zones, err := p.zones() + if err != nil { + return nil, errors.Wrap(err, "could not fetch zones") + } + + for _, zone := range zones { + log.Debugf("fetching records from zone '%s'", zone) + var resH []BluecatHostRecord + err = p.gatewayClient.getHostRecords(zone, &resH) + if err != nil { + return nil, errors.Wrapf(err, "could not fetch host records for zone: %v", zone) + } + for _, rec := range resH { + propMap := splitProperties(rec.Properties) + ips := strings.Split(propMap["addresses"], ",") + for _, ip := range ips { + ep := endpoint.NewEndpoint(propMap["absoluteName"], endpoint.RecordTypeA, ip) + endpoints = append(endpoints, ep) + } + } + + var resC []BluecatCNAMERecord + err = p.gatewayClient.getCNAMERecords(zone, &resC) + if err != nil { + return nil, errors.Wrapf(err, "could not fetch CNAME records for zone: %v", zone) + } + for _, rec := range resC { + propMap := splitProperties(rec.Properties) + endpoints = append(endpoints, endpoint.NewEndpoint(propMap["absoluteName"], endpoint.RecordTypeCNAME, propMap["linkedRecordName"])) + } + + var resT []BluecatTXTRecord + err = p.gatewayClient.getTXTRecords(zone, &resT) + if err != nil { + return nil, errors.Wrapf(err, "could not fetch TXT records for zone: %v", zone) + } + for _, rec := range resT { + endpoints = append(endpoints, endpoint.NewEndpoint(rec.Name, endpoint.RecordTypeTXT, rec.Text)) + } + } + + log.Debugf("fetched %d records from Bluecat", len(endpoints)) + return endpoints, nil +} + +// ApplyChanges updates necessary zones and replaces old records with new ones +// +// Returns nil upon success and err is there is an error +func (p *BluecatProvider) ApplyChanges(ctx context.Context, changes *plan.Changes) error { + zones, err := p.zones() + if err != nil { + return err + } + log.Infof("zones is: %+v\n", zones) + log.Infof("changes: %+v\n", changes) + created, deleted := p.mapChanges(zones, changes) + log.Infof("created: %+v\n", created) + log.Infof("deleted: %+v\n", deleted) + p.deleteRecords(deleted) + p.createRecords(created) + + // TODO: add bluecat deploy API call here + + return nil +} + +type bluecatChangeMap map[string][]*endpoint.Endpoint + +func (p *BluecatProvider) mapChanges(zones []string, changes *plan.Changes) (bluecatChangeMap, bluecatChangeMap) { + created := bluecatChangeMap{} + deleted := bluecatChangeMap{} + + mapChange := func(changeMap bluecatChangeMap, change *endpoint.Endpoint) { + zone := p.findZone(zones, change.DNSName) + if zone == "" { + log.Debugf("ignoring changes to '%s' because a suitable Bluecat DNS zone was not found", change.DNSName) + return + } + changeMap[zone] = append(changeMap[zone], change) + } + + for _, change := range changes.Delete { + mapChange(deleted, change) + } + for _, change := range changes.UpdateOld { + mapChange(deleted, change) + } + for _, change := range changes.Create { + mapChange(created, change) + } + for _, change := range changes.UpdateNew { + mapChange(created, change) + } + + return created, deleted +} + +// findZone finds the most specific matching zone for a given record 'name' from a list of all zones +func (p *BluecatProvider) findZone(zones []string, name string) string { + var result string + + for _, zone := range zones { + if strings.HasSuffix(name, "."+zone) { + if result == "" || len(zone) > len(result) { + result = zone + } + } else if strings.EqualFold(name, zone) { + if result == "" || len(zone) > len(result) { + result = zone + } + } + } + + return result +} + +func (p *BluecatProvider) zones() ([]string, error) { + log.Debugf("retrieving Bluecat zones for configuration: %s, view: %s", p.DNSConfiguration, p.View) + var zones []string + + zonelist, err := p.gatewayClient.getBluecatZones(p.RootZone) + if err != nil { + return nil, err + } + + for _, zone := range zonelist { + if !p.domainFilter.Match(zone.Name) { + continue + } + + // TODO: match to absoluteName(string) not Id(int) + if !p.zoneIDFilter.Match(strconv.Itoa(zone.ID)) { + continue + } + + zoneProps := splitProperties(zone.Properties) + + zones = append(zones, zoneProps["absoluteName"]) + } + log.Debugf("found %d zones", len(zones)) + return zones, nil +} + +func (p *BluecatProvider) createRecords(created bluecatChangeMap) { + for zone, endpoints := range created { + for _, ep := range endpoints { + if p.dryRun { + log.Infof("would create %s record named '%s' to '%s' for Bluecat DNS zone '%s'.", + ep.RecordType, + ep.DNSName, + ep.Targets, + zone, + ) + continue + } + + log.Infof("creating %s record named '%s' to '%s' for Bluecat DNS zone '%s'.", + ep.RecordType, + ep.DNSName, + ep.Targets, + zone, + ) + + recordSet, err := p.recordSet(ep, false) + if err != nil { + log.Errorf( + "Failed to retrieve %s record named '%s' to '%s' for Bluecat DNS zone '%s': %v", + ep.RecordType, + ep.DNSName, + ep.Targets, + zone, + err, + ) + continue + } + var response interface{} + switch ep.RecordType { + case endpoint.RecordTypeA: + response, err = p.gatewayClient.createHostRecord(zone, recordSet.obj.(*bluecatCreateHostRecordRequest)) + case endpoint.RecordTypeCNAME: + response, err = p.gatewayClient.createCNAMERecord(zone, recordSet.obj.(*bluecatCreateCNAMERecordRequest)) + case endpoint.RecordTypeTXT: + response, err = p.gatewayClient.createTXTRecord(zone, recordSet.obj.(*bluecatCreateTXTRecordRequest)) + } + log.Debugf("Response from create: %v", response) + if err != nil { + log.Errorf( + "Failed to create %s record named '%s' to '%s' for Bluecat DNS zone '%s': %v", + ep.RecordType, + ep.DNSName, + ep.Targets, + zone, + err, + ) + } + } + } +} + +func (p *BluecatProvider) deleteRecords(deleted bluecatChangeMap) { + // run deletions first + for zone, endpoints := range deleted { + for _, ep := range endpoints { + if p.dryRun { + log.Infof("would delete %s record named '%s' for Bluecat DNS zone '%s'.", + ep.RecordType, + ep.DNSName, + zone, + ) + continue + } else { + log.Infof("deleting %s record named '%s' for Bluecat DNS zone '%s'.", + ep.RecordType, + ep.DNSName, + zone, + ) + + recordSet, err := p.recordSet(ep, true) + if err != nil { + log.Errorf( + "Failed to retrieve %s record named '%s' to '%s' for Bluecat DNS zone '%s': %v", + ep.RecordType, + ep.DNSName, + ep.Targets, + zone, + err, + ) + continue + } + + switch ep.RecordType { + case endpoint.RecordTypeA: + for _, record := range *recordSet.res.(*[]BluecatHostRecord) { + err = p.gatewayClient.deleteHostRecord(record.Name) + } + case endpoint.RecordTypeCNAME: + for _, record := range *recordSet.res.(*[]BluecatCNAMERecord) { + err = p.gatewayClient.deleteCNAMERecord(record.Name) + } + case endpoint.RecordTypeTXT: + for _, record := range *recordSet.res.(*[]BluecatTXTRecord) { + err = p.gatewayClient.deleteTXTRecord(record.Name) + } + } + if err != nil { + log.Errorf("Failed to delete %s record named '%s' for Bluecat DNS zone '%s': %v", + ep.RecordType, + ep.DNSName, + zone, + err) + } + } + } + } +} + +func (p *BluecatProvider) recordSet(ep *endpoint.Endpoint, getObject bool) (recordSet bluecatRecordSet, err error) { + switch ep.RecordType { + case endpoint.RecordTypeA: + var res []BluecatHostRecord + // TODO Allow configurable properties/ttl + obj := bluecatCreateHostRecordRequest{ + AbsoluteName: ep.DNSName, + IP4Address: ep.Targets[0], + TTL: 0, + Properties: "", + } + if getObject { + var record BluecatHostRecord + err = p.gatewayClient.getHostRecord(ep.DNSName, &record) + if err != nil { + return + } + res = append(res, record) + } + recordSet = bluecatRecordSet{ + obj: &obj, + res: &res, + } + case endpoint.RecordTypeCNAME: + var res []BluecatCNAMERecord + obj := bluecatCreateCNAMERecordRequest{ + AbsoluteName: ep.DNSName, + LinkedRecord: ep.Targets[0], + TTL: 0, + Properties: "", + } + if getObject { + var record BluecatCNAMERecord + err = p.gatewayClient.getCNAMERecord(ep.DNSName, &record) + if err != nil { + return + } + res = append(res, record) + } + recordSet = bluecatRecordSet{ + obj: &obj, + res: &res, + } + case endpoint.RecordTypeTXT: + var res []BluecatTXTRecord + obj := bluecatCreateTXTRecordRequest{ + AbsoluteName: ep.DNSName, + Text: ep.Targets[0], + } + if getObject { + var record BluecatTXTRecord + err = p.gatewayClient.getTXTRecord(ep.DNSName, &record) + if err != nil { + return + } + res = append(res, record) + } + recordSet = bluecatRecordSet{ + obj: &obj, + res: &res, + } + } + return +} + +// getBluecatGatewayToken retrieves a Bluecat Gateway API token. +func getBluecatGatewayToken(cfg bluecatConfig) (string, http.Cookie, error) { + body, err := json.Marshal(map[string]string{ + "username": cfg.GatewayUsername, + "password": cfg.GatewayPassword, + }) + if err != nil { + return "", http.Cookie{}, errors.Wrap(err, "could not unmarshal credentials for bluecat gateway config") + } + + c := &http.Client{ + Transport: &http.Transport{ + TLSClientConfig: &tls.Config{InsecureSkipVerify: true}, // ignore self-signed SSL cert check + }} + + resp, err := c.Post(cfg.GatewayHost+"/rest_login", "application/json", bytes.NewBuffer(body)) + if err != nil { + return "", http.Cookie{}, errors.Wrap(err, "error obtaining API token from bluecat gateway") + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + details, _ := ioutil.ReadAll(resp.Body) + return "", http.Cookie{}, errors.Errorf("got HTTP response code %v, detailed message: %v", resp.StatusCode, string(details)) + } + + res, err := ioutil.ReadAll(resp.Body) + if err != nil { + return "", http.Cookie{}, errors.Wrap(err, "error reading get_token response from bluecat gateway") + } + + resJSON := map[string]string{} + err = json.Unmarshal(res, &resJSON) + if err != nil { + return "", http.Cookie{}, errors.Wrap(err, "error unmarshaling json response (auth) from bluecat gateway") + } + + // Example response: {"access_token": "BAMAuthToken: abc123"} + // We only care about the actual token string - i.e. abc123 + // The gateway also creates a cookie as part of the response. This seems to be the actual auth mechanism, at least + // for now. + return strings.Split(resJSON["access_token"], " ")[1], *resp.Cookies()[0], nil +} + +func (c GatewayClientConfig) getBluecatZones(zoneName string) ([]BluecatZone, error) { + transportCfg := &http.Transport{ + TLSClientConfig: &tls.Config{InsecureSkipVerify: true}, //ignore self-signed SSL cert check + } + client := &http.Client{ + Transport: transportCfg, + } + zonePath := expandZone(zoneName) + url := c.Host + "/api/v1/configurations/" + c.DNSConfiguration + "/views/" + c.View + "/" + zonePath + req, err := c.buildHTTPRequest("GET", url, nil) + if err != nil { + return nil, errors.Wrap(err, "error building http request") + } + + resp, err := client.Do(req) + if err != nil { + return nil, errors.Wrapf(err, "error retrieving zone(s) from gateway: %v, %v", url, zoneName) + } + + defer resp.Body.Close() + + zones := []BluecatZone{} + json.NewDecoder(resp.Body).Decode(&zones) + + // Bluecat Gateway only returns subzones one level deeper than the provided zone + // so this recursion is needed to traverse subzones until none are returned + for _, zone := range zones { + zoneProps := splitProperties(zone.Properties) + subZones, err := c.getBluecatZones(zoneProps["absoluteName"]) + if err != nil { + return nil, errors.Wrapf(err, "error retrieving subzones from gateway: %v", zoneName) + } + zones = append(zones, subZones...) + } + + return zones, nil +} + +func (c GatewayClientConfig) getHostRecords(zone string, records *[]BluecatHostRecord) error { + transportCfg := &http.Transport{ + TLSClientConfig: &tls.Config{InsecureSkipVerify: true}, //ignore self-signed SSL cert check + } + client := &http.Client{ + Transport: transportCfg, + } + + zonePath := expandZone(zone) + + // Remove the trailing 'zones/' + zonePath = strings.TrimSuffix(zonePath, "zones/") + + url := c.Host + "/api/v1/configurations/" + c.DNSConfiguration + "/views/" + c.View + "/" + zonePath + "host_records/" + req, err := c.buildHTTPRequest("GET", url, nil) + if err != nil { + return errors.Wrap(err, "error building http request") + } + + resp, err := client.Do(req) + if err != nil { + return errors.Wrapf(err, "error retrieving record(s) from gateway: %v", zone) + } + + defer resp.Body.Close() + + json.NewDecoder(resp.Body).Decode(records) + log.Debugf("Get Host Records Response: %v", records) + + return nil +} + +func (c GatewayClientConfig) getCNAMERecords(zone string, records *[]BluecatCNAMERecord) error { + transportCfg := &http.Transport{ + TLSClientConfig: &tls.Config{InsecureSkipVerify: true}, //ignore self-signed SSL cert check + } + client := &http.Client{ + Transport: transportCfg, + } + + zonePath := expandZone(zone) + + // Remove the trailing 'zones/' + zonePath = strings.TrimSuffix(zonePath, "zones/") + + url := c.Host + "/api/v1/configurations/" + c.DNSConfiguration + "/views/" + c.View + "/" + zonePath + "cname_records/" + req, err := c.buildHTTPRequest("GET", url, nil) + if err != nil { + return errors.Wrap(err, "error building http request") + } + + resp, err := client.Do(req) + if err != nil { + return errors.Wrapf(err, "error retrieving record(s) from gateway: %v", zone) + } + + defer resp.Body.Close() + + json.NewDecoder(resp.Body).Decode(records) + log.Debugf("Get CName Records Response: %v", records) + + return nil +} + +func (c GatewayClientConfig) getTXTRecords(zone string, records *[]BluecatTXTRecord) error { + transportCfg := &http.Transport{ + TLSClientConfig: &tls.Config{InsecureSkipVerify: true}, //ignore self-signed SSL cert check + } + client := &http.Client{ + Transport: transportCfg, + } + + zonePath := expandZone(zone) + + // Remove the trailing 'zones/' + zonePath = strings.TrimSuffix(zonePath, "zones/") + + url := c.Host + "/api/v1/configurations/" + c.DNSConfiguration + "/views/" + c.View + "/" + zonePath + "text_records/" + req, err := c.buildHTTPRequest("GET", url, nil) + if err != nil { + return errors.Wrap(err, "error building http request") + } + log.Debugf("Request: %v", req) + + resp, err := client.Do(req) + if err != nil { + return errors.Wrapf(err, "error retrieving record(s) from gateway: %v", zone) + } + log.Debugf("Get Txt Records response: %v", resp) + + defer resp.Body.Close() + + json.NewDecoder(resp.Body).Decode(records) + log.Debugf("Get TXT Records Body: %v", records) + + return nil +} + +func (c GatewayClientConfig) getHostRecord(name string, record *BluecatHostRecord) error { + transportCfg := &http.Transport{ + TLSClientConfig: &tls.Config{InsecureSkipVerify: true}, //ignore self-signed SSL cert check + } + client := &http.Client{ + Transport: transportCfg, + } + + url := c.Host + "/api/v1/configurations/" + c.DNSConfiguration + + "/views/" + c.View + "/" + + "host_records/" + name + "/" + req, err := c.buildHTTPRequest("GET", url, nil) + if err != nil { + return errors.Wrapf(err, "error building http request: %v", name) + } + + resp, err := client.Do(req) + if err != nil { + return errors.Wrapf(err, "error retrieving record(s) from gateway: %v", name) + } + + defer resp.Body.Close() + + json.NewDecoder(resp.Body).Decode(record) + log.Debugf("Get Host Record Response: %v", record) + return nil +} + +func (c GatewayClientConfig) getCNAMERecord(name string, record *BluecatCNAMERecord) error { + transportCfg := &http.Transport{ + TLSClientConfig: &tls.Config{InsecureSkipVerify: true}, //ignore self-signed SSL cert check + } + client := &http.Client{ + Transport: transportCfg, + } + + url := c.Host + "/api/v1/configurations/" + c.DNSConfiguration + + "/views/" + c.View + "/" + + "cname_records/" + name + "/" + req, err := c.buildHTTPRequest("GET", url, nil) + if err != nil { + return errors.Wrapf(err, "error building http request: %v", name) + } + + resp, err := client.Do(req) + if err != nil { + return errors.Wrapf(err, "error retrieving record(s) from gateway: %v", name) + } + + defer resp.Body.Close() + + json.NewDecoder(resp.Body).Decode(record) + log.Debugf("Get CName Record Response: %v", record) + return nil +} + +func (c GatewayClientConfig) getTXTRecord(name string, record *BluecatTXTRecord) error { + transportCfg := &http.Transport{ + TLSClientConfig: &tls.Config{InsecureSkipVerify: true}, //ignore self-signed SSL cert check + } + client := &http.Client{ + Transport: transportCfg, + } + + url := c.Host + "/api/v1/configurations/" + c.DNSConfiguration + + "/views/" + c.View + "/" + + "text_records/" + name + "/" + req, err := c.buildHTTPRequest("GET", url, nil) + if err != nil { + return errors.Wrap(err, "error building http request") + } + + resp, err := client.Do(req) + if err != nil { + return errors.Wrapf(err, "error retrieving record(s) from gateway: %v", name) + } + + defer resp.Body.Close() + + json.NewDecoder(resp.Body).Decode(record) + log.Debugf("Get TXT Record Response: %v", record) + + return nil +} + +func (c GatewayClientConfig) createHostRecord(zone string, req *bluecatCreateHostRecordRequest) (res interface{}, err error) { + transportCfg := &http.Transport{ + TLSClientConfig: &tls.Config{InsecureSkipVerify: true}, //ignore self-signed SSL cert check + } + client := &http.Client{ + Transport: transportCfg, + } + + zonePath := expandZone(zone) + // Remove the trailing 'zones/' + zonePath = strings.TrimSuffix(zonePath, "zones/") + + url := c.Host + "/api/v1/configurations/" + c.DNSConfiguration + "/views/" + c.View + "/" + zonePath + "host_records/" + body, _ := json.Marshal(req) + hreq, err := c.buildHTTPRequest("POST", url, bytes.NewBuffer(body)) + if err != nil { + return nil, errors.Wrap(err, "error building http request") + } + hreq.Header.Add("Content-Type", "application/json") + res, err = client.Do(hreq) + + return +} + +func (c GatewayClientConfig) createCNAMERecord(zone string, req *bluecatCreateCNAMERecordRequest) (res interface{}, err error) { + transportCfg := &http.Transport{ + TLSClientConfig: &tls.Config{InsecureSkipVerify: true}, //ignore self-signed SSL cert check + } + client := &http.Client{ + Transport: transportCfg, + } + + zonePath := expandZone(zone) + // Remove the trailing 'zones/' + zonePath = strings.TrimSuffix(zonePath, "zones/") + + url := c.Host + "/api/v1/configurations/" + c.DNSConfiguration + "/views/" + c.View + "/" + zonePath + "cname_records/" + body, _ := json.Marshal(req) + + hreq, err := c.buildHTTPRequest("POST", url, bytes.NewBuffer(body)) + if err != nil { + return nil, errors.Wrap(err, "error building http request") + } + + hreq.Header.Add("Content-Type", "application/json") + res, err = client.Do(hreq) + + return +} + +func (c GatewayClientConfig) createTXTRecord(zone string, req *bluecatCreateTXTRecordRequest) (interface{}, error) { + transportCfg := &http.Transport{ + TLSClientConfig: &tls.Config{InsecureSkipVerify: true}, //ignore self-signed SSL cert check + } + client := &http.Client{ + Transport: transportCfg, + } + + zonePath := expandZone(zone) + // Remove the trailing 'zones/' + zonePath = strings.TrimSuffix(zonePath, "zones/") + + url := c.Host + "/api/v1/configurations/" + c.DNSConfiguration + "/views/" + c.View + "/" + zonePath + "text_records/" + body, _ := json.Marshal(req) + hreq, err := c.buildHTTPRequest("POST", url, bytes.NewBuffer(body)) + if err != nil { + return nil, err + } + + hreq.Header.Add("Content-Type", "application/json") + res, err := client.Do(hreq) + + return res, err +} + +func (c GatewayClientConfig) deleteHostRecord(name string) (err error) { + transportCfg := &http.Transport{ + TLSClientConfig: &tls.Config{InsecureSkipVerify: true}, //ignore self-signed SSL cert check + } + client := &http.Client{ + Transport: transportCfg, + } + + url := c.Host + "/api/v1/configurations/" + c.DNSConfiguration + + "/views/" + c.View + "/" + + "host_records/" + name + "/" + req, err := c.buildHTTPRequest("DELETE", url, nil) + if err != nil { + return errors.Wrapf(err, "error building http request: %v", name) + } + + _, err = client.Do(req) + if err != nil { + return errors.Wrapf(err, "error deleting record(s) from gateway: %v", name) + } + + return nil +} + +func (c GatewayClientConfig) deleteCNAMERecord(name string) (err error) { + transportCfg := &http.Transport{ + TLSClientConfig: &tls.Config{InsecureSkipVerify: true}, //ignore self-signed SSL cert check + } + client := &http.Client{ + Transport: transportCfg, + } + + url := c.Host + "/api/v1/configurations/" + c.DNSConfiguration + + "/views/" + c.View + "/" + + "cname_records/" + name + "/" + req, err := c.buildHTTPRequest("DELETE", url, nil) + if err != nil { + return errors.Wrapf(err, "error building http request: %v", name) + } + + _, err = client.Do(req) + if err != nil { + return errors.Wrapf(err, "error deleting record(s) from gateway: %v", name) + } + + return nil +} + +func (c GatewayClientConfig) deleteTXTRecord(name string) error { + transportCfg := &http.Transport{ + TLSClientConfig: &tls.Config{InsecureSkipVerify: true}, //ignore self-signed SSL cert check + } + client := &http.Client{ + Transport: transportCfg, + } + + url := c.Host + "/api/v1/configurations/" + c.DNSConfiguration + + "/views/" + c.View + "/" + + "text_records/" + name + "/" + + req, err := c.buildHTTPRequest("DELETE", url, nil) + if err != nil { + return errors.Wrap(err, "error building http request") + } + + _, err = client.Do(req) + if err != nil { + return errors.Wrapf(err, "error deleting record(s) from gateway: %v", name) + } + + return nil +} + +//buildHTTPRequest builds a standard http Request and adds authentication headers required by Bluecat Gateway +func (c GatewayClientConfig) buildHTTPRequest(method, url string, body io.Reader) (*http.Request, error) { + req, err := http.NewRequest(method, url, body) + req.Header.Add("Accept", "application/json") + req.Header.Add("Authorization", "Basic "+c.Token) + req.AddCookie(&c.Cookie) + return req, err +} + +//splitProperties is a helper function to break a '|' separated string into key/value pairs +// i.e. "foo=bar|baz=mop" +func splitProperties(props string) map[string]string { + propMap := make(map[string]string) + + // remove trailing | character before we split + props = strings.TrimSuffix(props, "|") + + splits := strings.Split(props, "|") + for _, pair := range splits { + items := strings.Split(pair, "=") + propMap[items[0]] = items[1] + } + + return propMap +} + +//expandZone takes an absolute domain name such as 'example.com' and returns a zone hierarchy used by Bluecat Gateway, +//such as '/zones/com/zones/example/zones/' +func expandZone(zone string) string { + ze := "zones/" + parts := strings.Split(zone, ".") + if len(parts) > 1 { + last := len(parts) - 1 + for i := range parts { + ze = ze + parts[last-i] + "/zones/" + } + } else { + ze = ze + zone + "/zones/" + } + return ze +} diff --git a/provider/bluecat/bluecat_test.go b/provider/bluecat/bluecat_test.go new file mode 100644 index 000000000..1ec751d70 --- /dev/null +++ b/provider/bluecat/bluecat_test.go @@ -0,0 +1,390 @@ +/* +Copyright 2020 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 bluecat + +import ( + "context" + "fmt" + "io" + "net/http" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "sigs.k8s.io/external-dns/endpoint" + "sigs.k8s.io/external-dns/internal/testutils" + "sigs.k8s.io/external-dns/plan" + "sigs.k8s.io/external-dns/provider" +) + +type mockGatewayClient struct { + mockBluecatZones *[]BluecatZone + mockBluecatHosts *[]BluecatHostRecord + mockBluecatCNAMEs *[]BluecatCNAMERecord + mockBluecatTXTs *[]BluecatTXTRecord +} + +type Changes struct { + // Records that need to be created + Create []*endpoint.Endpoint + // Records that need to be updated (current data) + UpdateOld []*endpoint.Endpoint + // Records that need to be updated (desired data) + UpdateNew []*endpoint.Endpoint + // Records that need to be deleted + Delete []*endpoint.Endpoint +} + +func (g mockGatewayClient) getBluecatZones(zoneName string) ([]BluecatZone, error) { + return *g.mockBluecatZones, nil +} +func (g mockGatewayClient) getHostRecords(zone string, records *[]BluecatHostRecord) error { + *records = *g.mockBluecatHosts + return nil +} +func (g mockGatewayClient) getCNAMERecords(zone string, records *[]BluecatCNAMERecord) error { + *records = *g.mockBluecatCNAMEs + return nil +} +func (g mockGatewayClient) getHostRecord(name string, record *BluecatHostRecord) error { + for _, currentRecord := range *g.mockBluecatHosts { + if currentRecord.Name == strings.Split(name, ".")[0] { + *record = currentRecord + return nil + } + } + return nil +} +func (g mockGatewayClient) getCNAMERecord(name string, record *BluecatCNAMERecord) error { + for _, currentRecord := range *g.mockBluecatCNAMEs { + if currentRecord.Name == strings.Split(name, ".")[0] { + *record = currentRecord + return nil + } + } + return nil +} +func (g mockGatewayClient) createHostRecord(zone string, req *bluecatCreateHostRecordRequest) (res interface{}, err error) { + return nil, nil +} +func (g mockGatewayClient) createCNAMERecord(zone string, req *bluecatCreateCNAMERecordRequest) (res interface{}, err error) { + return nil, nil +} +func (g mockGatewayClient) deleteHostRecord(name string) (err error) { + *g.mockBluecatHosts = nil + return nil +} +func (g mockGatewayClient) deleteCNAMERecord(name string) (err error) { + *g.mockBluecatCNAMEs = nil + return nil +} +func (g mockGatewayClient) getTXTRecords(zone string, records *[]BluecatTXTRecord) error { + *records = *g.mockBluecatTXTs + return nil +} +func (g mockGatewayClient) getTXTRecord(name string, record *BluecatTXTRecord) error { + for _, currentRecord := range *g.mockBluecatTXTs { + if currentRecord.Name == name { + *record = currentRecord + return nil + } + } + return nil +} +func (g mockGatewayClient) createTXTRecord(zone string, req *bluecatCreateTXTRecordRequest) (res interface{}, err error) { + return nil, nil +} +func (g mockGatewayClient) deleteTXTRecord(name string) error { + *g.mockBluecatTXTs = nil + return nil +} + +func (g mockGatewayClient) buildHTTPRequest(method, url string, body io.Reader) (*http.Request, error) { + request, _ := http.NewRequest("GET", fmt.Sprintf("%s/users", "http://some.com/api/v1"), nil) + return request, nil +} + +func createMockBluecatZone(fqdn string) BluecatZone { + props := "absoluteName=" + fqdn + return BluecatZone{ + Properties: props, + Name: fqdn, + ID: 3, + } +} + +func createMockBluecatHostRecord(fqdn, target string) BluecatHostRecord { + props := "absoluteName=" + fqdn + "|addresses=" + target + "|" + nameParts := strings.Split(fqdn, ".") + return BluecatHostRecord{ + Name: nameParts[0], + Properties: props, + ID: 3, + } +} + +func createMockBluecatCNAME(alias, target string) BluecatCNAMERecord { + props := "absoluteName=" + alias + "|linkedRecordName=" + target + "|" + nameParts := strings.Split(alias, ".") + return BluecatCNAMERecord{ + Name: nameParts[0], + Properties: props, + } +} + +func createMockBluecatTXT(fqdn, txt string) BluecatTXTRecord { + return BluecatTXTRecord{ + Name: fqdn, + Text: txt, + } +} + +func newBluecatProvider(domainFilter endpoint.DomainFilter, zoneIDFilter provider.ZoneIDFilter, dryRun bool, client GatewayClient) *BluecatProvider { + return &BluecatProvider{ + domainFilter: domainFilter, + zoneIDFilter: zoneIDFilter, + dryRun: dryRun, + gatewayClient: client, + } +} + +type bluecatTestData []struct { + TestDescription string + Endpoints []*endpoint.Endpoint +} + +var tests = bluecatTestData{ + { + "first test case", // TODO: better test description + []*endpoint.Endpoint{ + { + DNSName: "example.com", + RecordType: endpoint.RecordTypeA, + Targets: endpoint.Targets{"123.123.123.122"}, + }, + { + DNSName: "nginx.example.com", + RecordType: endpoint.RecordTypeA, + Targets: endpoint.Targets{"123.123.123.123"}, + }, + { + DNSName: "whitespace.example.com", + RecordType: endpoint.RecordTypeA, + Targets: endpoint.Targets{"123.123.123.124"}, + }, + { + DNSName: "hack.example.com", + RecordType: endpoint.RecordTypeCNAME, + Targets: endpoint.Targets{"bluecatnetworks.com"}, + }, + { + DNSName: "abc.example.com", + RecordType: endpoint.RecordTypeTXT, + Targets: endpoint.Targets{"hello"}, + }, + }, + }, +} + +func TestBluecatRecords(t *testing.T) { + client := mockGatewayClient{ + mockBluecatZones: &[]BluecatZone{ + createMockBluecatZone("example.com"), + }, + mockBluecatHosts: &[]BluecatHostRecord{ + createMockBluecatHostRecord("example.com", "123.123.123.122"), + createMockBluecatHostRecord("nginx.example.com", "123.123.123.123"), + createMockBluecatHostRecord("whitespace.example.com", "123.123.123.124"), + }, + mockBluecatCNAMEs: &[]BluecatCNAMERecord{ + createMockBluecatCNAME("hack.example.com", "bluecatnetworks.com"), + }, + mockBluecatTXTs: &[]BluecatTXTRecord{ + createMockBluecatTXT("abc.example.com", "hello"), + }, + } + + provider := newBluecatProvider( + endpoint.NewDomainFilter([]string{"example.com"}), + provider.NewZoneIDFilter([]string{""}), false, client) + + for _, ti := range tests { + actual, err := provider.Records(context.Background()) + if err != nil { + t.Fatal(err) + } + validateEndpoints(t, actual, ti.Endpoints) + } +} + +func TestBluecatApplyChangesCreate(t *testing.T) { + client := mockGatewayClient{ + mockBluecatZones: &[]BluecatZone{ + createMockBluecatZone("example.com"), + }, + mockBluecatHosts: &[]BluecatHostRecord{}, + mockBluecatCNAMEs: &[]BluecatCNAMERecord{}, + mockBluecatTXTs: &[]BluecatTXTRecord{}, + } + + provider := newBluecatProvider( + endpoint.NewDomainFilter([]string{"example.com"}), + provider.NewZoneIDFilter([]string{""}), false, client) + + for _, ti := range tests { + err := provider.ApplyChanges(context.Background(), &plan.Changes{Create: ti.Endpoints}) + if err != nil { + t.Fatal(err) + } + + actual, err := provider.Records(context.Background()) + if err != nil { + t.Fatal(err) + } + validateEndpoints(t, actual, []*endpoint.Endpoint{}) + } +} +func TestBluecatApplyChangesDelete(t *testing.T) { + client := mockGatewayClient{ + mockBluecatZones: &[]BluecatZone{ + createMockBluecatZone("example.com"), + }, + mockBluecatHosts: &[]BluecatHostRecord{ + createMockBluecatHostRecord("example.com", "123.123.123.122"), + createMockBluecatHostRecord("nginx.example.com", "123.123.123.123"), + createMockBluecatHostRecord("whitespace.example.com", "123.123.123.124"), + }, + mockBluecatCNAMEs: &[]BluecatCNAMERecord{ + createMockBluecatCNAME("hack.example.com", "bluecatnetworks.com"), + }, + mockBluecatTXTs: &[]BluecatTXTRecord{ + createMockBluecatTXT("abc.example.com", "hello"), + }, + } + + provider := newBluecatProvider( + endpoint.NewDomainFilter([]string{"example.com"}), + provider.NewZoneIDFilter([]string{""}), false, client) + + for _, ti := range tests { + err := provider.ApplyChanges(context.Background(), &plan.Changes{Delete: ti.Endpoints}) + if err != nil { + t.Fatal(err) + } + + actual, err := provider.Records(context.Background()) + if err != nil { + t.Fatal(err) + } + validateEndpoints(t, actual, []*endpoint.Endpoint{}) + } +} + +// TODO: ensure mapChanges method is tested +// TODO: ensure findZone method is tested +// TODO: ensure zones method is tested +// TODO: ensure createRecords method is tested +// TODO: ensure deleteRecords method is tested +// TODO: ensure recordSet method is tested + +// TODO: Figure out why recordSet.res is not being set properly +func TestBluecatRecordset(t *testing.T) { + client := mockGatewayClient{ + mockBluecatZones: &[]BluecatZone{ + createMockBluecatZone("example.com"), + }, + mockBluecatHosts: &[]BluecatHostRecord{ + createMockBluecatHostRecord("example.com", "123.123.123.122"), + createMockBluecatHostRecord("nginx.example.com", "123.123.123.123"), + createMockBluecatHostRecord("whitespace.example.com", "123.123.123.124"), + }, + mockBluecatCNAMEs: &[]BluecatCNAMERecord{ + createMockBluecatCNAME("hack.example.com", "bluecatnetworks.com"), + }, + mockBluecatTXTs: &[]BluecatTXTRecord{ + createMockBluecatTXT("abc.example.com", "hello"), + }, + } + + provider := newBluecatProvider( + endpoint.NewDomainFilter([]string{"example.com"}), + provider.NewZoneIDFilter([]string{""}), false, client) + + // Test txt records for recordSet function + testTxtEndpoint := endpoint.NewEndpoint("abc.example.com", endpoint.RecordTypeTXT, "hello") + txtObj := bluecatCreateTXTRecordRequest{ + AbsoluteName: testTxtEndpoint.DNSName, + Text: testTxtEndpoint.Targets[0], + } + txtRecords := []BluecatTXTRecord{ + createMockBluecatTXT("abc.example.com", "hello"), + } + expected := bluecatRecordSet{ + obj: &txtObj, + res: &txtRecords, + } + actual, err := provider.recordSet(testTxtEndpoint, true) + if err != nil { + t.Fatal(err) + } + assert.Equal(t, actual.obj, expected.obj) + assert.Equal(t, actual.res, expected.res) + + // Test a records for recordSet function + testHostEndpoint := endpoint.NewEndpoint("whitespace.example.com", endpoint.RecordTypeA, "123.123.123.124") + hostObj := bluecatCreateHostRecordRequest{ + AbsoluteName: testHostEndpoint.DNSName, + IP4Address: testHostEndpoint.Targets[0], + } + hostRecords := []BluecatHostRecord{ + createMockBluecatHostRecord("whitespace.example.com", "123.123.123.124"), + } + hostExpected := bluecatRecordSet{ + obj: &hostObj, + res: &hostRecords, + } + hostActual, err := provider.recordSet(testHostEndpoint, true) + if err != nil { + t.Fatal(err) + } + assert.Equal(t, hostActual.obj, hostExpected.obj) + assert.Equal(t, hostActual.res, hostExpected.res) + + // Test CName records for recordSet function + testCnameEndpoint := endpoint.NewEndpoint("hack.example.com", endpoint.RecordTypeCNAME, "bluecatnetworks.com") + cnameObj := bluecatCreateCNAMERecordRequest{ + AbsoluteName: testCnameEndpoint.DNSName, + LinkedRecord: testCnameEndpoint.Targets[0], + } + cnameRecords := []BluecatCNAMERecord{ + createMockBluecatCNAME("hack.example.com", "bluecatnetworks.com"), + } + cnameExpected := bluecatRecordSet{ + obj: &cnameObj, + res: &cnameRecords, + } + cnameActual, err := provider.recordSet(testCnameEndpoint, true) + if err != nil { + t.Fatal(err) + } + assert.Equal(t, cnameActual.obj, cnameExpected.obj) + assert.Equal(t, cnameActual.res, cnameExpected.res) +} + +func validateEndpoints(t *testing.T, actual, expected []*endpoint.Endpoint) { + assert.True(t, testutils.SameEndpoints(actual, expected), "actual and expected endpoints don't match. %s:%s", actual, expected) +} From 0b8e047d2e025225b422851ae5ba3c7276524cd0 Mon Sep 17 00:00:00 2001 From: stovemeerkat Date: Thu, 18 Mar 2021 11:36:46 +0100 Subject: [PATCH 12/13] rfc2136: Add new flag to specify Kerberos realm for GSS-TSIG --- main.go | 2 +- pkg/apis/externaldns/types.go | 3 +++ provider/rfc2136/rfc2136.go | 8 ++++++-- provider/rfc2136/rfc2136_test.go | 2 +- 4 files changed, 11 insertions(+), 4 deletions(-) diff --git a/main.go b/main.go index 09860c08d..0773cc619 100644 --- a/main.go +++ b/main.go @@ -283,7 +283,7 @@ func main() { p, err = oci.NewOCIProvider(*config, domainFilter, zoneIDFilter, cfg.DryRun) } case "rfc2136": - p, err = rfc2136.NewRfc2136Provider(cfg.RFC2136Host, cfg.RFC2136Port, cfg.RFC2136Zone, cfg.RFC2136Insecure, cfg.RFC2136TSIGKeyName, cfg.RFC2136TSIGSecret, cfg.RFC2136TSIGSecretAlg, cfg.RFC2136TAXFR, domainFilter, cfg.DryRun, cfg.RFC2136MinTTL, cfg.RFC2136GSSTSIG, cfg.RFC2136KerberosUsername, cfg.RFC2136KerberosPassword, nil) + p, err = rfc2136.NewRfc2136Provider(cfg.RFC2136Host, cfg.RFC2136Port, cfg.RFC2136Zone, cfg.RFC2136Insecure, cfg.RFC2136TSIGKeyName, cfg.RFC2136TSIGSecret, cfg.RFC2136TSIGSecretAlg, cfg.RFC2136TAXFR, domainFilter, cfg.DryRun, cfg.RFC2136MinTTL, cfg.RFC2136GSSTSIG, cfg.RFC2136KerberosRealm, cfg.RFC2136KerberosUsername, cfg.RFC2136KerberosPassword, nil) case "ns1": p, err = ns1.NewNS1Provider( ns1.NS1Config{ diff --git a/pkg/apis/externaldns/types.go b/pkg/apis/externaldns/types.go index b8d43f462..5fc354a1d 100644 --- a/pkg/apis/externaldns/types.go +++ b/pkg/apis/externaldns/types.go @@ -142,6 +142,7 @@ type Config struct { RFC2136Zone string RFC2136Insecure bool RFC2136GSSTSIG bool + RFC2136KerberosRealm string RFC2136KerberosUsername string RFC2136KerberosPassword string RFC2136TSIGKeyName string @@ -255,6 +256,7 @@ var defaultConfig = &Config{ RFC2136Zone: "", RFC2136Insecure: false, RFC2136GSSTSIG: false, + RFC2136KerberosRealm: "", RFC2136KerberosUsername: "", RFC2136KerberosPassword: "", RFC2136TSIGKeyName: "", @@ -434,6 +436,7 @@ func (cfg *Config) ParseFlags(args []string) error { app.Flag("rfc2136-tsig-axfr", "When using the RFC2136 provider, specify the TSIG (base64) value to attached to DNS messages (required when --rfc2136-insecure=false)").BoolVar(&cfg.RFC2136TAXFR) app.Flag("rfc2136-min-ttl", "When using the RFC2136 provider, specify minimal TTL (in duration format) for records. This value will be used if the provided TTL for a service/ingress is lower than this").Default(defaultConfig.RFC2136MinTTL.String()).DurationVar(&cfg.RFC2136MinTTL) app.Flag("rfc2136-gss-tsig", "When using the RFC2136 provider, specify whether to use secure updates with GSS-TSIG using Kerberos (default: false, requires --rfc2136-kerberos-username and rfc2136-kerberos-password)").Default(strconv.FormatBool(defaultConfig.RFC2136GSSTSIG)).BoolVar(&cfg.RFC2136GSSTSIG) + app.Flag("rfc2136-kerberos-realm", "When using the RFC2136 provider with GSS-TSIG, specify the Kerberos realm used for authentication (default: the value of --rfc2316-zone converted to uppercase)").Default(defaultConfig.RFC2136KerberosRealm).StringVar(&cfg.RFC2136KerberosRealm) app.Flag("rfc2136-kerberos-username", "When using the RFC2136 provider with GSS-TSIG, specify the username of the user with permissions to update DNS records (required when --rfc2136-gss-tsig=true)").Default(defaultConfig.RFC2136KerberosUsername).StringVar(&cfg.RFC2136KerberosUsername) app.Flag("rfc2136-kerberos-password", "When using the RFC2136 provider with GSS-TSIG, specify the password of the user with permissions to update DNS records (required when --rfc2136-gss-tsig=true)").Default(defaultConfig.RFC2136KerberosPassword).StringVar(&cfg.RFC2136KerberosPassword) diff --git a/provider/rfc2136/rfc2136.go b/provider/rfc2136/rfc2136.go index 611261160..893c98be1 100644 --- a/provider/rfc2136/rfc2136.go +++ b/provider/rfc2136/rfc2136.go @@ -85,12 +85,16 @@ type rfc2136Actions interface { } // NewRfc2136Provider is a factory function for OpenStack rfc2136 providers -func NewRfc2136Provider(host string, port int, zoneName string, insecure bool, keyName string, secret string, secretAlg string, axfr bool, domainFilter endpoint.DomainFilter, dryRun bool, minTTL time.Duration, gssTsig bool, krb5Username string, krb5Password string, actions rfc2136Actions) (provider.Provider, error) { +func NewRfc2136Provider(host string, port int, zoneName string, insecure bool, keyName string, secret string, secretAlg string, axfr bool, domainFilter endpoint.DomainFilter, dryRun bool, minTTL time.Duration, gssTsig bool, krb5Realm string, krb5Username string, krb5Password string, actions rfc2136Actions) (provider.Provider, error) { secretAlgChecked, ok := tsigAlgs[secretAlg] if !ok && !insecure && !gssTsig { return nil, errors.Errorf("%s is not supported TSIG algorithm", secretAlg) } + if krb5Realm == "" { + krb5Realm = strings.ToUpper(zoneName) + } + r := &rfc2136Provider{ nameserver: net.JoinHostPort(host, strconv.Itoa(port)), zoneName: dns.Fqdn(zoneName), @@ -98,7 +102,7 @@ func NewRfc2136Provider(host string, port int, zoneName string, insecure bool, k gssTsig: gssTsig, krb5Username: krb5Username, krb5Password: krb5Password, - krb5Realm: strings.ToUpper(zoneName), + krb5Realm: krb5Realm, domainFilter: domainFilter, dryRun: dryRun, axfr: axfr, diff --git a/provider/rfc2136/rfc2136_test.go b/provider/rfc2136/rfc2136_test.go index 1df3ea1c6..59d3ea818 100644 --- a/provider/rfc2136/rfc2136_test.go +++ b/provider/rfc2136/rfc2136_test.go @@ -95,7 +95,7 @@ func (r *rfc2136Stub) IncomeTransfer(m *dns.Msg, a string) (env chan *dns.Envelo } func createRfc2136StubProvider(stub *rfc2136Stub) (provider.Provider, error) { - return NewRfc2136Provider("", 0, "", false, "key", "secret", "hmac-sha512", true, endpoint.DomainFilter{}, false, 300*time.Second, false, "", "", stub) + return NewRfc2136Provider("", 0, "", false, "key", "secret", "hmac-sha512", true, endpoint.DomainFilter{}, false, 300*time.Second, false, "", "", "", stub) } func extractAuthoritySectionFromMessage(msg fmt.Stringer) []string { From 098cedb7f98094ac58fa20adb6c775a9a965e818 Mon Sep 17 00:00:00 2001 From: stovemeerkat Date: Thu, 18 Mar 2021 12:07:36 +0100 Subject: [PATCH 13/13] docs: Update and improve tutorial for the RFC2136 provider --- docs/tutorials/rfc2136.md | 29 ++++++++++++++++++++++------- 1 file changed, 22 insertions(+), 7 deletions(-) diff --git a/docs/tutorials/rfc2136.md b/docs/tutorials/rfc2136.md index 923477813..d352dee61 100644 --- a/docs/tutorials/rfc2136.md +++ b/docs/tutorials/rfc2136.md @@ -287,7 +287,7 @@ While `external-dns` was not developed or tested against Microsoft DNS, it can b 1. Create a DNS zone 2. Enable insecure dynamic updates for the zone -3. Enable Zone Transfers from all servers +3. Enable Zone Transfers to all servers #### `external-dns` configuration @@ -310,8 +310,10 @@ You'll want to configure `external-dns` similarly to the following: 1. Create a DNS zone 2. Enable secure dynamic updates for the zone -3. Enable Zone Transfers from all servers +3. Enable Zone Transfers to all servers +If you see any error messages which indicate that `external-dns` was somehow not able to fetch +existing DNS records from your DNS server, this could mean that you forgot about step 3. #### Kerberos Configuration @@ -339,18 +341,20 @@ data: pkinit_anchors = /etc/pki/tls/certs/ca-bundle.crt default_ccache_name = KEYRING:persistent:%{uid} - default_realm = YOURDOMAIN.COM + default_realm = YOUR-REALM.COM [realms] - YOURDOMAIN.COM = { + YOUR-REALM.COM = { kdc = dc1.yourdomain.com admin_server = dc1.yourdomain.com } [domain_realm] - yourdomain.com = YOURDOMAIN.COM - .yourdomain.com = YOURDOMAIN.COM + yourdomain.com = YOUR-REALM.COM + .yourdomain.com = YOUR-REALM.COM ``` +In most cases, the realm name will probably be the same as the domain name, so you can simply replace +`YOUR-REALM.COM` with something like `YOURDOMAIN.COM`. Once the ConfigMap is created, the container `external-dns` container needs to be told to mount that ConfigMap as a volume at the default Kerberos configuration location. The pod spec should include a similar configuration to the following: @@ -376,11 +380,22 @@ You'll want to configure `external-dns` similarly to the following: ```text ... - --provider=rfc2136 - - --rfc2136-host=123.123.123.123 + - --rfc2136-host=dns-host.yourdomain.com - --rfc2136-port=53 - --rfc2136-zone=your-domain.com + - --rfc2136-gss-tsig + - --rfc2136-kerberos-realm=YOUR-REALM.COM # optional; use if your realm's name differs from the DNS zone - --rfc2136-kerberos-username=your-domain-account - --rfc2136-kerberos-password=your-domain-password - --rfc2136-tsig-axfr # needed to enable zone transfers, which is required for deletion of records. ... ``` + +As noted above, the `--rfc2136-kerberos-realm` flag is completely optional and won't be necessary in many cases. +Most likely, you will only need it if you see errors similar to this: `KRB Error: (68) KDC_ERR_WRONG_REALM Reserved for future use`. + +The flag `--rfc2136-host` can be set to the host's domain name or IP address. +However, it also determines the name of the Kerberos principal which is used during authentication. +This means that Active Directory might only work if this is set to a specific domain name, possibly leading to errors like this: +`KDC_ERR_S_PRINCIPAL_UNKNOWN Server not found in Kerberos database`. +To fix this, try setting `--rfc2136-host` to the "actual" hostname of your DNS server.