Add Gandi provider

This commit is contained in:
Patrick Stählin 2021-03-31 22:47:51 +02:00
parent 497aba2cfb
commit b5f7570c35
9 changed files with 1345 additions and 1 deletions

View File

@ -51,6 +51,7 @@ ExternalDNS' current release is `v0.7`. This version allows you to keep selected
* [Scaleway](https://www.scaleway.com)
* [Akamai Edge DNS](https://learn.akamai.com/en-us/products/cloud_security/edge_dns.html)
* [GoDaddy](https://www.godaddy.com)
* [Gandi](https://www.gandi.net)
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.
@ -107,6 +108,7 @@ The following table clarifies the current status of the providers according to t
| Vultr | Alpha | |
| UltraDNS | Alpha | |
| GoDaddy | Alpha | |
| Gandi | Alpha | @packi |
## Running ExternalDNS:
@ -160,6 +162,7 @@ The following tutorials are provided:
* [Vultr](docs/tutorials/vultr.md)
* [UltraDNS](docs/tutorials/ultradns.md)
* [GoDaddy](docs/tutorials/godaddy.md)
* [Gandi](docs/tutorials/gandi.md)
### Running Locally

191
docs/tutorials/gandi.md Normal file
View File

@ -0,0 +1,191 @@
# Setting up ExternalDNS for Services on Gandi
This tutorial describes how to setup ExternalDNS for usage within a Kubernetes cluster using Gandi.
Make sure to use **>=0.7.7** version of ExternalDNS for this tutorial.
## Creating a Gandi DNS zone (domain)
Create a new DNS zone where you want to create your records in. Let's use `example.com` as an example here. Make sure the zone uses
## Creating Gandi API Key
Generate an API key on [your account](https://account.gandi.net) (click on "Security").
The environment variable `GANDI_KEY` will be needed to run ExternalDNS with Gandi.
## 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:
replicas: 1
selector:
matchLabels:
app: external-dns
strategy:
type: Recreate
template:
metadata:
labels:
app: external-dns
spec:
containers:
- name: external-dns
image: k8s.gcr.io/external-dns/external-dns:v0.7.7
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=gandi
env:
- name: GANDI_KEY
value: "YOUR_GANDI_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","networking.k8s.io"]
resources: ["ingresses"]
verbs: ["get","watch","list"]
- apiGroups: [""]
resources: ["nodes"]
verbs: ["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:
replicas: 1
selector:
matchLabels:
app: external-dns
strategy:
type: Recreate
template:
metadata:
labels:
app: external-dns
spec:
serviceAccountName: external-dns
containers:
- name: external-dns
image: k8s.gcr.io/external-dns/external-dns:v0.7.7
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=gandi
env:
- name: GANDI_KEY
value: "YOUR_GANDI_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:
replicas: 1
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 Gandi Domain. Make sure that your Domain is configured to use Live-DNS.
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 Gandi DNS records.
## Verifying Gandi DNS records
Check your [Gandi Dashboard](https://admin.gandi.net/domain) to view the records for your Gandi 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 Gandi DNS records, we can delete the tutorial's example:
```
$ kubectl delete service -f nginx.yaml
$ kubectl delete service -f externaldns.yaml
```
# Additional options
If you're using organizations to separate your domains, you can pass the organization's ID in an environment variable called `GANDI_SHARING_ID` to get access to it.

1
go.mod
View File

@ -26,6 +26,7 @@ require (
github.com/exoscale/egoscale v0.18.1
github.com/fatih/structs v1.1.0 // indirect
github.com/ffledgling/pdns-go v0.0.0-20180219074714-524e7daccd99
github.com/go-gandi/go-gandi v0.0.0-20200921091836-0d8a64b9cc09
github.com/golang/sync v0.0.0-20180314180146-1d60e4601c6f
github.com/google/go-cmp v0.4.1
github.com/gophercloud/gophercloud v0.1.0

3
go.sum
View File

@ -84,6 +84,7 @@ github.com/alecthomas/colour v0.1.0 h1:nOE9rJm6dsZ66RGWYSFrXw461ZIt9A6+nHgL7FRrD
github.com/alecthomas/colour v0.1.0/go.mod h1:QO9JBoKquHd+jz9nshCh40fOfO+JzsoXy8qTHF68zU0=
github.com/alecthomas/kingpin v2.2.5+incompatible h1:umWl1NNd72+ZvRti3T9C0SYean2hPZ7ZhxU8bsgc9BQ=
github.com/alecthomas/kingpin v2.2.5+incompatible/go.mod h1:59OFYbFVLKQKq+mqrL6Rw5bR0c3ACQaawgXx0QYndlE=
github.com/alecthomas/kong v0.2.2/go.mod h1:kQOmtJgV+Lb4aj+I2LEn40cbtawdWJ9Y8QLq+lElKxE=
github.com/alecthomas/repr v0.0.0-20200325044227-4184120f674c h1:MVVbswUlqicyj8P/JljoocA7AyCo62gzD0O7jfvrhtE=
github.com/alecthomas/repr v0.0.0-20200325044227-4184120f674c/go.mod h1:xTS7Pm1pD1mvyM075QCDSRqH6qRLXylzS24ZTpRiSzQ=
github.com/alecthomas/template v0.0.0-20160405071501-a0175ee3bccc/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc=
@ -266,6 +267,8 @@ github.com/ghodss/yaml v1.0.0/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeME
github.com/globalsign/mgo v0.0.0-20180905125535-1ca0a4f7cbcb/go.mod h1:xkRDCp4j0OGD1HRkm4kmhM+pmpv3AKq5SU7GMg4oO/Q=
github.com/globalsign/mgo v0.0.0-20181015135952-eeefdecb41b8/go.mod h1:xkRDCp4j0OGD1HRkm4kmhM+pmpv3AKq5SU7GMg4oO/Q=
github.com/go-errors/errors v1.0.1/go.mod h1:f4zRHt4oKfwPJE5k8C9vpYG+aDHdBFUsgrm6/TyX73Q=
github.com/go-gandi/go-gandi v0.0.0-20200921091836-0d8a64b9cc09 h1:w+iZczt5J4LJa13RX5uguKI866vIEMOESgXr4XcwrwA=
github.com/go-gandi/go-gandi v0.0.0-20200921091836-0d8a64b9cc09/go.mod h1:Vv36tv/GTi8FNAFIQ88+9GPHm4CAihAuJu7rfqRJ9aY=
github.com/go-gl/glfw/v3.3/glfw v0.0.0-20191125211704-12ad95a8df72/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8=
github.com/go-ini/ini v1.25.4/go.mod h1:ByCAeIL28uOIIG0E3PJtZPDL8WnHpFKFOtgjp+3Ies8=
github.com/go-kit/kit v0.8.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as=

View File

@ -47,6 +47,7 @@ import (
"sigs.k8s.io/external-dns/provider/dnsimple"
"sigs.k8s.io/external-dns/provider/dyn"
"sigs.k8s.io/external-dns/provider/exoscale"
"sigs.k8s.io/external-dns/provider/gandi"
"sigs.k8s.io/external-dns/provider/godaddy"
"sigs.k8s.io/external-dns/provider/google"
"sigs.k8s.io/external-dns/provider/hetzner"
@ -305,6 +306,8 @@ func main() {
p, err = scaleway.NewScalewayProvider(ctx, domainFilter, cfg.DryRun)
case "godaddy":
p, err = godaddy.NewGoDaddyProvider(ctx, domainFilter, cfg.GoDaddyTTL, cfg.GoDaddyAPIKey, cfg.GoDaddySecretKey, cfg.GoDaddyOTE, cfg.DryRun)
case "gandi":
p, err = gandi.NewGandiProvider(ctx, domainFilter, cfg.DryRun)
default:
log.Fatalf("unknown dns provider: %s", cfg.Provider)
}

View File

@ -362,7 +362,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, 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", "bluecat")
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, gandi)").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", "bluecat", "gandi")
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)

120
provider/gandi/client.go Normal file
View File

@ -0,0 +1,120 @@
/*
Copyright 2021 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 gandi
import (
"github.com/go-gandi/go-gandi/domain"
"github.com/go-gandi/go-gandi/livedns"
)
type DomainClientAdapter interface {
ListDomains() (domains []domain.ListResponse, err error)
}
type DomainClient struct {
Client *domain.Domain
}
func (p *DomainClient) ListDomains() (domains []domain.ListResponse, err error) {
return p.Client.ListDomains()
}
func NewDomainClient(client *domain.Domain) DomainClientAdapter {
return &DomainClient{client}
}
// StandardResponse copied from go-gandi/internal/gandi.go
type StandardResponse struct {
Code int `json:"code,omitempty"`
Message string `json:"message,omitempty"`
UUID string `json:"uuid,omitempty"`
Object string `json:"object,omitempty"`
Cause string `json:"cause,omitempty"`
Status string `json:"status,omitempty"`
Errors []StandardError `json:"errors,omitempty"`
}
// StandardError copied from go-gandi/internal/gandi.go
type StandardError struct {
Location string `json:"location"`
Name string `json:"name"`
Description string `json:"description"`
}
type LiveDNSClientAdapter interface {
GetDomainRecords(fqdn string) (records []livedns.DomainRecord, err error)
CreateDomainRecord(fqdn, name, recordtype string, ttl int, values []string) (response StandardResponse, err error)
DeleteDomainRecord(fqdn, name, recordtype string) (err error)
UpdateDomainRecordByNameAndType(fqdn, name, recordtype string, ttl int, values []string) (response StandardResponse, err error)
}
type LiveDNSClient struct {
Client *livedns.LiveDNS
}
func NewLiveDNSClient(client *livedns.LiveDNS) LiveDNSClientAdapter {
return &LiveDNSClient{client}
}
func (p *LiveDNSClient) GetDomainRecords(fqdn string) (records []livedns.DomainRecord, err error) {
return p.Client.GetDomainRecords(fqdn)
}
func (p *LiveDNSClient) CreateDomainRecord(fqdn, name, recordtype string, ttl int, values []string) (response StandardResponse, err error) {
res, err := p.Client.CreateDomainRecord(fqdn, name, recordtype, ttl, values)
if err != nil {
return StandardResponse{}, err
}
// response needs to be copied as the Standard* structs are internal
var errors []StandardError
for _, e := range res.Errors {
errors = append(errors, StandardError(e))
}
return StandardResponse{
Code: res.Code,
Message: res.Message,
UUID: res.UUID,
Object: res.Object,
Cause: res.Cause,
Status: res.Status,
Errors: errors,
}, err
}
func (p *LiveDNSClient) DeleteDomainRecord(fqdn, name, recordtype string) (err error) {
return p.Client.DeleteDomainRecord(fqdn, name, recordtype)
}
func (p *LiveDNSClient) UpdateDomainRecordByNameAndType(fqdn, name, recordtype string, ttl int, values []string) (response StandardResponse, err error) {
res, err := p.Client.UpdateDomainRecordByNameAndType(fqdn, name, recordtype, ttl, values)
if err != nil {
return StandardResponse{}, err
}
// response needs to be copied as the Standard* structs are internal
var errors []StandardError
for _, e := range res.Errors {
errors = append(errors, StandardError(e))
}
return StandardResponse{
Code: res.Code,
Message: res.Message,
UUID: res.UUID,
Object: res.Object,
Cause: res.Cause,
Status: res.Status,
Errors: errors,
}, err
}

268
provider/gandi/gandi.go Normal file
View File

@ -0,0 +1,268 @@
/*
Copyright 2021 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 gandi
import (
"context"
"errors"
"fmt"
"os"
"strings"
"github.com/go-gandi/go-gandi"
"github.com/go-gandi/go-gandi/livedns"
log "github.com/sirupsen/logrus"
"sigs.k8s.io/external-dns/endpoint"
"sigs.k8s.io/external-dns/plan"
"sigs.k8s.io/external-dns/provider"
)
const (
gandiCreate = "CREATE"
gandiDelete = "DELETE"
gandiUpdate = "UPDATE"
gandiTTL = 600
gandiLiveDNSProvider = "livedns"
)
type GandiChanges struct {
Action string
ZoneName string
Record livedns.DomainRecord
}
type GandiProvider struct {
provider.BaseProvider
LiveDNSClient LiveDNSClientAdapter
DomainClient DomainClientAdapter
domainFilter endpoint.DomainFilter
DryRun bool
}
func NewGandiProvider(ctx context.Context, domainFilter endpoint.DomainFilter, dryRun bool) (*GandiProvider, error) {
key, ok := os.LookupEnv("GANDI_KEY")
if !ok {
return nil, errors.New("no environment variable GANDI_KEY provided")
}
sharingID, _ := os.LookupEnv("GANDI_SHARING_ID")
g := gandi.Config{
SharingID: sharingID,
Debug: false,
// dry-run doesn't work but it won't hurt passing the flag
DryRun: dryRun,
}
liveDNSClient := gandi.NewLiveDNSClient(key, g)
domainClient := gandi.NewDomainClient(key, g)
gandiProvider := &GandiProvider{
LiveDNSClient: NewLiveDNSClient(liveDNSClient),
DomainClient: NewDomainClient(domainClient),
domainFilter: domainFilter,
DryRun: dryRun,
}
return gandiProvider, nil
}
func (p *GandiProvider) Zones() (zones []string, err error) {
availableDomains, err := p.DomainClient.ListDomains()
if err != nil {
return nil, err
}
zones = []string{}
for _, domain := range availableDomains {
if !p.domainFilter.Match(domain.FQDN) {
log.Debugf("Excluding domain %s by domain-filter", domain.FQDN)
continue
}
if domain.NameServer.Current != gandiLiveDNSProvider {
log.Debugf("Excluding domain %s, not configured for livedns", domain.FQDN)
continue
}
zones = append(zones, domain.FQDN)
}
return zones, nil
}
func (p *GandiProvider) Records(ctx context.Context) ([]*endpoint.Endpoint, error) {
liveDNSZones, err := p.Zones()
if err != nil {
return nil, err
}
endpoints := []*endpoint.Endpoint{}
for _, zone := range liveDNSZones {
records, err := p.LiveDNSClient.GetDomainRecords(zone)
if err != nil {
return nil, err
}
for _, r := range records {
if provider.SupportedRecordType(r.RrsetType) {
name := r.RrsetName + "." + zone
if r.RrsetName == "@" {
name = zone
}
if len(r.RrsetValues) > 1 {
return nil, fmt.Errorf("can't handle multiple values for rrset %s", name)
}
endpoints = append(endpoints, endpoint.NewEndpoint(name, r.RrsetType, r.RrsetValues[0]))
}
}
}
return endpoints, nil
}
func (p *GandiProvider) ApplyChanges(ctx context.Context, changes *plan.Changes) error {
combinedChanges := make([]*GandiChanges, 0, len(changes.Create)+len(changes.UpdateNew)+len(changes.Delete))
combinedChanges = append(combinedChanges, p.newGandiChanges(gandiCreate, changes.Create)...)
combinedChanges = append(combinedChanges, p.newGandiChanges(gandiUpdate, changes.UpdateNew)...)
combinedChanges = append(combinedChanges, p.newGandiChanges(gandiDelete, changes.Delete)...)
return p.submitChanges(ctx, combinedChanges)
}
func (p *GandiProvider) submitChanges(ctx context.Context, changes []*GandiChanges) error {
if len(changes) == 0 {
log.Infof("All records are already up to date")
return nil
}
liveDNSDomains, err := p.Zones()
if err != nil {
return err
}
zoneChanges := p.groupAndFilterByZone(liveDNSDomains, changes)
for _, changes := range zoneChanges {
for _, change := range changes {
// Prepare record name
recordName := strings.TrimSuffix(change.Record.RrsetName, "."+change.ZoneName)
if recordName == change.ZoneName {
recordName = "@"
}
if change.Record.RrsetType == endpoint.RecordTypeCNAME && !strings.HasSuffix(change.Record.RrsetValues[0], ".") {
change.Record.RrsetValues[0] += "."
}
change.Record.RrsetName = recordName
log.WithFields(log.Fields{
"record": change.Record.RrsetName,
"type": change.Record.RrsetType,
"value": change.Record.RrsetValues[0],
"ttl": change.Record.RrsetTTL,
"action": change.Action,
"zone": change.ZoneName,
}).Info("Changing record")
if !p.DryRun {
switch change.Action {
case gandiCreate:
answer, err := p.LiveDNSClient.CreateDomainRecord(
change.ZoneName,
change.Record.RrsetName,
change.Record.RrsetType,
change.Record.RrsetTTL,
change.Record.RrsetValues,
)
if err != nil {
log.WithFields(log.Fields{
"Code": answer.Code,
"Message": answer.Message,
"Cause": answer.Cause,
"Errors": answer.Errors,
}).Warning("Create problem")
return err
}
case gandiDelete:
err := p.LiveDNSClient.DeleteDomainRecord(change.ZoneName, change.Record.RrsetName, change.Record.RrsetType)
if err != nil {
log.Warning("Delete problem")
return err
}
case gandiUpdate:
answer, err := p.LiveDNSClient.UpdateDomainRecordByNameAndType(
change.ZoneName,
change.Record.RrsetName,
change.Record.RrsetType,
change.Record.RrsetTTL,
change.Record.RrsetValues,
)
if err != nil {
log.WithFields(log.Fields{
"Code": answer.Code,
"Message": answer.Message,
"Cause": answer.Cause,
"Errors": answer.Errors,
}).Warning("Update problem")
return err
}
}
}
}
}
return nil
}
func (p *GandiProvider) newGandiChanges(action string, endpoints []*endpoint.Endpoint) []*GandiChanges {
changes := make([]*GandiChanges, 0, len(endpoints))
ttl := gandiTTL
for _, e := range endpoints {
if e.RecordTTL.IsConfigured() {
ttl = int(e.RecordTTL)
}
change := &GandiChanges{
Action: action,
Record: livedns.DomainRecord{
RrsetType: e.RecordType,
RrsetName: e.DNSName,
RrsetValues: e.Targets,
RrsetTTL: ttl,
},
}
changes = append(changes, change)
}
return changes
}
func (p *GandiProvider) groupAndFilterByZone(zones []string, changes []*GandiChanges) map[string][]*GandiChanges {
change := make(map[string][]*GandiChanges)
zoneNameID := provider.ZoneIDName{}
for _, z := range zones {
zoneNameID.Add(z, z)
change[z] = []*GandiChanges{}
}
for _, c := range changes {
zoneID, zoneName := zoneNameID.FindZone(c.Record.RrsetName)
if zoneName == "" {
log.Debugf("Skipping record %s because no hosted domain matching record DNS Name was detected", c.Record.RrsetName)
continue
}
c.ZoneName = zoneName
change[zoneID] = append(change[zoneID], c)
}
return change
}

View File

@ -0,0 +1,755 @@
/*
Copyright 2021 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 gandi
import (
"context"
"fmt"
"github.com/go-gandi/go-gandi/domain"
"github.com/go-gandi/go-gandi/livedns"
"github.com/maxatome/go-testdeep/td"
"strings"
"os"
"reflect"
"testing"
"github.com/stretchr/testify/assert"
"sigs.k8s.io/external-dns/endpoint"
"sigs.k8s.io/external-dns/plan"
)
type MockAction struct {
Name string
FQDN string
Record livedns.DomainRecord
}
type mockGandiClient struct {
Actions []MockAction
FunctionToFail string
RecordsToReturn []livedns.DomainRecord
}
func mockGandiClientNew() *mockGandiClient {
return &mockGandiClient{
RecordsToReturn: testRecords(),
}
}
func mockGandiClientNewWithRecords(recordsToReturn []livedns.DomainRecord) *mockGandiClient {
return &mockGandiClient{
RecordsToReturn: recordsToReturn,
}
}
func mockGandiClientNewWithFailure(functionToFail string) *mockGandiClient {
return &mockGandiClient{
FunctionToFail: functionToFail,
RecordsToReturn: testRecords(),
}
}
func testRecords() []livedns.DomainRecord {
return []livedns.DomainRecord{
{
RrsetType: endpoint.RecordTypeCNAME,
RrsetTTL: 600,
RrsetName: "@",
RrsetHref: "https://api.gandi.net/v5/domain/domains/example.com/records/%40/A",
RrsetValues: []string{"192.168.0.1"},
},
{
RrsetType: endpoint.RecordTypeCNAME,
RrsetTTL: 600,
RrsetName: "www",
RrsetHref: "https://api.gandi.net/v5/domain/domains/example.com/records/www/CNAME",
RrsetValues: []string{"lb.example.com"},
},
{
RrsetType: endpoint.RecordTypeA,
RrsetTTL: 600,
RrsetName: "test",
RrsetHref: "https://api.gandi.net/v5/domain/domains/example.com/records/test/A",
RrsetValues: []string{"192.168.0.2"},
},
}
}
// Mock all methods
func (m *mockGandiClient) GetDomainRecords(fqdn string) (records []livedns.DomainRecord, err error) {
m.Actions = append(m.Actions, MockAction{
Name: "GetDomainRecords",
FQDN: fqdn,
})
if m.FunctionToFail == "GetDomainRecords" {
return nil, fmt.Errorf("injected error")
}
return m.RecordsToReturn, err
}
func (m *mockGandiClient) CreateDomainRecord(fqdn, name, recordtype string, ttl int, values []string) (response StandardResponse, err error) {
m.Actions = append(m.Actions, MockAction{
Name: "CreateDomainRecord",
FQDN: fqdn,
Record: livedns.DomainRecord{
RrsetType: recordtype,
RrsetTTL: ttl,
RrsetName: name,
RrsetValues: values,
},
})
if m.FunctionToFail == "CreateDomainRecord" {
return StandardResponse{}, fmt.Errorf("injected error")
}
return StandardResponse{}, nil
}
func (m *mockGandiClient) DeleteDomainRecord(fqdn, name, recordtype string) (err error) {
m.Actions = append(m.Actions, MockAction{
Name: "DeleteDomainRecord",
FQDN: fqdn,
Record: livedns.DomainRecord{
RrsetType: recordtype,
RrsetName: name,
},
})
if m.FunctionToFail == "DeleteDomainRecord" {
return fmt.Errorf("injected error")
}
return nil
}
func (m *mockGandiClient) UpdateDomainRecordByNameAndType(fqdn, name, recordtype string, ttl int, values []string) (response StandardResponse, err error) {
m.Actions = append(m.Actions, MockAction{
Name: "UpdateDomainRecordByNameAndType",
FQDN: fqdn,
Record: livedns.DomainRecord{
RrsetType: recordtype,
RrsetTTL: ttl,
RrsetName: name,
RrsetValues: values,
},
})
if m.FunctionToFail == "UpdateDomainRecordByNameAndType" {
return StandardResponse{}, fmt.Errorf("injected error")
}
return StandardResponse{}, nil
}
func (m *mockGandiClient) ListDomains() (domains []domain.ListResponse, err error) {
m.Actions = append(m.Actions, MockAction{
Name: "ListDomains",
})
if m.FunctionToFail == "ListDomains" {
return []domain.ListResponse{}, fmt.Errorf("injected error")
}
return []domain.ListResponse{
{
FQDN: "example.com",
FQDNUnicode: "example.com",
Href: "https://api.gandi.net/v5/domain/domains/example.com",
ID: "b3e9c271-1c29-4441-97d9-bc021a7ac7c3",
NameServer: &domain.NameServerConfig{
Current: gandiLiveDNSProvider,
},
TLD: "com",
},
{
FQDN: "example.net",
FQDNUnicode: "example.net",
Href: "https://api.gandi.net/v5/domain/domains/example.net",
ID: "dc78c1d8-6143-4edb-93bc-3a20d8bc3570",
NameServer: &domain.NameServerConfig{
Current: "other",
},
TLD: "net",
},
}, nil
}
// Tests
func TestNewGandiProvider(t *testing.T) {
_ = os.Setenv("GANDI_KEY", "myGandiKey")
provider, err := NewGandiProvider(context.Background(), endpoint.NewDomainFilter([]string{"example.com"}), true)
if err != nil {
t.Errorf("failed : %s", err)
}
assert.Equal(t, true, provider.DryRun)
_ = os.Setenv("GANDI_SHARING_ID", "aSharingId")
provider, err = NewGandiProvider(context.Background(), endpoint.NewDomainFilter([]string{"example.com"}), false)
if err != nil {
t.Errorf("failed : %s", err)
}
assert.Equal(t, false, provider.DryRun)
_ = os.Unsetenv("GANDI_KEY")
_, err = NewGandiProvider(context.Background(), endpoint.NewDomainFilter([]string{"example.com"}), true)
if err == nil {
t.Errorf("expected to fail")
}
}
func TestGandiProvider_TestData(t *testing.T) {
mockedClient := mockGandiClientNew()
// Check test zone data is ok
expectedZonesAnswer := []domain.ListResponse{
{
FQDN: "example.com",
FQDNUnicode: "example.com",
Href: "https://api.gandi.net/v5/domain/domains/example.com",
ID: "b3e9c271-1c29-4441-97d9-bc021a7ac7c3",
NameServer: &domain.NameServerConfig{
Current: gandiLiveDNSProvider,
},
TLD: "com",
},
{
FQDN: "example.net",
FQDNUnicode: "example.net",
Href: "https://api.gandi.net/v5/domain/domains/example.net",
ID: "dc78c1d8-6143-4edb-93bc-3a20d8bc3570",
NameServer: &domain.NameServerConfig{
Current: "other",
},
TLD: "net",
},
}
testingZonesAnswer, err := mockedClient.ListDomains()
if err != nil {
t.Errorf("should not fail, %s", err)
}
if !reflect.DeepEqual(expectedZonesAnswer, testingZonesAnswer) {
t.Errorf("should be equal, %s", err)
}
// Check test record data is ok
expectedRecordsAnswer := []livedns.DomainRecord{
{
RrsetType: endpoint.RecordTypeCNAME,
RrsetTTL: 600,
RrsetName: "@",
RrsetHref: "https://api.gandi.net/v5/domain/domains/example.com/records/%40/A",
RrsetValues: []string{"192.168.0.1"},
},
{
RrsetType: endpoint.RecordTypeCNAME,
RrsetTTL: 600,
RrsetName: "www",
RrsetHref: "https://api.gandi.net/v5/domain/domains/example.com/records/www/CNAME",
RrsetValues: []string{"lb.example.com"},
},
{
RrsetType: endpoint.RecordTypeA,
RrsetTTL: 600,
RrsetName: "test",
RrsetHref: "https://api.gandi.net/v5/domain/domains/example.com/records/test/A",
RrsetValues: []string{"192.168.0.2"},
},
}
testingRecordsAnswer, err := mockedClient.GetDomainRecords("example.com")
if err != nil {
t.Errorf("should not fail, %s", err)
}
if !reflect.DeepEqual(expectedRecordsAnswer, testingRecordsAnswer) {
t.Errorf("should be equal, %s", err)
}
}
func TestGandiProvider_Records(t *testing.T) {
mockedClient := mockGandiClientNew()
mockedProvider := &GandiProvider{
DomainClient: mockedClient,
LiveDNSClient: mockedClient,
}
expectedActions := []MockAction{
{
Name: "ListDomains",
},
{
Name: "GetDomainRecords",
FQDN: "example.com",
},
}
endpoints, err := mockedProvider.Records(context.Background())
if err != nil {
t.Errorf("should not fail, %s", err)
}
assert.Equal(t, 3, len(endpoints))
fmt.Printf("%+v\n", endpoints[0].DNSName)
assert.Equal(t, "example.com", endpoints[0].DNSName)
assert.Equal(t, endpoint.RecordTypeCNAME, endpoints[0].RecordType)
td.Cmp(t, expectedActions, mockedClient.Actions)
}
func TestGandiProvider_RecordsAppliesDomainFilter(t *testing.T) {
mockedClient := mockGandiClientNew()
mockedProvider := &GandiProvider{
DomainClient: mockedClient,
LiveDNSClient: mockedClient,
domainFilter: endpoint.NewDomainFilterWithExclusions([]string{}, []string{"example.com"}),
}
expectedActions := []MockAction{
{
Name: "ListDomains",
},
}
endpoints, err := mockedProvider.Records(context.Background())
if err != nil {
t.Errorf("should not fail, %s", err)
}
assert.Equal(t, 0, len(endpoints))
td.Cmp(t, expectedActions, mockedClient.Actions)
}
func TestGandiProvider_RecordsErrorOnMultipleValues(t *testing.T) {
mockedClient := mockGandiClientNewWithRecords([]livedns.DomainRecord{
{
RrsetValues: []string{"foo", "bar"},
RrsetType: endpoint.RecordTypeCNAME,
},
})
mockedProvider := &GandiProvider{
DomainClient: mockedClient,
LiveDNSClient: mockedClient,
}
expectedActions := []MockAction{
{
Name: "ListDomains",
},
{
Name: "GetDomainRecords",
FQDN: "example.com",
},
}
endpoints, err := mockedProvider.Records(context.Background())
if err == nil {
t.Errorf("expected to fail")
}
assert.Equal(t, 0, len(endpoints))
assert.True(t, strings.HasPrefix(err.Error(), "can't handle multiple values for rrset"))
td.Cmp(t, expectedActions, mockedClient.Actions)
}
func TestGandiProvider_ApplyChangesEmpty(t *testing.T) {
changes := &plan.Changes{}
mockedClient := mockGandiClientNew()
mockedProvider := &GandiProvider{
DomainClient: mockedClient,
LiveDNSClient: mockedClient,
}
err := mockedProvider.ApplyChanges(context.Background(), changes)
if err != nil {
t.Errorf("should not fail, %s", err)
}
if mockedClient.Actions != nil {
t.Error("expected no changes")
}
}
func TestGandiProvider_ApplyChanges(t *testing.T) {
changes := &plan.Changes{}
mockedClient := mockGandiClientNew()
mockedProvider := &GandiProvider{
DomainClient: mockedClient,
LiveDNSClient: mockedClient,
}
changes.Create = []*endpoint.Endpoint{{DNSName: "test2.example.com", Targets: endpoint.Targets{"target"}, RecordType: "A", RecordTTL: 666}}
changes.UpdateNew = []*endpoint.Endpoint{{DNSName: "test3.example.com", Targets: endpoint.Targets{"target-new"}, RecordType: "A", RecordTTL: 777}}
changes.Delete = []*endpoint.Endpoint{{DNSName: "test4.example.com", Targets: endpoint.Targets{"target-other"}, RecordType: "A"}}
err := mockedProvider.ApplyChanges(context.Background(), changes)
if err != nil {
t.Errorf("should not fail, %s", err)
}
td.Cmp(t, mockedClient.Actions, []MockAction{
{
Name: "ListDomains",
},
{
Name: "CreateDomainRecord",
FQDN: "example.com",
Record: livedns.DomainRecord{
RrsetType: endpoint.RecordTypeA,
RrsetName: "test2",
RrsetValues: []string{"target"},
RrsetTTL: 666,
},
},
{
Name: "UpdateDomainRecordByNameAndType",
FQDN: "example.com",
Record: livedns.DomainRecord{
RrsetType: endpoint.RecordTypeA,
RrsetName: "test3",
RrsetValues: []string{"target-new"},
RrsetTTL: 777,
},
},
{
Name: "DeleteDomainRecord",
FQDN: "example.com",
Record: livedns.DomainRecord{
RrsetType: endpoint.RecordTypeA,
RrsetName: "test4",
},
},
})
}
func TestGandiProvider_ApplyChangesSkipsNonManaged(t *testing.T) {
changes := &plan.Changes{}
mockedClient := mockGandiClientNew()
mockedProvider := &GandiProvider{
DomainClient: mockedClient,
LiveDNSClient: mockedClient,
}
changes.Create = []*endpoint.Endpoint{{DNSName: "example.net", Targets: endpoint.Targets{"target"}}}
changes.UpdateNew = []*endpoint.Endpoint{{DNSName: "test.example.net", Targets: endpoint.Targets{"target-new"}, RecordType: "A", RecordTTL: 777}}
changes.Delete = []*endpoint.Endpoint{{DNSName: "test2.example.net", Targets: endpoint.Targets{"target"}, RecordType: "A"}}
err := mockedProvider.ApplyChanges(context.Background(), changes)
if err != nil {
t.Errorf("should not fail, %s", err)
}
td.Cmp(t, mockedClient.Actions, []MockAction{
{
Name: "ListDomains",
},
})
}
func TestGandiProvider_ApplyChangesCreateUpdateCname(t *testing.T) {
changes := &plan.Changes{}
mockedClient := mockGandiClientNew()
mockedProvider := &GandiProvider{
DomainClient: mockedClient,
LiveDNSClient: mockedClient,
}
changes.Create = []*endpoint.Endpoint{
{DNSName: "test-cname.example.com", Targets: endpoint.Targets{"target"}, RecordTTL: 666, RecordType: "CNAME"},
}
changes.UpdateNew = []*endpoint.Endpoint{{DNSName: "test-cname2.example.com", Targets: endpoint.Targets{"target-new"}, RecordType: "CNAME", RecordTTL: 777}}
err := mockedProvider.ApplyChanges(context.Background(), changes)
if err != nil {
t.Errorf("should not fail, %s", err)
}
td.Cmp(t, mockedClient.Actions, []MockAction{
{
Name: "ListDomains",
},
{
Name: "CreateDomainRecord",
FQDN: "example.com",
Record: livedns.DomainRecord{
RrsetType: endpoint.RecordTypeCNAME,
RrsetName: "test-cname",
RrsetValues: []string{"target."},
RrsetTTL: 666,
},
},
{
Name: "UpdateDomainRecordByNameAndType",
FQDN: "example.com",
Record: livedns.DomainRecord{
RrsetType: endpoint.RecordTypeCNAME,
RrsetName: "test-cname2",
RrsetValues: []string{"target-new."},
RrsetTTL: 777,
},
},
})
}
func TestGandiProvider_ApplyChangesCreateEmpty(t *testing.T) {
changes := &plan.Changes{}
mockedClient := mockGandiClientNew()
mockedProvider := &GandiProvider{
DomainClient: mockedClient,
LiveDNSClient: mockedClient,
}
changes.Create = []*endpoint.Endpoint{
{DNSName: "example.com", Targets: endpoint.Targets{"target"}, RecordTTL: 666, RecordType: "A"},
}
changes.UpdateNew = []*endpoint.Endpoint{}
err := mockedProvider.ApplyChanges(context.Background(), changes)
if err != nil {
t.Errorf("should not fail, %s", err)
}
td.Cmp(t, mockedClient.Actions, []MockAction{
{
Name: "ListDomains",
},
{
Name: "CreateDomainRecord",
FQDN: "example.com",
Record: livedns.DomainRecord{
RrsetType: endpoint.RecordTypeA,
RrsetName: "@",
RrsetValues: []string{"target"},
RrsetTTL: 666,
},
},
})
}
func TestGandiProvider_ApplyChangesRespectsDryRun(t *testing.T) {
changes := &plan.Changes{}
mockedClient := mockGandiClientNew()
mockedProvider := &GandiProvider{
DomainClient: mockedClient,
LiveDNSClient: mockedClient,
DryRun: true,
}
changes.Create = []*endpoint.Endpoint{
{DNSName: "foo.example.com", Targets: endpoint.Targets{"target"}, RecordTTL: 666, RecordType: "A"},
}
changes.UpdateNew = []*endpoint.Endpoint{
{DNSName: "bar.example.com", Targets: endpoint.Targets{"target"}, RecordTTL: 666, RecordType: "A"},
}
changes.Delete = []*endpoint.Endpoint{
{DNSName: "baz.example.com", Targets: endpoint.Targets{"target"}, RecordTTL: 666, RecordType: "A"},
}
err := mockedProvider.ApplyChanges(context.Background(), changes)
if err != nil {
t.Errorf("should not fail, %s", err)
}
td.Cmp(t, mockedClient.Actions, []MockAction{
{
Name: "ListDomains",
},
})
}
func TestGandiProvider_ApplyChangesErrorListDomains(t *testing.T) {
changes := &plan.Changes{}
mockedClient := mockGandiClientNewWithFailure("ListDomains")
mockedProvider := &GandiProvider{
DomainClient: mockedClient,
LiveDNSClient: mockedClient,
}
changes.Create = []*endpoint.Endpoint{
{DNSName: "foo.example.com", Targets: endpoint.Targets{"target"}, RecordTTL: 666, RecordType: "A"},
}
changes.UpdateNew = []*endpoint.Endpoint{
{DNSName: "bar.example.com", Targets: endpoint.Targets{"target"}, RecordTTL: 666, RecordType: "A"},
}
changes.Delete = []*endpoint.Endpoint{
{DNSName: "baz.example.com", Targets: endpoint.Targets{"target"}, RecordTTL: 666, RecordType: "A"},
}
err := mockedProvider.ApplyChanges(context.Background(), changes)
if err == nil {
t.Error("should have failed")
}
td.Cmp(t, mockedClient.Actions, []MockAction{
{
Name: "ListDomains",
},
})
}
func TestGandiProvider_ApplyChangesErrorCreate(t *testing.T) {
changes := &plan.Changes{}
mockedClient := mockGandiClientNewWithFailure("CreateDomainRecord")
mockedProvider := &GandiProvider{
DomainClient: mockedClient,
LiveDNSClient: mockedClient,
}
changes.Create = []*endpoint.Endpoint{
{DNSName: "foo.example.com", Targets: endpoint.Targets{"target"}, RecordTTL: 666, RecordType: "A"},
}
changes.UpdateNew = []*endpoint.Endpoint{
{DNSName: "bar.example.com", Targets: endpoint.Targets{"target"}, RecordTTL: 666, RecordType: "A"},
}
changes.Delete = []*endpoint.Endpoint{
{DNSName: "baz.example.com", Targets: endpoint.Targets{"target"}, RecordTTL: 666, RecordType: "A"},
}
err := mockedProvider.ApplyChanges(context.Background(), changes)
if err == nil {
t.Error("should have failed")
}
td.Cmp(t, mockedClient.Actions, []MockAction{
{
Name: "ListDomains",
},
{
Name: "CreateDomainRecord",
FQDN: "example.com",
Record: livedns.DomainRecord{
RrsetType: endpoint.RecordTypeA,
RrsetName: "foo",
RrsetValues: []string{"target"},
RrsetTTL: 666,
},
},
})
}
func TestGandiProvider_ApplyChangesErrorUpdate(t *testing.T) {
changes := &plan.Changes{}
mockedClient := mockGandiClientNewWithFailure("UpdateDomainRecordByNameAndType")
mockedProvider := &GandiProvider{
DomainClient: mockedClient,
LiveDNSClient: mockedClient,
}
changes.Create = []*endpoint.Endpoint{
{DNSName: "foo.example.com", Targets: endpoint.Targets{"target"}, RecordTTL: 666, RecordType: "A"},
}
changes.UpdateNew = []*endpoint.Endpoint{
{DNSName: "bar.example.com", Targets: endpoint.Targets{"target"}, RecordTTL: 666, RecordType: "A"},
}
changes.Delete = []*endpoint.Endpoint{
{DNSName: "baz.example.com", Targets: endpoint.Targets{"target"}, RecordTTL: 666, RecordType: "A"},
}
err := mockedProvider.ApplyChanges(context.Background(), changes)
if err == nil {
t.Error("should have failed")
}
td.Cmp(t, mockedClient.Actions, []MockAction{
{
Name: "ListDomains",
},
{
Name: "CreateDomainRecord",
FQDN: "example.com",
Record: livedns.DomainRecord{
RrsetType: endpoint.RecordTypeA,
RrsetName: "foo",
RrsetValues: []string{"target"},
RrsetTTL: 666,
},
},
{
Name: "UpdateDomainRecordByNameAndType",
FQDN: "example.com",
Record: livedns.DomainRecord{
RrsetType: endpoint.RecordTypeA,
RrsetName: "bar",
RrsetValues: []string{"target"},
RrsetTTL: 666,
},
},
})
}
func TestGandiProvider_ApplyChangesErrorDelete(t *testing.T) {
changes := &plan.Changes{}
mockedClient := mockGandiClientNewWithFailure("DeleteDomainRecord")
mockedProvider := &GandiProvider{
DomainClient: mockedClient,
LiveDNSClient: mockedClient,
}
changes.Create = []*endpoint.Endpoint{
{DNSName: "foo.example.com", Targets: endpoint.Targets{"target"}, RecordTTL: 666, RecordType: "A"},
}
changes.UpdateNew = []*endpoint.Endpoint{
{DNSName: "bar.example.com", Targets: endpoint.Targets{"target"}, RecordTTL: 666, RecordType: "A"},
}
changes.Delete = []*endpoint.Endpoint{
{DNSName: "baz.example.com", Targets: endpoint.Targets{"target"}, RecordTTL: 666, RecordType: "A"},
}
err := mockedProvider.ApplyChanges(context.Background(), changes)
if err == nil {
t.Error("should have failed")
}
td.Cmp(t, mockedClient.Actions, []MockAction{
{
Name: "ListDomains",
},
{
Name: "CreateDomainRecord",
FQDN: "example.com",
Record: livedns.DomainRecord{
RrsetType: endpoint.RecordTypeA,
RrsetName: "foo",
RrsetValues: []string{"target"},
RrsetTTL: 666,
},
},
{
Name: "UpdateDomainRecordByNameAndType",
FQDN: "example.com",
Record: livedns.DomainRecord{
RrsetType: endpoint.RecordTypeA,
RrsetName: "bar",
RrsetValues: []string{"target"},
RrsetTTL: 666,
},
},
{
Name: "DeleteDomainRecord",
FQDN: "example.com",
Record: livedns.DomainRecord{
RrsetType: endpoint.RecordTypeA,
RrsetName: "baz",
},
},
})
}