diff --git a/.github/labeler.yml b/.github/labeler.yml index 2ed29809e..0274c9d9d 100644 --- a/.github/labeler.yml +++ b/.github/labeler.yml @@ -61,3 +61,6 @@ provider/oci: provider/oci* # Add 'provider/vinyldns' in file which starts with vinyldns provider/vinyldns: provider/vinyldns* + +# Add 'provider/vultr' in file which starts with vultr +provider/vultr: provider/vultr* diff --git a/README.md b/README.md index dd46bd145..5f1431445 100644 --- a/README.md +++ b/README.md @@ -95,6 +95,7 @@ The following table clarifies the current status of the providers according to t | RancherDNS | Alpha | | | Akamai FastDNS | Alpha | | | OVH | Alpha | | +| Vultr | Alpha | | ## Running ExternalDNS: @@ -142,6 +143,7 @@ The following tutorials are provided: * [TransIP](docs/tutorials/transip.md) * [VinylDNS](docs/tutorials/vinyldns.md) * [OVH](docs/tutorials/ovh.md) +* [Vultr](docs/tutorials/vultr.md) ### Running Locally diff --git a/docs/ttl.md b/docs/ttl.md index 8855131e1..8e5df4e0f 100644 --- a/docs/ttl.md +++ b/docs/ttl.md @@ -44,6 +44,7 @@ Providers - [x] Linode - [x] TransIP - [x] RFC2136 +- [x] Vultr PRs welcome! @@ -72,3 +73,6 @@ The Linode Provider default TTL is used when the TTL is 0. The default is 24 hou ### TransIP Provider The TransIP Provider minimal TTL is used when the TTL is 0. The minimal TTL is 60s. + +### Vultr Provider +The Vultr provider minimal TTL is used when the TTL is 0. The default is 1 hour. diff --git a/docs/tutorials/vultr.md b/docs/tutorials/vultr.md new file mode 100644 index 000000000..7bd0a9f5b --- /dev/null +++ b/docs/tutorials/vultr.md @@ -0,0 +1,188 @@ +# Setting up ExternalDNS for Services on Vultr + +This tutorial describes how to setup ExternalDNS for usage within a Kubernetes cluster using Vultr DNS. + +Make sure to use **>=0.6** version of ExternalDNS for this tutorial. + +## Managing DNS with Vultr + +If you want to read up on vultr DNS service you can read the following tutorial: +[Introduction to Vultr DNS](https://www.vultr.com/docs/introduction-to-vultr-dns) + +Create a new DNS Zone where you want to create your records in. For the examples we will be using `example.com` + +## Creating Vultr Credentials + +You will need to create a new API Key which can be found on the [Vultr Dashboard](https://my.vultr.com/settings/#settingsapi). + +The environment variable `VULTR_API_KEY` will be needed to run ExternalDNS with Vultr. + +## Deploy ExternalDNS + +Connect your `kubectl` client to the cluster you want to test ExternalDNS with. +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.opensource.zalan.do/teapot/external-dns:latest + args: + - --source=service # ingress is also possible + - --domain-filter=example.com # (optional) limit to only example.com domains; change to match the zone created above. + - --provider=vultr + env: + - name: VULTR_API_KEY + value: "YOU_VULTR_API_KEY" +``` + +### Manifest (for clusters with RBAC enabled) + +```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: ["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: 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.opensource.zalan.do/teapot/external-dns:latest + args: + - --source=service # ingress is also possible + - --domain-filter=example.com # (optional) limit to only example.com domains; change to match the zone created above. + - --provider=vultr + env: + - name: VULTR_API_KEY + value: "YOU_VULTR_API_KEY" +``` + +## 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: my-app.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 Vultr DNS zone created above. + +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: + +```console +$ 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 Vultr DNS records. + +## Verifying Vultr DNS records + +Check your [Vultr UI](https://my.vultr.com/dns/) to view the records for your Vultr DNS zone. + +Click on the zone for the one created above if a different domain was used. + +This 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 Vultr DNS records, we can delete the tutorial's example: + +``` +$ kubectl delete service -f nginx.yaml +$ kubectl delete service -f externaldns.yaml +``` diff --git a/go.mod b/go.mod index 31c945ddd..88d165710 100644 --- a/go.mod +++ b/go.mod @@ -50,6 +50,7 @@ require ( github.com/stretchr/testify v1.4.0 github.com/transip/gotransip v5.8.2+incompatible github.com/vinyldns/go-vinyldns v0.0.0-20190611170422-7119fe55ed92 + github.com/vultr/govultr v0.3.2 go.etcd.io/etcd v0.5.0-alpha.5.0.20200401174654-e694b7bb0875 golang.org/x/net v0.0.0-20190923162816-aa69164e4478 golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45 diff --git a/go.sum b/go.sum index cbd568e10..1215b0df5 100644 --- a/go.sum +++ b/go.sum @@ -320,8 +320,12 @@ github.com/hashicorp/consul v1.3.0/go.mod h1:mFrjN1mfidgJfYP1xrJCF+AfRhr6Eaqhb2+ github.com/hashicorp/errwrap v1.0.0 h1:hLrqtEDnRye3+sgx6z4qVLNuviH3MR5aQ0ykNJa/UYA= github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= github.com/hashicorp/go-cleanhttp v0.5.0/go.mod h1:JpRdi6/HCYpAwUzNwuwqhbovhLtngrth3wmdIIUrZ80= +github.com/hashicorp/go-cleanhttp v0.5.1 h1:dH3aiDG9Jvb5r5+bYHsikaOUIpcM0xvgMXVoDkXMzJM= +github.com/hashicorp/go-cleanhttp v0.5.1/go.mod h1:JpRdi6/HCYpAwUzNwuwqhbovhLtngrth3wmdIIUrZ80= github.com/hashicorp/go-hclog v0.0.0-20180709165350-ff2cf002a8dd/go.mod h1:9bjs9uLqI8l75knNv3lV1kA55veR+WUPSiKIWcQHudI= github.com/hashicorp/go-hclog v0.9.0/go.mod h1:5CU+agLiy3J7N7QjHK5d05KxGsuXiQLrjA0H7acj2lQ= +github.com/hashicorp/go-hclog v0.9.2 h1:CG6TE5H9/JXsFWJCfoIVpKFIkFe6ysEuHirp4DxCsHI= +github.com/hashicorp/go-hclog v0.9.2/go.mod h1:5CU+agLiy3J7N7QjHK5d05KxGsuXiQLrjA0H7acj2lQ= github.com/hashicorp/go-immutable-radix v1.0.0/go.mod h1:0y9vanUI8NX6FsYoO3zeMjhV/C5i9g4Q3DwcSNZ4P60= github.com/hashicorp/go-memdb v1.0.1/go.mod h1:I6dKdmYhZqU0RJSheVEWgTNWdVQH5QvTgIUQ0t/t32M= github.com/hashicorp/go-msgpack v0.5.3/go.mod h1:ahLV/dePpqEmjfWmKiqvPkv/twdG7iPBM1vqhUKIvfM= @@ -330,6 +334,8 @@ github.com/hashicorp/go-multierror v1.0.0 h1:iVjPR7a6H0tWELX5NxNe7bYopibicUzc7uP github.com/hashicorp/go-multierror v1.0.0/go.mod h1:dHtQlpGsu+cZNNAkkCN/P3hoUDHhCYQXV3UM06sGGrk= github.com/hashicorp/go-plugin v1.0.0/go.mod h1:++UyYGoz3o5w9ZzAdZxtQKrWWP+iqPBn3cQptSMzBuY= github.com/hashicorp/go-retryablehttp v0.5.3/go.mod h1:9B5zBasrRhHXnJnui7y6sL7es7NDiJgTc6Er0maI1Xs= +github.com/hashicorp/go-retryablehttp v0.6.4 h1:BbgctKO892xEyOXnGiaAwIoSq1QZ/SS4AhjoAh9DnfY= +github.com/hashicorp/go-retryablehttp v0.6.4/go.mod h1:vAew36LZh98gCBJNLH42IQ1ER/9wtLZZ8meHqQvEYWY= github.com/hashicorp/go-rootcerts v0.0.0-20160503143440-6bb64b370b90/go.mod h1:o4zcYY1e0GEZI6eSEr+43QDYmuGglw1qSO6qdHUHCgg= github.com/hashicorp/go-sockaddr v1.0.0/go.mod h1:7Xibr9yA9JjQq1JpNB2Vw7kxv8xerXegt+ozgdvDeDU= github.com/hashicorp/go-uuid v1.0.0/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro= @@ -604,6 +610,8 @@ github.com/urfave/cli v1.20.0/go.mod h1:70zkFmudgCuE/ngEzBv17Jvp/497gISqfk5gWijb github.com/urfave/cli v1.21.0/go.mod h1:lxDj6qX9Q6lWQxIrbrT0nwecwUtRnhVZAJjJZrVUZZQ= github.com/vinyldns/go-vinyldns v0.0.0-20190611170422-7119fe55ed92 h1:Q76MzqJu++vAfhj0mVf7t0F4xHUbg+V/d/Uk5PBQjRU= github.com/vinyldns/go-vinyldns v0.0.0-20190611170422-7119fe55ed92/go.mod h1:AZuEfReFWdvtU0LatbLpo70t3lqdLvph2D5mqFP0bkA= +github.com/vultr/govultr v0.3.2 h1:1tV/88jkm+4Y345qAXBe3peNbnmvCY/VAIZApklbKkI= +github.com/vultr/govultr v0.3.2/go.mod h1:81RwK1wAmb08alkFDJiZmu9gdv+IO+UamzaF0+PIieE= github.com/xeipuuv/gojsonpointer v0.0.0-20180127040702-4e3ac2762d5f/go.mod h1:N2zxlSyiKSe5eX1tZViRH5QA0qijqEDrYZiPEAiq3wU= github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415/go.mod h1:GwrjFmJcFw6At/Gs6z4yjiIwzuJ1/+UwLxMQDVQXShQ= github.com/xeipuuv/gojsonschema v1.2.0/go.mod h1:anYRn/JVcOK2ZgGU+IjEV4nwlhoK5sQluxsYJ78Id3Y= diff --git a/main.go b/main.go index b31b0884c..a09e21d47 100644 --- a/main.go +++ b/main.go @@ -165,6 +165,8 @@ func main() { p, err = provider.NewAzurePrivateDNSProvider(domainFilter, zoneIDFilter, cfg.AzureResourceGroup, cfg.AzureSubscriptionID, cfg.DryRun) case "vinyldns": p, err = provider.NewVinylDNSProvider(domainFilter, zoneIDFilter, cfg.DryRun) + case "vultr": + p, err = provider.NewVultrProvider(domainFilter, cfg.DryRun) case "cloudflare": p, err = provider.NewCloudFlareProvider(domainFilter, zoneIDFilter, cfg.CloudflareZonesPerPage, cfg.CloudflareProxied, cfg.DryRun) case "rcodezero": diff --git a/pkg/apis/externaldns/types.go b/pkg/apis/externaldns/types.go index 12d7e4551..385427d9d 100644 --- a/pkg/apis/externaldns/types.go +++ b/pkg/apis/externaldns/types.go @@ -312,7 +312,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, azure-dns, azure-private-dns, cloudflare, rcodezero, digitalocean, dnsimple, akamai, infoblox, dyn, designate, coredns, skydns, inmemory, ovh, pdns, oci, exoscale, linode, rfc2136, ns1, transip, vinyldns, rdns)").Required().PlaceHolder("provider").EnumVar(&cfg.Provider, "aws", "aws-sd", "google", "azure", "azure-dns", "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") + app.Flag("provider", "The DNS provider where the DNS records will be created (required, options: aws, aws-sd, google, azure, azure-dns, azure-private-dns, cloudflare, rcodezero, digitalocean, dnsimple, akamai, infoblox, dyn, designate, coredns, skydns, inmemory, ovh, pdns, oci, exoscale, linode, rfc2136, ns1, transip, vinyldns, rdns, vultr)").Required().PlaceHolder("provider").EnumVar(&cfg.Provider, "aws", "aws-sd", "google", "azure", "azure-dns", "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", "vultr") 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/provider/vultr.go b/provider/vultr.go new file mode 100644 index 000000000..869ace7c3 --- /dev/null +++ b/provider/vultr.go @@ -0,0 +1,276 @@ +/* +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 provider + +import ( + "context" + "fmt" + "os" + "strconv" + "strings" + + log "github.com/sirupsen/logrus" + "github.com/vultr/govultr" + "sigs.k8s.io/external-dns/endpoint" + "sigs.k8s.io/external-dns/plan" +) + +const ( + vultrCreate = "CREATE" + vultrDelete = "DELETE" + vultrUpdate = "UPDATE" + vultrTTL = 3600 +) + +type VultrProvider struct { + client govultr.Client + + domainFilter endpoint.DomainFilter + DryRun bool +} + +type VultrChanges struct { + Action string + + ResourceRecordSet govultr.DNSRecord +} + +// NewVultrProvider initializes a new Vultr BNS based provider +func NewVultrProvider(domainFilter endpoint.DomainFilter, dryRun bool) (*VultrProvider, error) { + apiKey, ok := os.LookupEnv("VULTR_API_KEY") + if !ok { + return nil, fmt.Errorf("no token found") + } + + client := govultr.NewClient(nil, apiKey) + client.SetUserAgent(fmt.Sprintf("ExternalDNS/%s", client.UserAgent)) + + provider := &VultrProvider{ + client: *client, + domainFilter: domainFilter, + DryRun: dryRun, + } + + return provider, nil +} + +// Zones returns list of hosted zones +func (p *VultrProvider) Zones(ctx context.Context) ([]govultr.DNSDomain, error) { + zones, err := p.fetchZones(ctx) + if err != nil { + return nil, err + } + + return zones, nil +} + +func (p *VultrProvider) Records(ctx context.Context) ([]*endpoint.Endpoint, error) { + zones, err := p.Zones(ctx) + if err != nil { + return nil, err + } + + var endpoints []*endpoint.Endpoint + + for _, zone := range zones { + records, err := p.fetchRecords(ctx, zone.Domain) + if err != nil { + return nil, err + } + + for _, r := range records { + if supportedRecordType(r.Type) { + name := fmt.Sprintf("%s.%s", r.Name, zone.Domain) + + // root name is identified by the empty string and should be + // translated to zone name for the endpoint entry. + if r.Name == "" { + name = zone.Domain + } + + endPointTTL := endpoint.NewEndpointWithTTL(name, r.Type, endpoint.TTL(r.TTL), r.Data) + endpoints = append(endpoints, endPointTTL) + } + } + } + return endpoints, nil +} + +func (p *VultrProvider) fetchRecords(ctx context.Context, domain string) ([]govultr.DNSRecord, error) { + records, err := p.client.DNSRecord.List(ctx, domain) + if err != nil { + return nil, err + } + + return records, nil +} + +func (p *VultrProvider) fetchZones(ctx context.Context) ([]govultr.DNSDomain, error) { + var zones []govultr.DNSDomain + + allZones, err := p.client.DNSDomain.List(ctx) + if err != nil { + return nil, err + } + + for _, zone := range allZones { + if p.domainFilter.Match(zone.Domain) { + zones = append(zones, zone) + } + } + + return zones, nil +} + +func (p *VultrProvider) submitChanges(ctx context.Context, changes []*VultrChanges) error { + if len(changes) == 0 { + log.Infof("All records are already up to date") + return nil + } + + zones, err := p.Zones(ctx) + if err != nil { + return err + } + + zoneChanges := seperateChangesByZone(zones, changes) + + for zoneName, changes := range zoneChanges { + for _, change := range changes { + + log.WithFields(log.Fields{ + "record": change.ResourceRecordSet.Name, + "type": change.ResourceRecordSet.Type, + "ttl": change.ResourceRecordSet.TTL, + "action": change.Action, + "zone": zoneName, + }).Info("Changing record.") + + switch change.Action { + case vultrCreate: + err = p.client.DNSRecord.Create(ctx, zoneName, change.ResourceRecordSet.Type, change.ResourceRecordSet.Name, change.ResourceRecordSet.Data, change.ResourceRecordSet.TTL, change.ResourceRecordSet.Priority) + if err != nil { + return err + } + case vultrDelete: + id, err := p.getRecordID(ctx, zoneName, change.ResourceRecordSet) + if err != nil { + return err + } + + err = p.client.DNSRecord.Delete(ctx, zoneName, strconv.Itoa(id)) + if err != nil { + return err + } + case vultrUpdate: + id, err := p.getRecordID(ctx, zoneName, change.ResourceRecordSet) + if err != nil { + return err + } + + record := &govultr.DNSRecord{ + RecordID: id, + Type: change.ResourceRecordSet.Type, + Name: change.ResourceRecordSet.Name, + Data: change.ResourceRecordSet.Data, + TTL: change.ResourceRecordSet.TTL, + } + + err = p.client.DNSRecord.Update(ctx, zoneName, record) + if err != nil { + return err + } + } + } + } + + return nil +} + +func (p *VultrProvider) ApplyChanges(ctx context.Context, changes *plan.Changes) error { + combinedChanges := make([]*VultrChanges, 0, len(changes.Create)+len(changes.UpdateNew)+len(changes.Delete)) + + combinedChanges = append(combinedChanges, newVultrChanges(vultrCreate, changes.Create)...) + combinedChanges = append(combinedChanges, newVultrChanges(vultrUpdate, changes.UpdateNew)...) + combinedChanges = append(combinedChanges, newVultrChanges(vultrDelete, changes.Delete)...) + + return p.submitChanges(ctx, combinedChanges) +} + +func newVultrChanges(action string, endpoints []*endpoint.Endpoint) []*VultrChanges { + changes := make([]*VultrChanges, 0, len(endpoints)) + ttl := vultrTTL + for _, e := range endpoints { + + if e.RecordTTL.IsConfigured() { + ttl = int(e.RecordTTL) + } + + change := &VultrChanges{ + Action: action, + ResourceRecordSet: govultr.DNSRecord{ + Type: e.RecordType, + Name: e.DNSName, + Data: e.Targets[0], + TTL: ttl, + }, + } + changes = append(changes, change) + } + return changes +} + +func seperateChangesByZone(zones []govultr.DNSDomain, changes []*VultrChanges) map[string][]*VultrChanges { + change := make(map[string][]*VultrChanges) + zoneNameID := zoneIDName{} + + for _, z := range zones { + zoneNameID.Add(z.Domain, z.Domain) + change[z.Domain] = []*VultrChanges{} + } + + for _, c := range changes { + zone, _ := zoneNameID.FindZone(c.ResourceRecordSet.Name) + if zone == "" { + log.Debugf("Skipping record %s because no hosted zone matching record DNS Name was detected", c.ResourceRecordSet.Name) + continue + } + change[zone] = append(change[zone], c) + + } + return change +} + +func (p *VultrProvider) getRecordID(ctx context.Context, zone string, record govultr.DNSRecord) (recordID int, err error) { + records, err := p.client.DNSRecord.List(ctx, zone) + if err != nil { + return 0, err + } + + for _, r := range records { + strippedName := strings.TrimSuffix(record.Name, "."+zone) + if record.Name == zone { + strippedName = "" + } + + if r.Name == strippedName && r.Type == record.Type { + return r.RecordID, nil + } + } + + return 0, fmt.Errorf("no record was found") +} diff --git a/provider/vultr_test.go b/provider/vultr_test.go new file mode 100644 index 000000000..73bd0e02a --- /dev/null +++ b/provider/vultr_test.go @@ -0,0 +1,191 @@ +/* +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 provider + +import ( + "context" + "os" + "reflect" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/vultr/govultr" + "sigs.k8s.io/external-dns/endpoint" + "sigs.k8s.io/external-dns/plan" +) + +type mockVultrDomain struct { + client *govultr.Client +} + +func (m *mockVultrDomain) Create(ctx context.Context, domain, InstanceIP string) error { + return nil +} + +func (m *mockVultrDomain) Delete(ctx context.Context, domain string) error { + return nil +} + +func (m *mockVultrDomain) ToggleDNSSec(ctx context.Context, domain string, enabled bool) error { + return nil +} + +func (m *mockVultrDomain) DNSSecInfo(ctx context.Context, domain string) ([]string, error) { + return nil, nil +} + +func (m *mockVultrDomain) List(ctx context.Context) ([]govultr.DNSDomain, error) { + return []govultr.DNSDomain{{Domain: "test.com"}}, nil +} + +func (m *mockVultrDomain) GetSoa(ctx context.Context, domain string) (*govultr.Soa, error) { + return nil, nil +} + +func (m *mockVultrDomain) UpdateSoa(ctx context.Context, domain, nsPrimary, email string) error { + return nil +} + +type mockVultrRecord struct { + client *govultr.Client +} + +func (m *mockVultrRecord) Create(ctx context.Context, domain, recordType, name, data string, ttl, priority int) error { + return nil +} + +func (m *mockVultrRecord) Delete(ctx context.Context, domain, recordID string) error { + return nil +} + +func (m *mockVultrRecord) List(ctx context.Context, domain string) ([]govultr.DNSRecord, error) { + return []govultr.DNSRecord{{RecordID: 123, Type: "A", Name: "test", Data: "192.168.1.1", TTL: 300}}, nil +} + +func (m *mockVultrRecord) Update(ctx context.Context, domain string, dnsRecord *govultr.DNSRecord) error { + return nil +} + +func TestNewVultrProvider(t *testing.T) { + _ = os.Setenv("VULTR_API_KEY", "") + _, err := NewVultrProvider(endpoint.NewDomainFilter([]string{"test.vultr.com"}), true) + if err != nil { + t.Errorf("failed : %s", err) + } + + _ = os.Unsetenv("VULTR_API_KEY") + _, err = NewVultrProvider(endpoint.NewDomainFilter([]string{"test.vultr.com"}), true) + if err == nil { + t.Errorf("expected to fail") + } +} + +func TestVultrProvider_Zones(t *testing.T) { + mocked := mockVultrDomain{nil} + provider := &VultrProvider{ + client: govultr.Client{ + DNSDomain: &mocked, + }, + } + + expected, err := provider.client.DNSDomain.List(context.Background()) + if err != nil { + t.Fatal(err) + } + zones, err := provider.Zones(context.Background()) + if err != nil { + t.Fatal(err) + } + if !reflect.DeepEqual(expected, zones) { + t.Fatal(err) + } +} + +func TestVultrProvider_Records(t *testing.T) { + mocked := mockVultrRecord{nil} + mockedDomain := mockVultrDomain{nil} + + provider := &VultrProvider{ + client: govultr.Client{ + DNSRecord: &mocked, + DNSDomain: &mockedDomain, + }, + } + + expected, _ := provider.client.DNSRecord.List(context.Background(), "test.com") + records, err := provider.Records(context.Background()) + if err != nil { + t.Fatal(err) + } + + for _, v := range records { + assert.Equal(t, strings.TrimSuffix(v.DNSName, ".test.com"), expected[0].Name) + assert.Equal(t, v.RecordType, expected[0].Type) + assert.Equal(t, int(v.RecordTTL), expected[0].TTL) + } + +} + +func TestVultrProvider_ApplyChanges(t *testing.T) { + changes := &plan.Changes{} + mocked := mockVultrRecord{nil} + mockedDomain := mockVultrDomain{nil} + + provider := &VultrProvider{ + client: govultr.Client{ + DNSRecord: &mocked, + DNSDomain: &mockedDomain, + }, + } + + changes.Create = []*endpoint.Endpoint{ + {DNSName: "test.com", Targets: endpoint.Targets{"target"}}, + {DNSName: "ttl.test.com", Targets: endpoint.Targets{"target"}, RecordTTL: 100}, + } + + changes.UpdateNew = []*endpoint.Endpoint{{DNSName: "test.test.com", Targets: endpoint.Targets{"target-new"}, RecordType: "A", RecordTTL: 100}} + changes.Delete = []*endpoint.Endpoint{{DNSName: "test.test.com", Targets: endpoint.Targets{"target"}, RecordType: "A"}} + err := provider.ApplyChanges(context.Background(), changes) + if err != nil { + t.Errorf("should not fail, %s", err) + } +} + +func TestVultrProvider_getRecordID(t *testing.T) { + mocked := mockVultrRecord{nil} + mockedDomain := mockVultrDomain{nil} + + provider := &VultrProvider{ + client: govultr.Client{ + DNSRecord: &mocked, + DNSDomain: &mockedDomain, + }, + } + + record := govultr.DNSRecord{ + RecordID: 123, + Type: "A", + Name: "test.test.com", + } + id, err := provider.getRecordID(context.Background(), "test.com", record) + if err != nil { + t.Fatal(err) + } + + assert.Equal(t, id, record.RecordID) +}