From 7f81ab4202a5a412d48a392aea5051f36247091a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Manuel=20R=C3=BCger?= Date: Sun, 17 Jul 2022 14:25:58 +0200 Subject: [PATCH] Support Netcup as DNS provider Add support for https://www.netcup-wiki.de/wiki/CCP_API via netcup-dns-api library. See also: https://github.com/aellwein/netcup-dns-api/issues/1 Co-Authored-By: Alex Ellwein --- README.md | 3 + docs/tutorials/netcup.md | 208 +++++++++++++++++++++++ go.mod | 1 + go.sum | 2 + main.go | 3 + pkg/apis/externaldns/types.go | 13 +- provider/netcup/netcup.go | 291 +++++++++++++++++++++++++++++++++ provider/netcup/netcup_test.go | 261 +++++++++++++++++++++++++++++ 8 files changed, 781 insertions(+), 1 deletion(-) create mode 100644 docs/tutorials/netcup.md create mode 100644 provider/netcup/netcup.go create mode 100644 provider/netcup/netcup_test.go diff --git a/README.md b/README.md index a5041afbed..217f49972d 100644 --- a/README.md +++ b/README.md @@ -62,6 +62,7 @@ ExternalDNS allows you to keep selected zones (via `--domain-filter`) synchroniz * [TencentCloud DNSPod](https://cloud.tencent.com/product/cns) * [Plural](https://www.plural.sh/) * [Pi-hole](https://pi-hole.net/) +* [Netcup](https://netcup.de) From this release, ExternalDNS can become aware of the records it is managing (enabled via `--registry=txt`), therefore ExternalDNS can safely manage non-empty hosted zones. We strongly encourage you to use `v0.5` (or greater) with `--registry=txt` enabled and `--txt-owner-id` set to a unique value that doesn't change for the lifetime of your cluster. You might also want to run ExternalDNS in a dry run mode (`--dry-run` flag) to see the changes to be submitted to your DNS Provider API. @@ -124,6 +125,7 @@ The following table clarifies the current status of the providers according to t | TencentCloud | Alpha | @Hyzhou | | Plural | Alpha | @michaeljguarino | | Pi-hole | Alpha | @tinyzimmer | +| Netcup | Alpha | @aellwein @mrueg | ## Kubernetes version compatibility @@ -196,6 +198,7 @@ The following tutorials are provided: * [TencentCloud](docs/tutorials/tencentcloud.md) * [Plural](docs/tutorials/plural.md) * [Pi-hole](docs/tutorials/pihole.md) +* [Netcup](docs/tutorials/netcup.md) ### Running Locally diff --git a/docs/tutorials/netcup.md b/docs/tutorials/netcup.md new file mode 100644 index 0000000000..e68197c06b --- /dev/null +++ b/docs/tutorials/netcup.md @@ -0,0 +1,208 @@ +# Setting up ExternalDNS for Netcup + +This tutorial describes how to setup ExternalDNS for usage within a Kubernetes cluster using Netcup. + +Make sure to use **master** version of ExternalDNS for this tutorial. + +## Creating Netcup Credentials + +A secret containing the a Netcup API token and an API Password is needed for this provider. You can get a token for your user [here](https://www.customercontrolpanel.de/daten_aendern.php?sprung=api). + +To create the API token secret you can run `kubectl create secret generic netcup-api-key --from-literal=EXTERNAL_DNS_NETCUP_API_KEY=`. + +To create the API password secret you can run `kubectl create secret generic netcup-api-password --from-literal=EXTERNAL_DNS_NETCUP_API_PASSWORD=`. + +## Deploy ExternalDNS + +Connect your `kubectl` client to the cluster you want to test ExternalDNS with. + +Besides the API key and password, it is mandatory to provide a customer id as well as a list of DNS zones you want ExternalDNS to manage. The hosted DNS zones will be provides via the `--domain-filter`. + +Then apply one of the following manifests file to deploy ExternalDNS. + +### 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 + image: registry.k8s.io/external-dns/external-dns:latest + args: + - --source=service # ingress is also possible + - --provider=netcup + - --domain-filter="example.com" + - --netcup-customer-id="123456" + env: + - name: EXTERNAL_DNS_NETCUP_API_KEY + valueFrom: + secretKeyRef: + key: EXTERNAL_DNS_NETCUP_API_KEY + name: netcup-api-key + - name: EXTERNAL_DNS_NETCUP_API_PASSWORD + valueFrom: + secretKeyRef: + key: EXTERNAL_DNS_NETCUP_API_PASSWORD + name: netcup-api-password + +``` + +### Manifest (for clusters with RBAC enabled) + +```yaml +apiVersion: v1 +kind: ServiceAccount +metadata: + name: external-dns +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRole +metadata: + name: external-dns +rules: +- apiGroups: [""] + resources: ["services","endpoints","pods"] + verbs: ["get","watch","list"] +- apiGroups: ["extensions","networking.k8s.io"] + resources: ["ingresses"] + verbs: ["get","watch","list"] +- apiGroups: [""] + resources: ["nodes"] + verbs: ["list", "watch"] +--- +apiVersion: rbac.authorization.k8s.io/v1 +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 + image: registry.k8s.io/external-dns/external-dns:latest + args: + - --source=service # ingress is also possible + - --provider=netcup + - --domain-filter=example.com + - --netcup-customer-id=123456 + env: + - name: EXTERNAL_DNS_NETCUP_API_TOKEN + valueFrom: + secretKeyRef: + key: EXTERNAL_DNS_NETCUP_API_TOKEN + name: netcup-api-token + - name: EXTERNAL_DNS_NETCUP_API_PASSWORD + valueFrom: + secretKeyRef: + key: EXTERNAL_DNS_NETCUP_API_PASSWORD + name: netcup-api-password +``` + +## Deploying an Nginx Service + +Create a service file called 'nginx.yaml' with the following contents: + +```yaml +apiVersion: apps/v1 +kind: Deployment +metadata: + name: nginx +spec: + selector: + matchLabels: + app: nginx + template: + metadata: + labels: + app: nginx + spec: + containers: + - image: nginx + name: nginx + ports: + - containerPort: 80 +--- +apiVersion: v1 +kind: Service +metadata: + name: nginx + annotations: + external-dns.alpha.kubernetes.io/hostname: example.com +spec: + selector: + app: nginx + type: LoadBalancer + ports: + - protocol: TCP + port: 80 + targetPort: 80 +``` + +Note the annotation on the service; use the same hostname as the Netcup DNS zone created above. The annotation may also be a subdomain +of the DNS zone (e.g. 'www.example.com'). + +By setting the TTL annotation on the service, you have to pass a valid TTL, which must be 120 or above. +This annotation is optional, if you won't set it, it will be 1 (automatic) which is 300. + +ExternalDNS uses this annotation to determine what services should be registered with DNS. Removing the annotation +will cause ExternalDNS to remove the corresponding DNS records. + +Create the deployment and service: + +``` +$ kubectl create -f nginx.yaml +``` + +Depending where you run your service it can take a little while for your cloud provider to create an external IP for the service. + +Once the service has an external IP assigned, ExternalDNS will notice the new service IP address and synchronize +the Netcup DNS records. + +## Verifying Netcup DNS records + +Check your [Netcup domain overview](https://www.customercontrolpanel.de/domains.php) to view the domains associated with your Netcup account. There you can view the records for each domain. + +The records should show the external IP address of the service as the A record for your domain. + +## Cleanup + +Now that we have verified that ExternalDNS will automatically manage Netcup DNS records, we can delete the tutorial's example: + +``` +$ kubectl delete -f nginx.yaml +$ kubectl delete -f externaldns.yaml diff --git a/go.mod b/go.mod index 013f3f9745..c06f1bcd33 100644 --- a/go.mod +++ b/go.mod @@ -12,6 +12,7 @@ require ( github.com/IBM/go-sdk-core/v5 v5.8.0 github.com/IBM/networking-go-sdk v0.36.0 github.com/StackExchange/dnscontrol v0.2.8 + github.com/aellwein/netcup-dns-api v1.0.3 github.com/akamai/AkamaiOPEN-edgegrid-golang v1.2.1 github.com/alecthomas/kingpin v2.2.5+incompatible github.com/aliyun/alibaba-cloud-sdk-go v1.62.4 diff --git a/go.sum b/go.sum index 05976c3a48..c140f0412b 100644 --- a/go.sum +++ b/go.sum @@ -129,6 +129,8 @@ github.com/StackExchange/dnscontrol v0.2.8/go.mod h1:BH+5nX50JxHDdb3+AD/z/UfYMCc github.com/VividCortex/gohistogram v1.0.0/go.mod h1:Pf5mBqqDxYaXu3hDrrU+w6nw50o/4+TcAqDqk/vUH7g= github.com/Yamashou/gqlgenc v0.11.0 h1:y6I7CDrUdY4JBxfwss9168HTP5k/CdExLV5+YPG/3nY= github.com/Yamashou/gqlgenc v0.11.0/go.mod h1:OeQhghEgvGWvRwzx9XjMeg3FUQOHnTo5/12iuJSJxLg= +github.com/aellwein/netcup-dns-api v1.0.3 h1:4tdq2k4Rhb124PdjHV4mWN/eVE7uUtoE+24yUGdOpe0= +github.com/aellwein/netcup-dns-api v1.0.3/go.mod h1:CPCLCm+QlSObPNH4KfFqfCtzVF3Wj03SsHq/vp4E/pE= github.com/afex/hystrix-go v0.0.0-20180502004556-fa1af6a1f4f5/go.mod h1:SkGFH1ia65gfNATL8TAiHDNxPzPdmEL5uirI2Uyuz6c= github.com/agnivade/levenshtein v1.0.1/go.mod h1:CURSv5d9Uaml+FovSIICkLbAUZ9S4RqaHDIsdSBg7lM= github.com/akamai/AkamaiOPEN-edgegrid-golang v1.2.1 h1:5BIsppVPdWJA29Yb5cYawQYeh5geN413WxAgBZvEtdA= diff --git a/main.go b/main.go index df8eabb13a..218aa94258 100644 --- a/main.go +++ b/main.go @@ -56,6 +56,7 @@ import ( "sigs.k8s.io/external-dns/provider/infoblox" "sigs.k8s.io/external-dns/provider/inmemory" "sigs.k8s.io/external-dns/provider/linode" + "sigs.k8s.io/external-dns/provider/netcup" "sigs.k8s.io/external-dns/provider/ns1" "sigs.k8s.io/external-dns/provider/oci" "sigs.k8s.io/external-dns/provider/ovh" @@ -354,6 +355,8 @@ func main() { p, err = plural.NewPluralProvider(cfg.PluralCluster, cfg.PluralProvider) case "tencentcloud": p, err = tencentcloud.NewTencentCloudProvider(domainFilter, zoneIDFilter, cfg.TencentCloudConfigFile, cfg.TencentCloudZoneType, cfg.DryRun) + case "netcup": + p, err = netcup.NewNetcupProvider(domainFilter, cfg.NetcupCustomerID, cfg.NetcupAPIKey, cfg.NetcupAPIPassword, cfg.DryRun) default: log.Fatalf("unknown dns provider: %s", cfg.Provider) } diff --git a/pkg/apis/externaldns/types.go b/pkg/apis/externaldns/types.go index 8224c80ee4..ab86542a71 100644 --- a/pkg/apis/externaldns/types.go +++ b/pkg/apis/externaldns/types.go @@ -198,6 +198,9 @@ type Config struct { PiholeTLSInsecureSkipVerify bool PluralCluster string PluralProvider string + NetcupCustomerID int + NetcupAPIKey string `secure:"yes"` + NetcupAPIPassword string `secure:"yes"` } var defaultConfig = &Config{ @@ -340,6 +343,9 @@ var defaultConfig = &Config{ PiholeTLSInsecureSkipVerify: false, PluralCluster: "", PluralProvider: "", + NetcupCustomerID: 0, + NetcupAPIKey: "", + NetcupAPIPassword: "", } // NewConfig returns new Config object @@ -429,7 +435,7 @@ func (cfg *Config) ParseFlags(args []string) error { app.Flag("exclude-target-net", "Exclude target nets (optional)").StringsVar(&cfg.ExcludeTargetNets) // Flags related to providers - providers := []string{"akamai", "alibabacloud", "aws", "aws-sd", "azure", "azure-dns", "azure-private-dns", "bluecat", "civo", "cloudflare", "coredns", "designate", "digitalocean", "dnsimple", "dyn", "exoscale", "gandi", "godaddy", "google", "ibmcloud", "infoblox", "inmemory", "linode", "ns1", "oci", "ovh", "pdns", "pihole", "plural", "rcodezero", "rdns", "rfc2136", "safedns", "scaleway", "skydns", "tencentcloud", "transip", "ultradns", "vinyldns", "vultr"} + providers := []string{"akamai", "alibabacloud", "aws", "aws-sd", "azure", "azure-dns", "azure-private-dns", "bluecat", "civo", "cloudflare", "coredns", "designate", "digitalocean", "dnsimple", "dyn", "exoscale", "gandi", "godaddy", "google", "ibmcloud", "infoblox", "inmemory", "linode", "netcup", "ns1", "oci", "ovh", "pdns", "pihole", "plural", "rcodezero", "rdns", "rfc2136", "safedns", "scaleway", "skydns", "tencentcloud", "transip", "ultradns", "vinyldns", "vultr"} app.Flag("provider", "The DNS provider where the DNS records will be created (required, options: "+strings.Join(providers, ", ")+")").Required().PlaceHolder("provider").EnumVar(&cfg.Provider, providers...) 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) @@ -553,6 +559,11 @@ func (cfg *Config) ParseFlags(args []string) error { app.Flag("plural-cluster", "When using the plural provider, specify the cluster name you're running with").Default(defaultConfig.PluralCluster).StringVar(&cfg.PluralCluster) app.Flag("plural-provider", "When using the plural provider, specify the provider name you're running with").Default(defaultConfig.PluralProvider).StringVar(&cfg.PluralProvider) + // Flags related to the Netcup provider + app.Flag("netcup-customer-id", "When using the netcup provider, specify the customer id you're running with").Default(strconv.Itoa(defaultConfig.NetcupCustomerID)).IntVar(&cfg.NetcupCustomerID) + app.Flag("netcup-api-key", "When using the netcup provider, specify the api key you're running with").Default(defaultConfig.NetcupAPIKey).StringVar(&cfg.NetcupAPIKey) + app.Flag("netcup-api-password", "When using the netcup provider, specify the api password you're running with").Default(defaultConfig.NetcupAPIPassword).StringVar(&cfg.NetcupAPIPassword) + // Flags related to policies app.Flag("policy", "Modify how DNS records are synchronized between sources and providers (default: sync, options: sync, upsert-only, create-only)").Default(defaultConfig.Policy).EnumVar(&cfg.Policy, "sync", "upsert-only", "create-only") diff --git a/provider/netcup/netcup.go b/provider/netcup/netcup.go new file mode 100644 index 0000000000..ae04227b88 --- /dev/null +++ b/provider/netcup/netcup.go @@ -0,0 +1,291 @@ +/* +Copyright 2022 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 netcup + +import ( + "context" + "fmt" + "strconv" + "strings" + + nc "github.com/aellwein/netcup-dns-api/pkg/v1" + log "github.com/sirupsen/logrus" + + "sigs.k8s.io/external-dns/endpoint" + "sigs.k8s.io/external-dns/plan" + "sigs.k8s.io/external-dns/provider" +) + +// NetcupProvider is an implementation of Provider for Netcup DNS. +type NetcupProvider struct { + provider.BaseProvider + client *nc.NetcupDnsClient + session *nc.NetcupSession + domainFilter endpoint.DomainFilter + dryRun bool +} + +// NetcupChange includes the changesets that need to be applied to the Netcup CCP API +type NetcupChange struct { + Create *[]nc.DnsRecord + UpdateNew *[]nc.DnsRecord + UpdateOld *[]nc.DnsRecord + Delete *[]nc.DnsRecord +} + +// NewNetcupProvider creates a new provider including the netcup CCP API client +func NewNetcupProvider(domainFilter endpoint.DomainFilter, customerID int, apiKey string, apiPassword string, dryRun bool) (*NetcupProvider, error) { + if !domainFilter.IsConfigured() { + return nil, fmt.Errorf("netcup provider requires at least one configured domain in the domainFilter") + } + + if customerID == 0 { + return nil, fmt.Errorf("netcup provider requires a customer ID") + } + + if apiKey == "" { + return nil, fmt.Errorf("netcup provider requires an API Key") + } + + if apiPassword == "" { + return nil, fmt.Errorf("netcup provider requires an API Password") + } + + client := nc.NewNetcupDnsClient(customerID, apiKey, apiPassword) + + return &NetcupProvider{ + client: client, + domainFilter: domainFilter, + dryRun: dryRun, + }, nil +} + +// Records delivers the list of Endpoint records for all zones. +func (p *NetcupProvider) Records(ctx context.Context) ([]*endpoint.Endpoint, error) { + endpoints := make([]*endpoint.Endpoint, 0) + + if p.dryRun { + log.Debugf("dry run - skipping login") + } else { + err := p.ensureLogin() + if err != nil { + return nil, err + } + + defer p.session.Logout() + + for _, domain := range p.domainFilter.Filters { + // some information is on DNS zone itself, query it first + zone, err := p.session.InfoDnsZone(domain) + if err != nil { + return nil, fmt.Errorf("unable to query DNS zone info for domain '%v': %v", domain, err) + } + ttl, err := strconv.ParseUint(zone.Ttl, 10, 64) + if err != nil { + return nil, fmt.Errorf("unexpected error: unable to convert '%s' to uint64", zone.Ttl) + } + // query the records of the domain + recs, err := p.session.InfoDnsRecords(domain) + if err != nil { + return nil, fmt.Errorf("unable to get DNS records for domain '%v': %v", domain, err) + } + log.Infof("got DNS records for domain '%v'", domain) + for _, rec := range *recs { + name := fmt.Sprintf("%s.%s", rec.Hostname, domain) + if rec.Hostname == "@" { + name = domain + } + + ep := endpoint.NewEndpointWithTTL(name, rec.Type, endpoint.TTL(ttl), rec.Destination) + endpoints = append(endpoints, ep) + } + } + } + log.Debugf("Endpoints collected: %v", endpoints) + return endpoints, nil +} + +// ApplyChanges applies a given set of changes in a given zone. +func (p *NetcupProvider) ApplyChanges(ctx context.Context, changes *plan.Changes) error { + if !changes.HasChanges() { + log.Debugf("no changes detected - nothing to do") + return nil + } + + if p.dryRun { + log.Debugf("dry run - skipping login") + } else { + err := p.ensureLogin() + if err != nil { + return err + } + defer p.session.Logout() + } + perZoneChanges := map[string]*plan.Changes{} + + for _, zoneName := range p.domainFilter.Filters { + log.Debugf("zone detected - %s", zoneName) + + perZoneChanges[zoneName] = &plan.Changes{} + } + + for _, ep := range changes.Create { + zoneName := endpointZoneName(ep, p.domainFilter.Filters) + if zoneName == "" { + log.Debugf("create - ignoring change since %s did not match any zone", ep) + continue + } + log.Debugf("planning Create %v in %s", ep, zoneName) + + perZoneChanges[zoneName].Create = append(perZoneChanges[zoneName].Create, ep) + } + + for _, ep := range changes.UpdateOld { + zoneName := endpointZoneName(ep, p.domainFilter.Filters) + if zoneName == "" { + log.Debugf("updateOld - ignoring change since %v did not match any zone", ep) + continue + } + log.Debugf("planning UpdateOld %v in %s", ep, zoneName) + + perZoneChanges[zoneName].UpdateOld = append(perZoneChanges[zoneName].UpdateOld, ep) + } + + for _, ep := range changes.UpdateNew { + zoneName := endpointZoneName(ep, p.domainFilter.Filters) + if zoneName == "" { + log.Debugf("updateNew - ignoring change since %v did not match any zone", ep) + continue + } + log.Debugf("planning UpdateNew %v in %s", ep, zoneName) + perZoneChanges[zoneName].UpdateNew = append(perZoneChanges[zoneName].UpdateNew, ep) + } + + for _, ep := range changes.Delete { + zoneName := endpointZoneName(ep, p.domainFilter.Filters) + if zoneName == "" { + log.Debugf("ignoring change since %v did not match any zone", ep) + continue + } + log.Debugf("planning Delete %v in %s", ep, zoneName) + perZoneChanges[zoneName].Delete = append(perZoneChanges[zoneName].Delete, ep) + } + + if p.dryRun { + log.Infof("dry run - not applying changes") + return nil + } + + // Assemble changes per zone and prepare it for the Netcup API client + for zoneName, c := range perZoneChanges { + // Gather records from API to extract the record ID which is necessary for updating/deleting the record + recs, err := p.session.InfoDnsRecords(zoneName) + if err != nil { + log.Errorf("unable to get DNS records for domain '%v': %v", zoneName, err) + } + change := &NetcupChange{ + Create: convertToNetcupRecord(recs, c.Create, zoneName, false), + UpdateNew: convertToNetcupRecord(recs, c.UpdateNew, zoneName, false), + UpdateOld: convertToNetcupRecord(recs, c.UpdateOld, zoneName, true), + Delete: convertToNetcupRecord(recs, c.Delete, zoneName, true), + } + + // If not in dry run, apply changes + _, err = p.session.UpdateDnsRecords(zoneName, change.UpdateOld) + if err != nil { + return err + } + _, err = p.session.UpdateDnsRecords(zoneName, change.Delete) + if err != nil { + return err + } + _, err = p.session.UpdateDnsRecords(zoneName, change.Create) + if err != nil { + return err + } + _, err = p.session.UpdateDnsRecords(zoneName, change.UpdateNew) + if err != nil { + return err + } + } + + log.Debugf("update completed") + + return nil +} + +// convertToNetcupRecord transforms a list of endpoints into a list of Netcup DNS Records +// returns a pointer to a list of DNS Records +func convertToNetcupRecord(recs *[]nc.DnsRecord, endpoints []*endpoint.Endpoint, zoneName string, DeleteRecord bool) *[]nc.DnsRecord { + records := make([]nc.DnsRecord, len(endpoints)) + + for i, ep := range endpoints { + recordName := strings.TrimSuffix(ep.DNSName, "."+zoneName) + if recordName == zoneName { + recordName = "@" + } + target := ep.Targets[0] + if ep.RecordType == endpoint.RecordTypeTXT && strings.HasPrefix(target, "\"heritage=") { + target = strings.Trim(ep.Targets[0], "\"") + } + + records[i] = nc.DnsRecord{ + Type: ep.RecordType, + Hostname: recordName, + Destination: target, + Id: getIDforRecord(recordName, target, ep.RecordType, recs), + DeleteRecord: DeleteRecord, + } + } + return &records +} + +// getIDforRecord compares the endpoint with existing records to get the ID from Netcup to ensure it can be safely removed. +// returns empty string if no match found +func getIDforRecord(recordName string, target string, recordType string, recs *[]nc.DnsRecord) string { + for _, rec := range *recs { + if recordType == rec.Type && target == rec.Destination && rec.Hostname == recordName { + return rec.Id + } + } + + return "" +} + +// endpointZoneName determines zoneName for endpoint by taking longest suffix zoneName match in endpoint DNSName +// returns empty string if no match found +func endpointZoneName(endpoint *endpoint.Endpoint, zones []string) (zone string) { + var matchZoneName string = "" + for _, zoneName := range zones { + if strings.HasSuffix(endpoint.DNSName, zoneName) && len(zoneName) > len(matchZoneName) { + matchZoneName = zoneName + } + } + return matchZoneName +} + +// ensureLogin makes sure that we are logged in to Netcup API. +func (p *NetcupProvider) ensureLogin() error { + log.Debug("performing login to Netcup DNS API") + session, err := p.client.Login() + if err != nil { + return err + } + p.session = session + log.Debug("successfully logged in to Netcup DNS API") + return nil +} diff --git a/provider/netcup/netcup_test.go b/provider/netcup/netcup_test.go new file mode 100644 index 0000000000..4b691b3cc4 --- /dev/null +++ b/provider/netcup/netcup_test.go @@ -0,0 +1,261 @@ +/* +Copyright 2022 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 netcup + +import ( + "context" + "testing" + + nc "github.com/aellwein/netcup-dns-api/pkg/v1" + + "github.com/stretchr/testify/assert" + "sigs.k8s.io/external-dns/endpoint" + "sigs.k8s.io/external-dns/plan" +) + +func TestNetcupProvider(t *testing.T) { + t.Run("EndpointZoneName", testEndpointZoneName) + t.Run("GetIDforRecord", testGetIDforRecord) + t.Run("ConvertToNetcupRecord", testConvertToNetcupRecord) + t.Run("NewNetcupProvider", testNewNetcupProvider) + t.Run("ApplyChanges", testApplyChanges) + t.Run("Records", testRecords) +} + +func testEndpointZoneName(t *testing.T) { + zoneList := []string{"bar.org", "baz.org"} + + // in zone list + ep1 := endpoint.Endpoint{ + DNSName: "foo.bar.org", + Targets: endpoint.Targets{"5.5.5.5"}, + RecordType: endpoint.RecordTypeA, + } + + // not in zone list + ep2 := endpoint.Endpoint{ + DNSName: "foo.foo.org", + Targets: endpoint.Targets{"5.5.5.5"}, + RecordType: endpoint.RecordTypeA, + } + + // matches zone exactly + ep3 := endpoint.Endpoint{ + DNSName: "baz.org", + Targets: endpoint.Targets{"5.5.5.5"}, + RecordType: endpoint.RecordTypeA, + } + + assert.Equal(t, endpointZoneName(&ep1, zoneList), "bar.org") + assert.Equal(t, endpointZoneName(&ep2, zoneList), "") + assert.Equal(t, endpointZoneName(&ep3, zoneList), "baz.org") +} + +func testGetIDforRecord(t *testing.T) { + + recordName := "foo.example.com" + target1 := "heritage=external-dns,external-dns/owner=default,external-dns/resource=service/default/nginx" + target2 := "5.5.5.5" + recordType := "TXT" + + nc1 := nc.DnsRecord{ + Hostname: "foo.example.com", + Type: "TXT", + Destination: "heritage=external-dns,external-dns/owner=default,external-dns/resource=service/default/nginx", + Id: "10", + DeleteRecord: false, + } + nc2 := nc.DnsRecord{ + Hostname: "foo.foo.org", + Type: "A", + Destination: "5.5.5.5", + Id: "10", + DeleteRecord: false, + } + + nc3 := nc.DnsRecord{ + Id: "", + Hostname: "baz.org", + Type: "A", + Destination: "5.5.5.5", + DeleteRecord: false, + } + + ncRecordList := []nc.DnsRecord{nc1, nc2, nc3} + + assert.Equal(t, "10", getIDforRecord(recordName, target1, recordType, &ncRecordList)) + assert.Equal(t, "", getIDforRecord(recordName, target2, recordType, &ncRecordList)) + +} + +func testConvertToNetcupRecord(t *testing.T) { + // in zone list + ep1 := endpoint.Endpoint{ + DNSName: "foo.bar.org", + Targets: endpoint.Targets{"5.5.5.5"}, + RecordType: endpoint.RecordTypeA, + } + + // not in zone list + ep2 := endpoint.Endpoint{ + DNSName: "foo.foo.org", + Targets: endpoint.Targets{"5.5.5.5"}, + RecordType: endpoint.RecordTypeA, + } + + // matches zone exactly + ep3 := endpoint.Endpoint{ + DNSName: "bar.org", + Targets: endpoint.Targets{"5.5.5.5"}, + RecordType: endpoint.RecordTypeA, + } + + ep4 := endpoint.Endpoint{ + DNSName: "foo.baz.org", + Targets: endpoint.Targets{"\"heritage=external-dns,external-dns/owner=default,external-dns/resource=service/default/nginx\""}, + RecordType: endpoint.RecordTypeTXT, + } + + epList := []*endpoint.Endpoint{&ep1, &ep2, &ep3, &ep4} + + nc1 := nc.DnsRecord{ + Hostname: "foo", + Type: "A", + Destination: "5.5.5.5", + Id: "10", + DeleteRecord: false, + } + nc2 := nc.DnsRecord{ + Hostname: "foo.foo.org", + Type: "A", + Destination: "5.5.5.5", + Id: "15", + DeleteRecord: false, + } + + nc3 := nc.DnsRecord{ + Id: "", + Hostname: "@", + Type: "A", + Destination: "5.5.5.5", + DeleteRecord: false, + } + + nc4 := nc.DnsRecord{ + Id: "", + Hostname: "foo.baz.org", + Type: "TXT", + Destination: "heritage=external-dns,external-dns/owner=default,external-dns/resource=service/default/nginx", + DeleteRecord: false, + } + + ncRecordList := []nc.DnsRecord{nc1, nc2, nc3, nc4} + + // No deletion + assert.Equal(t, convertToNetcupRecord(&ncRecordList, epList, "bar.org", false), &ncRecordList) + // Deletion active + + nc1.DeleteRecord = true + nc2.DeleteRecord = true + nc3.DeleteRecord = true + nc4.DeleteRecord = true + ncRecordList2 := []nc.DnsRecord{nc1, nc2, nc3, nc4} + assert.Equal(t, convertToNetcupRecord(&ncRecordList2, epList, "bar.org", true), &ncRecordList2) + +} + +func testNewNetcupProvider(t *testing.T) { + p, err := NewNetcupProvider(endpoint.NewDomainFilter([]string{"example.com"}), 10, "KEY", "PASSWORD", true) + assert.NotNil(t, p.client) + assert.NoError(t, err) + + _, err = NewNetcupProvider(endpoint.NewDomainFilter([]string{"example.com"}), 0, "KEY", "PASSWORD", true) + assert.Error(t, err) + + _, err = NewNetcupProvider(endpoint.NewDomainFilter([]string{"example.com"}), 10, "", "PASSWORD", true) + assert.Error(t, err) + + _, err = NewNetcupProvider(endpoint.NewDomainFilter([]string{"example.com"}), 10, "KEY", "", true) + assert.Error(t, err) + + _, err = NewNetcupProvider(endpoint.NewDomainFilter([]string{}), 10, "KEY", "PASSWORD", true) + assert.Error(t, err) + +} + +func testApplyChanges(t *testing.T) { + p, _ := NewNetcupProvider(endpoint.NewDomainFilter([]string{"example.com"}), 10, "KEY", "PASSWORD", true) + changes1 := &plan.Changes{ + Create: []*endpoint.Endpoint{}, + Delete: []*endpoint.Endpoint{}, + UpdateNew: []*endpoint.Endpoint{}, + UpdateOld: []*endpoint.Endpoint{}, + } + + // No Changes + err := p.ApplyChanges(context.TODO(), changes1) + assert.NoError(t, err) + + // Changes + changes2 := &plan.Changes{ + Create: []*endpoint.Endpoint{ + { + DNSName: "api.example.com", + RecordType: "A", + }, + { + DNSName: "api.baz.com", + RecordType: "TXT", + }}, + Delete: []*endpoint.Endpoint{ + { + DNSName: "api.example.com", + RecordType: "A", + }, + { + DNSName: "api.baz.com", + RecordType: "TXT", + }}, + UpdateNew: []*endpoint.Endpoint{ + { + DNSName: "api.example.com", + RecordType: "A", + }, + { + DNSName: "api.baz.com", + RecordType: "TXT", + }}, + UpdateOld: []*endpoint.Endpoint{ + { + DNSName: "api.example.com", + RecordType: "A", + }, + { + DNSName: "api.baz.com", + RecordType: "TXT", + }}, + } + + err = p.ApplyChanges(context.TODO(), changes2) + assert.NoError(t, err) + +} + +func testRecords(t *testing.T) { + p, _ := NewNetcupProvider(endpoint.NewDomainFilter([]string{"example.com"}), 10, "KEY", "PASSWORD", true) + ep, err := p.Records(context.TODO()) + assert.Equal(t, []*endpoint.Endpoint{}, ep) + assert.NoError(t, err) +}