From f685704fcc26e23c2c1f7cb71b6bd7504865773f Mon Sep 17 00:00:00 2001 From: Jason-ZW Date: Fri, 12 Jul 2019 17:23:27 +0800 Subject: [PATCH] Add rancher dns(RDNS) provider --- README.md | 2 + docs/tutorials/rdns.md | 174 +++++++++ main.go | 7 + pkg/apis/externaldns/types.go | 2 +- pkg/apis/externaldns/types_test.go | 1 + provider/rdns.go | 549 +++++++++++++++++++++++++++++ provider/rdns_test.go | 356 +++++++++++++++++++ 7 files changed, 1090 insertions(+), 1 deletion(-) create mode 100644 docs/tutorials/rdns.md create mode 100644 provider/rdns.go create mode 100644 provider/rdns_test.go diff --git a/README.md b/README.md index 61548cbec..2f34c9a90 100644 --- a/README.md +++ b/README.md @@ -91,6 +91,7 @@ The following table clarifies the current status of the providers according to t | NS1 | Alpha | | TransIP | Alpha | | VinylDNS | Alpha | +| RancherDNS | Alpha | ## Running ExternalDNS: @@ -122,6 +123,7 @@ The following tutorials are provided: * [RFC2136](docs/tutorials/rfc2136.md) * [NS1](docs/tutorials/ns1.md) * [TransIP](docs/tutorials/transip.md) +* [RancherDNS](docs/tutorials/rdns.md) ### Running Locally diff --git a/docs/tutorials/rdns.md b/docs/tutorials/rdns.md new file mode 100644 index 000000000..8738d023d --- /dev/null +++ b/docs/tutorials/rdns.md @@ -0,0 +1,174 @@ +# Setting up ExternalDNS for RancherDNS(RDNS) with kubernetes +This tutorial describes how to setup ExternalDNS for usage within a kubernetes cluster that makes use of [RDNS](https://github.com/rancher/rdns) and [nginx ingress controller](https://github.com/kubernetes/ingress-nginx). +You need to: +* install RDNS with [etcd](https://github.com/etcd-io/etcd) enabled +* install external-dns with rdns as a provider + +## Installing RDNS with etcdv3 backend + +### Clone RDNS +``` +git clone https://github.com/rancher/rdns-server.git +``` + +### Installing ETCD +``` +cd rdns-server +docker-compose -f deploy/etcdv3/etcd-compose.yaml up -d +``` + +> ETCD was successfully deployed on `http://172.31.35.77:2379` + +### Installing RDNS +``` +export ETCD_ENDPOINTS="http://172.31.35.77:2379" +export DOMAIN="lb.rancher.cloud" +./scripts/start etcdv3 +``` + +> RDNS was successfully deployed on `172.31.35.77` + +## Installing ExternalDNS +### Install external ExternalDNS +ETCD_URLS is configured to etcd client service address. +RDNS_ROOT_DOMAIN is configured to the same with RDNS DOMAIN environment. e.g. lb.rancher.cloud. + +#### Manifest (for clusters without RBAC enabled) +```yaml +apiVersion: apps/v1 +kind: Deployment +metadata: + name: external-dns + namespace: kube-system +spec: + strategy: + type: Recreate + selector: + matchLabels: + app: external-dns + template: + metadata: + labels: + app: external-dns + spec: + serviceAccountName: external-dns + containers: + - name: external-dns + image: registry.opensource.zalan.do/teapot/external-dns:latest + args: + - --source=ingress + - --provider=rdns + - --log-level=debug # debug only + env: + - name: ETCD_URLS + value: http://172.31.35.77:2379 + - name: RDNS_ROOT_DOMAIN + value: lb.rancher.cloud +``` + +#### Manifest (for clusters with RBAC enabled) +```yaml + +--- +apiVersion: rbac.authorization.k8s.io/v1beta1 +kind: ClusterRole +metadata: + name: external-dns +rules: +- apiGroups: [""] + resources: ["services"] + verbs: ["get","watch","list"] +- apiGroups: [""] + resources: ["pods"] + verbs: ["get","watch","list"] +- apiGroups: ["extensions"] + resources: ["ingresses"] + verbs: ["get","watch","list"] +- apiGroups: [""] + resources: ["nodes"] + verbs: ["list"] +--- +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: kube-system +--- +apiVersion: v1 +kind: ServiceAccount +metadata: + name: external-dns + namespace: kube-system +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + name: external-dns + namespace: kube-system +spec: + strategy: + type: Recreate + selector: + matchLabels: + app: external-dns + template: + metadata: + labels: + app: external-dns + spec: + serviceAccountName: external-dns + containers: + - name: external-dns + image: registry.opensource.zalan.do/teapot/external-dns:latest + args: + - --source=ingress + - --provider=rdns + - --log-level=debug # debug only + env: + - name: ETCD_URLS + value: http://172.31.35.77:2379 + - name: RDNS_ROOT_DOMAIN + value: lb.rancher.cloud +``` + +## Testing ingress example +``` +$ cat ingress.yaml +apiVersion: extensions/v1beta1 +kind: Ingress +metadata: + name: nginx + annotations: + kubernetes.io/ingress.class: "nginx" +spec: + rules: + - host: nginx.lb.rancher.cloud + http: + paths: + - backend: + serviceName: nginx + servicePort: 80 + +$ kubectl apply -f ingress.yaml +ingress.extensions "nginx" created +``` + +Wait a moment until DNS has the ingress IP. The RDNS IP in this example is "172.31.35.77". +``` +$ kubectl get ingress +NAME HOSTS ADDRESS PORTS AGE +nginx nginx.lb.rancher.cloud 172.31.42.211 80 2m + +$ kubectl run -it --rm --restart=Never --image=infoblox/dnstools:latest dnstools +If you don't see a command prompt, try pressing enter. +dnstools# dig @172.31.35.77 nginx.lb.rancher.cloud +short +172.31.42.211 +dnstools# +``` \ No newline at end of file diff --git a/main.go b/main.go index 5c6737f7e..825f6d4fb 100644 --- a/main.go +++ b/main.go @@ -177,6 +177,13 @@ func main() { ) case "coredns", "skydns": p, err = provider.NewCoreDNSProvider(domainFilter, cfg.DryRun) + case "rdns": + p, err = provider.NewRDNSProvider( + provider.RDNSConfig{ + DomainFilter: domainFilter, + DryRun: cfg.DryRun, + }, + ) case "exoscale": p, err = provider.NewExoscaleProvider(cfg.ExoscaleEndpoint, cfg.ExoscaleAPIKey, cfg.ExoscaleAPISecret, cfg.DryRun, provider.ExoscaleWithDomain(domainFilter), provider.ExoscaleWithLogging()), nil case "inmemory": diff --git a/pkg/apis/externaldns/types.go b/pkg/apis/externaldns/types.go index 2107d746c..998e60cd4 100644 --- a/pkg/apis/externaldns/types.go +++ b/pkg/apis/externaldns/types.go @@ -276,7 +276,7 @@ func (cfg *Config) ParseFlags(args []string) error { app.Flag("service-type-filter", "The service types to take care about (default: all, expected: ClusterIP, NodePort, LoadBalancer or ExternalName)").StringsVar(&cfg.ServiceTypeFilter) // Flags related to providers - app.Flag("provider", "The DNS provider where the DNS records will be created (required, options: aws, aws-sd, google, azure, cloudflare, rcodezero, digitalocean, dnsimple, infoblox, dyn, designate, coredns, skydns, inmemory, pdns, oci, exoscale, linode, rfc2136, ns1, transip, vinyldns)").Required().PlaceHolder("provider").EnumVar(&cfg.Provider, "aws", "aws-sd", "google", "azure", "alibabacloud", "cloudflare", "rcodezero", "digitalocean", "dnsimple", "infoblox", "dyn", "designate", "coredns", "skydns", "inmemory", "pdns", "oci", "exoscale", "linode", "rfc2136", "ns1", "transip", "vinyldns") + app.Flag("provider", "The DNS provider where the DNS records will be created (required, options: aws, aws-sd, google, azure, cloudflare, rcodezero, digitalocean, dnsimple, infoblox, dyn, designate, coredns, skydns, inmemory, pdns, oci, exoscale, linode, rfc2136, ns1, transip, vinyldns, rdns)").Required().PlaceHolder("provider").EnumVar(&cfg.Provider, "aws", "aws-sd", "google", "azure", "alibabacloud", "cloudflare", "rcodezero", "digitalocean", "dnsimple", "infoblox", "dyn", "designate", "coredns", "skydns", "inmemory", "pdns", "oci", "exoscale", "linode", "rfc2136", "ns1", "transip", "vinyldns", "rdns") 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-id-filter", "Filter target zones by hosted zone id; specify multiple times for multiple zones (optional)").Default("").StringsVar(&cfg.ZoneIDFilter) diff --git a/pkg/apis/externaldns/types_test.go b/pkg/apis/externaldns/types_test.go index dc580a036..78c50d97b 100644 --- a/pkg/apis/externaldns/types_test.go +++ b/pkg/apis/externaldns/types_test.go @@ -340,6 +340,7 @@ func TestParseFlags(t *testing.T) { "EXTERNAL_DNS_PDNS_SERVER": "http://ns.example.com:8081", "EXTERNAL_DNS_PDNS_API_KEY": "some-secret-key", "EXTERNAL_DNS_PDNS_TLS_ENABLED": "1", + "EXTERNAL_DNS_RDNS_ROOT_DOMAIN": "lb.rancher.cloud", "EXTERNAL_DNS_TLS_CA": "/path/to/ca.crt", "EXTERNAL_DNS_TLS_CLIENT_CERT": "/path/to/cert.pem", "EXTERNAL_DNS_TLS_CLIENT_CERT_KEY": "/path/to/key.pem", diff --git a/provider/rdns.go b/provider/rdns.go new file mode 100644 index 000000000..a56563fe5 --- /dev/null +++ b/provider/rdns.go @@ -0,0 +1,549 @@ +/* +Copyright 2019 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 provider + +import ( + "context" + "crypto/tls" + "crypto/x509" + "encoding/json" + "fmt" + "io/ioutil" + "math/rand" + "os" + "regexp" + "strings" + "time" + + "github.com/coreos/etcd/clientv3" + "github.com/pkg/errors" + "istio.io/istio/pkg/log" + + "github.com/kubernetes-incubator/external-dns/endpoint" + "github.com/kubernetes-incubator/external-dns/plan" +) + +const ( + rdnsMaxHosts = 10 + rdnsOriginalLabel = "originalText" + rdnsPrefix = "/rdnsv3" + rdnsTimeout = 5 * time.Second +) + +func init() { + rand.Seed(time.Now().UnixNano()) +} + +// RDNSClient is an interface to work with Rancher DNS(RDNS) records in etcdv3 backend. +type RDNSClient interface { + Get(key string) ([]RDNSRecord, error) + List(rootDomain string) ([]RDNSRecord, error) + Set(value RDNSRecord) error + Delete(key string) error +} + +// RDNSConfig contains configuration to create a new Rancher DNS(RDNS) provider. +type RDNSConfig struct { + DryRun bool + DomainFilter DomainFilter + RootDomain string +} + +// RDNSProvider is an implementation of Provider for Rancher DNS(RDNS). +type RDNSProvider struct { + client RDNSClient + dryRun bool + domainFilter DomainFilter + rootDomain string +} + +// RDNSRecord represents Rancher DNS(RDNS) etcdv3 record. +type RDNSRecord struct { + AggregationHosts []string `json:"aggregation_hosts,omitempty"` + Host string `json:"host,omitempty"` + Text string `json:"text,omitempty"` + TTL uint32 `json:"ttl,omitempty"` + Key string `json:"-"` +} + +// RDNSRecordType represents Rancher DNS(RDNS) etcdv3 record type. +type RDNSRecordType struct { + Type string `json:"type,omitempty"` + Domain string `json:"domain,omitempty"` +} + +type etcdv3Client struct { + client *clientv3.Client + ctx context.Context +} + +var _ RDNSClient = etcdv3Client{} + +// NewRDNSProvider initializes a new Rancher DNS(RDNS) based Provider. +func NewRDNSProvider(config RDNSConfig) (*RDNSProvider, error) { + client, err := newEtcdv3Client() + if err != nil { + return nil, err + } + domain := os.Getenv("RDNS_ROOT_DOMAIN") + if domain == "" { + return nil, errors.New("needed root domain environment") + } + return &RDNSProvider{ + client: client, + dryRun: config.DryRun, + domainFilter: config.DomainFilter, + rootDomain: domain, + }, nil +} + +// Records returns all DNS records found in Rancher DNS(RDNS) etcdv3 backend. Depending on the record fields +// it may be mapped to one or two records of type A, TXT, A+TXT. +func (p RDNSProvider) Records() ([]*endpoint.Endpoint, error) { + var result []*endpoint.Endpoint + + rs, err := p.client.List(p.rootDomain) + if err != nil { + return nil, err + } + + for _, r := range rs { + domains := strings.Split(strings.TrimPrefix(r.Key, rdnsPrefix+"/"), "/") + keyToDnsNameSplits(domains) + dnsName := strings.Join(domains, ".") + if !p.domainFilter.Match(dnsName) { + continue + } + + // only return rdnsMaxHosts at most + if len(r.AggregationHosts) > 0 { + if len(r.AggregationHosts) > rdnsMaxHosts { + r.AggregationHosts = r.AggregationHosts[:rdnsMaxHosts] + } + ep := endpoint.NewEndpointWithTTL( + dnsName, + endpoint.RecordTypeA, + endpoint.TTL(r.TTL), + r.AggregationHosts..., + ) + ep.Labels[rdnsOriginalLabel] = r.Text + result = append(result, ep) + } + if r.Text != "" { + ep := endpoint.NewEndpoint( + dnsName, + endpoint.RecordTypeTXT, + r.Text, + ) + result = append(result, ep) + } + } + + return result, nil +} + +// ApplyChanges stores changes back to etcdv3 converting them to Rancher DNS(RDNS) format and aggregating A and TXT records. +func (p RDNSProvider) ApplyChanges(ctx context.Context, changes *plan.Changes) error { + grouped := map[string][]*endpoint.Endpoint{} + + for _, ep := range changes.Create { + grouped[ep.DNSName] = append(grouped[ep.DNSName], ep) + } + + for _, ep := range changes.UpdateNew { + if ep.RecordType == endpoint.RecordTypeA { + // append useless domain records to the changes.Delete + if err := p.filterAndRemoveUseless(ep, changes); err != nil { + return err + } + } + grouped[ep.DNSName] = append(grouped[ep.DNSName], ep) + } + + for dnsName, group := range grouped { + if !p.domainFilter.Match(dnsName) { + log.Debugf("Skipping record %s because it was filtered out by the specified --domain-filter", dnsName) + continue + } + + var rs []RDNSRecord + + for _, ep := range group { + if ep.RecordType == endpoint.RecordTypeTXT { + continue + } + for _, target := range ep.Targets { + rs = append(rs, RDNSRecord{ + Host: target, + Text: ep.Labels[rdnsOriginalLabel], + Key: keyFor(ep.DNSName) + "/" + formatKey(target), + TTL: uint32(ep.RecordTTL), + }) + } + } + + // Add the TXT attribute to the existing A record + for _, ep := range group { + if ep.RecordType != endpoint.RecordTypeTXT { + continue + } + for i, r := range rs { + if strings.Contains(r.Key, keyFor(ep.DNSName)) { + r.Text = ep.Targets[0] + rs[i] = r + } + } + } + + for _, r := range rs { + log.Infof("Add/set key %s to Host=%s, Text=%s, TTL=%d", r.Key, r.Host, r.Text, r.TTL) + if !p.dryRun { + err := p.client.Set(r) + if err != nil { + return err + } + } + } + } + + for _, ep := range changes.Delete { + key := keyFor(ep.DNSName) + log.Infof("Delete key %s", key) + if !p.dryRun { + err := p.client.Delete(key) + if err != nil { + return err + } + } + } + + return nil +} + +// filterAndRemoveUseless filter and remove useless records. +func (p *RDNSProvider) filterAndRemoveUseless(ep *endpoint.Endpoint, changes *plan.Changes) error { + rs, err := p.client.Get(keyFor(ep.DNSName)) + if err != nil { + return err + } + for _, r := range rs { + exist := false + for _, target := range ep.Targets { + if strings.Contains(r.Key, formatKey(target)) { + exist = true + continue + } + } + if !exist { + ds := strings.Split(strings.TrimPrefix(r.Key, rdnsPrefix+"/"), "/") + keyToDnsNameSplits(ds) + changes.Delete = append(changes.Delete, &endpoint.Endpoint{ + DNSName: strings.Join(ds, "."), + }) + } + } + return nil +} + +// newEtcdv3Client is an etcdv3 client constructor. +func newEtcdv3Client() (RDNSClient, error) { + cfg := &clientv3.Config{} + + endpoints := os.Getenv("ETCD_URLS") + ca := os.Getenv("ETCD_CA_FILE") + cert := os.Getenv("ETCD_CERT_FILE") + key := os.Getenv("ETCD_KEY_FILE") + name := os.Getenv("ETCD_TLS_SERVER_NAME") + insecure := os.Getenv("ETCD_TLS_INSECURE") + + if endpoints == "" { + endpoints = "http://localhost:2379" + } + + urls := strings.Split(endpoints, ",") + scheme := strings.ToLower(urls[0])[0:strings.Index(strings.ToLower(urls[0]), "://")] + + switch scheme { + case "http": + cfg.Endpoints = urls + case "https": + var certificates []tls.Certificate + + insecure = strings.ToLower(insecure) + isInsecure := insecure == "true" || insecure == "yes" || insecure == "1" + + if ca != "" && key == "" || cert == "" && key != "" { + return nil, errors.New("either both cert and key or none must be provided") + } + + if cert != "" { + cert, err := tls.LoadX509KeyPair(cert, key) + if err != nil { + return nil, fmt.Errorf("could not load TLS cert: %s", err) + } + certificates = append(certificates, cert) + } + + config := &tls.Config{ + Certificates: certificates, + InsecureSkipVerify: isInsecure, + ServerName: name, + } + + if ca != "" { + roots := x509.NewCertPool() + pem, err := ioutil.ReadFile(ca) + if err != nil { + return nil, fmt.Errorf("error reading %s: %s", ca, err) + } + ok := roots.AppendCertsFromPEM(pem) + if !ok { + return nil, fmt.Errorf("could not read root certs: %s", err) + } + config.RootCAs = roots + } + + cfg.Endpoints = urls + cfg.TLS = config + default: + return nil, errors.New("etcdv3 URLs must start with either http:// or https://") + } + + c, err := clientv3.New(*cfg) + if err != nil { + return nil, err + } + + return etcdv3Client{c, context.Background()}, nil +} + +// Get return A records stored in etcdv3 stored anywhere under the given key (recursively). +func (c etcdv3Client) Get(key string) ([]RDNSRecord, error) { + ctx, cancel := context.WithTimeout(c.ctx, rdnsTimeout) + defer cancel() + + result, err := c.client.Get(ctx, key, clientv3.WithPrefix()) + if err != nil { + return nil, err + } + + rs := make([]RDNSRecord, 0) + for _, v := range result.Kvs { + r := new(RDNSRecord) + if err := json.Unmarshal(v.Value, r); err != nil { + return nil, fmt.Errorf("%s: %s", v.Key, err.Error()) + } + r.Key = string(v.Key) + rs = append(rs, *r) + } + + return rs, nil +} + +// List return all records stored in etcdv3 stored anywhere under the given rootDomain (recursively). +func (c etcdv3Client) List(rootDomain string) ([]RDNSRecord, error) { + ctx, cancel := context.WithTimeout(c.ctx, rdnsTimeout) + defer cancel() + + path := keyFor(rootDomain) + + result, err := c.client.Get(ctx, path, clientv3.WithPrefix()) + if err != nil { + return nil, err + } + + return c.aggregationRecords(result) +} + +// Set persists records data into etcdv3. +func (c etcdv3Client) Set(r RDNSRecord) error { + ctx, cancel := context.WithTimeout(c.ctx, etcdTimeout) + defer cancel() + + v, err := json.Marshal(&r) + if err != nil { + return err + } + + if r.Text == "" && r.Host == "" { + return nil + } + + _, err = c.client.Put(ctx, r.Key, string(v)) + if err != nil { + return err + } + + return nil +} + +// Delete deletes record from etcdv3. +func (c etcdv3Client) Delete(key string) error { + ctx, cancel := context.WithTimeout(c.ctx, etcdTimeout) + defer cancel() + + _, err := c.client.Delete(ctx, key, clientv3.WithPrefix()) + return err +} + +// aggregationRecords will aggregation multi A records under the given path. +// e.g. A: 1_1_1_1.xxx.lb.rancher.cloud & 2_2_2_2.sample.lb.rancher.cloud => sample.lb.rancher.cloud {"aggregation_hosts": ["1.1.1.1", "2.2.2.2"]} +// e.g. TXT: sample.lb.rancher.cloud => sample.lb.rancher.cloud => {"text": "xxx"} +func (c etcdv3Client) aggregationRecords(result *clientv3.GetResponse) ([]RDNSRecord, error) { + var rs []RDNSRecord + bx := make(map[RDNSRecordType]RDNSRecord) + + for _, n := range result.Kvs { + r := new(RDNSRecord) + if err := json.Unmarshal(n.Value, r); err != nil { + return nil, fmt.Errorf("%s: %s", n.Key, err.Error()) + } + + r.Key = string(n.Key) + + if r.Host == "" && r.Text == "" { + continue + } + + if r.Host != "" { + c := RDNSRecord{ + AggregationHosts: r.AggregationHosts, + Host: r.Host, + Text: r.Text, + TTL: r.TTL, + Key: r.Key, + } + n, isContinue := appendRecords(c, endpoint.RecordTypeA, bx, rs) + if isContinue { + continue + } + rs = n + } + + if r.Text != "" && r.Host == "" { + c := RDNSRecord{ + AggregationHosts: []string{}, + Host: r.Host, + Text: r.Text, + TTL: r.TTL, + Key: r.Key, + } + n, isContinue := appendRecords(c, endpoint.RecordTypeTXT, bx, rs) + if isContinue { + continue + } + rs = n + } + } + + return rs, nil +} + +// appendRecords append record to an array +func appendRecords(r RDNSRecord, dnsType string, bx map[RDNSRecordType]RDNSRecord, rs []RDNSRecord) ([]RDNSRecord, bool) { + dnsName := keyToParentDnsName(r.Key) + bt := RDNSRecordType{Domain: dnsName, Type: dnsType} + if v, ok := bx[bt]; ok { + // skip the TXT records if already added to record list. + // append A record if dnsName already added to record list but not found the value. + // the same record might be found in multiple etcdv3 nodes. + if bt.Type == endpoint.RecordTypeA { + exist := false + for _, h := range v.AggregationHosts { + if h == r.Host { + exist = true + break + } + } + if !exist { + for i, t := range rs { + if !strings.HasPrefix(r.Key, t.Key) { + continue + } + t.Host = "" + t.AggregationHosts = append(t.AggregationHosts, r.Host) + bx[bt] = t + rs[i] = t + } + } + } + return rs, true + } + + if bt.Type == endpoint.RecordTypeA { + r.AggregationHosts = append(r.AggregationHosts, r.Host) + } + + r.Key = rdnsPrefix + dnsNameToKey(dnsName) + r.Host = "" + bx[bt] = r + rs = append(rs, r) + return rs, false +} + +// keyFor used to get a path as etcdv3 preferred. +// e.g. sample.lb.rancher.cloud => /rdnsv3/cloud/rancher/lb/sample +func keyFor(fqdn string) string { + return rdnsPrefix + dnsNameToKey(fqdn) +} + +// keyToParentDnsName used to get dnsName. +// e.g. /rdnsv3/cloud/rancher/lb/sample/xxx => xxx.sample.lb.rancher.cloud +// e.g. /rdnsv3/cloud/rancher/lb/sample/xxx/1_1_1_1 => xxx.sample.lb.rancher.cloud +func keyToParentDnsName(key string) string { + ds := strings.Split(strings.TrimPrefix(key, rdnsPrefix+"/"), "/") + keyToDnsNameSplits(ds) + + dns := strings.Join(ds, ".") + prefix := strings.Split(dns, ".")[0] + + p := `^\d{1,3}_\d{1,3}_\d{1,3}_\d{1,3}$` + m, _ := regexp.MatchString(p, prefix) + if prefix != "" && strings.Contains(prefix, "_") && m { + // 1_1_1_1.xxx.sample.lb.rancher.cloud => xxx.sample.lb.rancher.cloud + return strings.Join(strings.Split(dns, ".")[1:], ".") + } + + return dns +} + +// dnsNameToKey used to convert domain to a path as etcdv3 preferred. +// e.g. sample.lb.rancher.cloud => /cloud/rancher/lb/sample +func dnsNameToKey(domain string) string { + ss := strings.Split(domain, ".") + last := len(ss) - 1 + for i := 0; i < len(ss)/2; i++ { + ss[i], ss[last-i] = ss[last-i], ss[i] + } + return "/" + strings.Join(ss, "/") +} + +// keyToDnsNameSplits used to reverse etcdv3 path to domain splits. +// e.g. /cloud/rancher/lb/sample => [sample lb rancher cloud] +func keyToDnsNameSplits(ss []string) { + for i := 0; i < len(ss)/2; i++ { + j := len(ss) - i - 1 + ss[i], ss[j] = ss[j], ss[i] + } +} + +// formatKey used to format a key as etcdv3 preferred +// e.g. 1.1.1.1 => 1_1_1_1 +// e.g. sample.lb.rancher.cloud => sample_lb_rancher_cloud +func formatKey(key string) string { + return strings.Replace(key, ".", "_", -1) +} diff --git a/provider/rdns_test.go b/provider/rdns_test.go new file mode 100644 index 000000000..766393a2e --- /dev/null +++ b/provider/rdns_test.go @@ -0,0 +1,356 @@ +/* +Copyright 2019 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 provider + +import ( + "context" + "encoding/json" + "fmt" + "strings" + "testing" + + "github.com/coreos/etcd/clientv3" + "github.com/coreos/etcd/mvcc/mvccpb" + + "github.com/kubernetes-incubator/external-dns/endpoint" + "github.com/kubernetes-incubator/external-dns/plan" +) + +type fakeEtcdv3Client struct { + rs map[string]RDNSRecord +} + +func (c fakeEtcdv3Client) Get(key string) ([]RDNSRecord, error) { + rs := make([]RDNSRecord, 0) + for k, v := range c.rs { + if strings.Contains(k, key) { + rs = append(rs, v) + } + } + return rs, nil +} + +func (c fakeEtcdv3Client) List(rootDomain string) ([]RDNSRecord, error) { + var result []RDNSRecord + for key, value := range c.rs { + rootPath := rdnsPrefix + dnsNameToKey(rootDomain) + if strings.HasPrefix(key, rootPath) { + value.Key = key + result = append(result, value) + } + } + + r := &clientv3.GetResponse{} + + for _, v := range result { + b, err := json.Marshal(v) + if err != nil { + return nil, err + } + + k := &mvccpb.KeyValue{ + Key: []byte(v.Key), + Value: []byte(b), + } + + r.Kvs = append(r.Kvs, k) + } + + return c.aggregationRecords(r) +} + +func (c fakeEtcdv3Client) Set(r RDNSRecord) error { + c.rs[r.Key] = r + return nil +} + +func (c fakeEtcdv3Client) Delete(key string) error { + ks := make([]string, 0) + for k := range c.rs { + if strings.Contains(k, key) { + ks = append(ks, k) + } + } + + for _, v := range ks { + delete(c.rs, v) + } + + return nil +} + +func TestARecordTranslation(t *testing.T) { + expectedTarget1 := "1.2.3.4" + expectedTarget2 := "2.3.4.5" + expectedDNSName := "p1xaf1.lb.rancher.cloud" + expectedRecordType := endpoint.RecordTypeA + + client := fakeEtcdv3Client{ + map[string]RDNSRecord{ + "/rdnsv3/cloud/rancher/lb/p1xaf1/1_2_3_4": {Host: expectedTarget1}, + "/rdnsv3/cloud/rancher/lb/p1xaf1/2_3_4_5": {Host: expectedTarget2}, + }, + } + + provider := RDNSProvider{ + client: client, + rootDomain: "lb.rancher.cloud", + } + + endpoints, err := provider.Records() + if err != nil { + t.Fatal(err) + } + if len(endpoints) != 1 { + t.Fatalf("got unexpected number of endpoints: %d", len(endpoints)) + } + if endpoints[0].DNSName != expectedDNSName { + t.Errorf("got unexpected DNS name: %s != %s", endpoints[0].DNSName, expectedDNSName) + } + if endpoints[0].Targets[0] != expectedTarget1 { + t.Errorf("got unexpected DNS target: %s != %s", endpoints[0].Targets[0], expectedTarget1) + } + if endpoints[0].Targets[1] != expectedTarget2 { + t.Errorf("got unexpected DNS target: %s != %s", endpoints[0].Targets[1], expectedTarget2) + } + if endpoints[0].RecordType != expectedRecordType { + t.Errorf("got unexpected DNS record type: %s != %s", endpoints[0].RecordType, expectedRecordType) + } +} + +func TestTXTRecordTranslation(t *testing.T) { + expectedTarget := "string" + expectedDNSName := "p1xaf1.lb.rancher.cloud" + expectedRecordType := endpoint.RecordTypeTXT + + client := fakeEtcdv3Client{ + map[string]RDNSRecord{ + "/rdnsv3/cloud/rancher/lb/p1xaf1": {Text: expectedTarget}, + }, + } + + provider := RDNSProvider{ + client: client, + rootDomain: "lb.rancher.cloud", + } + + endpoints, err := provider.Records() + if err != nil { + t.Fatal(err) + } + if len(endpoints) != 1 { + t.Fatalf("got unexpected number of endpoints: %d", len(endpoints)) + } + if endpoints[0].DNSName != expectedDNSName { + t.Errorf("got unexpected DNS name: %s != %s", endpoints[0].DNSName, expectedDNSName) + } + if endpoints[0].Targets[0] != expectedTarget { + t.Errorf("got unexpected DNS target: %s != %s", endpoints[0].Targets[0], expectedTarget) + } + if endpoints[0].RecordType != expectedRecordType { + t.Errorf("got unexpected DNS record type: %s != %s", endpoints[0].RecordType, expectedRecordType) + } +} + +func TestAWithTXTRecordTranslation(t *testing.T) { + expectedTargets := map[string]string{ + endpoint.RecordTypeA: "1.2.3.4", + endpoint.RecordTypeTXT: "string", + } + expectedDNSName := "p1xaf1.lb.rancher.cloud" + + client := fakeEtcdv3Client{ + map[string]RDNSRecord{ + "/rdnsv3/cloud/rancher/lb/p1xaf1": {Host: "1.2.3.4", Text: "string"}, + }, + } + + provider := RDNSProvider{ + client: client, + rootDomain: "lb.rancher.cloud", + } + + endpoints, err := provider.Records() + if err != nil { + t.Fatal(err) + } + + if len(endpoints) != len(expectedTargets) { + t.Fatalf("got unexpected number of endpoints: %d", len(endpoints)) + } + + for _, ep := range endpoints { + expectedTarget := expectedTargets[ep.RecordType] + if expectedTarget == "" { + t.Errorf("got unexpected DNS record type: %s", ep.RecordType) + continue + } + delete(expectedTargets, ep.RecordType) + + if ep.DNSName != expectedDNSName { + t.Errorf("got unexpected DNS name: %s != %s", ep.DNSName, expectedDNSName) + } + + if ep.Targets[0] != expectedTarget { + t.Errorf("got unexpected DNS target: %s != %s", ep.Targets[0], expectedTarget) + } + } +} + +func TestRDNSApplyChanges(t *testing.T) { + client := fakeEtcdv3Client{ + map[string]RDNSRecord{}, + } + + provider := RDNSProvider{ + client: client, + rootDomain: "lb.rancher.cloud", + } + + changes1 := &plan.Changes{ + Create: []*endpoint.Endpoint{ + endpoint.NewEndpoint("p1xaf1.lb.rancher.cloud", endpoint.RecordTypeA, "5.5.5.5", "6.6.6.6"), + endpoint.NewEndpoint("p1xaf1.lb.rancher.cloud", endpoint.RecordTypeTXT, "string1"), + }, + } + + if err := provider.ApplyChanges(context.Background(), changes1); err != nil { + t.Error(err) + } + + expectedRecords1 := map[string]RDNSRecord{ + "/rdnsv3/cloud/rancher/lb/p1xaf1/5_5_5_5": {Host: "5.5.5.5", Text: "string1"}, + "/rdnsv3/cloud/rancher/lb/p1xaf1/6_6_6_6": {Host: "6.6.6.6", Text: "string1"}, + } + + client.validateRecords(client.rs, expectedRecords1, t) + + changes2 := &plan.Changes{ + Create: []*endpoint.Endpoint{ + endpoint.NewEndpoint("abx1v1.lb.rancher.cloud", endpoint.RecordTypeA, "7.7.7.7"), + }, + UpdateNew: []*endpoint.Endpoint{ + endpoint.NewEndpoint("p1xaf1.lb.rancher.cloud", endpoint.RecordTypeA, "8.8.8.8", "9.9.9.9"), + }, + } + + records, _ := provider.Records() + for _, ep := range records { + if ep.DNSName == "p1xaf1.lb.rancher.cloud" { + changes2.UpdateOld = append(changes2.UpdateOld, ep) + } + } + + if err := provider.ApplyChanges(context.Background(), changes2); err != nil { + t.Error(err) + } + + expectedRecords2 := map[string]RDNSRecord{ + "/rdnsv3/cloud/rancher/lb/p1xaf1/8_8_8_8": {Host: "8.8.8.8"}, + "/rdnsv3/cloud/rancher/lb/p1xaf1/9_9_9_9": {Host: "9.9.9.9"}, + "/rdnsv3/cloud/rancher/lb/abx1v1/7_7_7_7": {Host: "7.7.7.7"}, + } + + client.validateRecords(client.rs, expectedRecords2, t) + + changes3 := &plan.Changes{ + Delete: []*endpoint.Endpoint{ + endpoint.NewEndpoint("p1xaf1.lb.rancher.cloud", endpoint.RecordTypeA, "8.8.8.8", "9.9.9.9"), + }, + } + + if err := provider.ApplyChanges(context.Background(), changes3); err != nil { + t.Error(err) + } + + expectedRecords3 := map[string]RDNSRecord{ + "/rdnsv3/cloud/rancher/lb/abx1v1/7_7_7_7": {Host: "7.7.7.7"}, + } + + client.validateRecords(client.rs, expectedRecords3, t) + +} + +func (c fakeEtcdv3Client) aggregationRecords(result *clientv3.GetResponse) ([]RDNSRecord, error) { + var rs []RDNSRecord + bx := make(map[RDNSRecordType]RDNSRecord) + + for _, n := range result.Kvs { + r := new(RDNSRecord) + if err := json.Unmarshal(n.Value, r); err != nil { + return nil, fmt.Errorf("%s: %s", n.Key, err.Error()) + } + + r.Key = string(n.Key) + + if r.Host == "" && r.Text == "" { + continue + } + + if r.Host != "" { + c := RDNSRecord{ + AggregationHosts: r.AggregationHosts, + Host: r.Host, + Text: r.Text, + TTL: r.TTL, + Key: r.Key, + } + n, isContinue := appendRecords(c, endpoint.RecordTypeA, bx, rs) + if isContinue { + continue + } + rs = n + } + + if r.Text != "" && r.Host == "" { + c := RDNSRecord{ + AggregationHosts: []string{}, + Host: r.Host, + Text: r.Text, + TTL: r.TTL, + Key: r.Key, + } + n, isContinue := appendRecords(c, endpoint.RecordTypeTXT, bx, rs) + if isContinue { + continue + } + rs = n + } + } + + return rs, nil +} + +func (c fakeEtcdv3Client) validateRecords(rs, expectedRs map[string]RDNSRecord, t *testing.T) { + if len(rs) != len(expectedRs) { + t.Errorf("wrong number of records: %d != %d", len(rs), len(expectedRs)) + } + for key, value := range rs { + if _, ok := expectedRs[key]; !ok { + t.Errorf("unexpected record %s", key) + continue + } + expected := expectedRs[key] + delete(expectedRs, key) + if value.Host != expected.Host { + t.Errorf("wrong host for record %s: %s != %s", key, value.Host, expected.Host) + } + if value.Text != expected.Text { + t.Errorf("wrong text for record %s: %s != %s", key, value.Text, expected.Text) + } + } +}