Repository: kubernetes-sigs/external-dns (language: go)

Issue:
Feature: support for Gandi DNS
Hi,

It will be great for my use case that external-dns is compatible with Gandi registrar.

Gandi an offering an API called LiveDNS: https://doc.livedns.gandi.net
There is also an old API not i'm not sure if it will be maintained for a long time. 

Dnscontrol project, which is also written in Go, as a provider for it: https://github.com/StackExchange/dnscontrol/tree/master/providers/gandi

They are using that Go library: https://github.com/prasmussen/gandi-api/


Discussion / hints:
Hi, 
we happily accept pull requests with new provides. Please follow the instructions in https://github.com/kubernetes-incubator/external-dns/blob/master/CONTRIBUTING.md for information on how to contribute.
Hi there :wave: I am new to the k8s ecosystem and would like to take a crack at this
Please feel free to submit a PR and reference it here.
Issues go stale after 90d of inactivity.
Mark the issue as fresh with `/remove-lifecycle stale`.
Stale issues rot after an additional 30d of inactivity and eventually close.

If this issue is safe to close now please do so with `/close`.

Send feedback to sig-testing, kubernetes/test-infra and/or [fejta](https://github.com/fejta).
/lifecycle stale
Stale issues rot after 30d of inactivity.
Mark the issue as fresh with `/remove-lifecycle rotten`.
Rotten issues close after an additional 30d of inactivity.

If this issue is safe to close now please do so with `/close`.

Send feedback to sig-testing, kubernetes/test-infra and/or [fejta](https://github.com/fejta).
/lifecycle rotten
Rotten issues close after 30d of inactivity.
Reopen the issue with `/reopen`.
Mark the issue as fresh with `/remove-lifecycle rotten`.

Send feedback to sig-testing, kubernetes/test-infra and/or [fejta](https://github.com/fejta).
/close
@fejta-bot: Closing this issue.

<details>

In response to [this](https://github.com/kubernetes-incubator/external-dns/issues/789#issuecomment-507261903):

>Rotten issues close after 30d of inactivity.
>Reopen the issue with `/reopen`.
>Mark the issue as fresh with `/remove-lifecycle rotten`.
>
>Send feedback to sig-testing, kubernetes/test-infra and/or [fejta](https://github.com/fejta).
>/close


Instructions for interacting with me using PR comments are available [here](https://git.k8s.io/community/contributors/guide/pull-requests.md).  If you have questions or suggestions related to my behavior, please file an issue against the [kubernetes/test-infra](https://github.com/kubernetes/test-infra/issues/new?title=Prow%20issue:) repository.
</details>
Some things have changed. There is now the Gandi v5 API, which has a Go client at [go-gandi/go-gandi](https://github.com/go-gandi/go-gandi). The project linked by the OP now has the Gandi provider at https://github.com/StackExchange/dnscontrol/tree/master/providers/gandiv5.

/reopen
@nebulon42: You can't reopen an issue/PR unless you authored it or you are a collaborator.

<details>

In response to [this](https://github.com/kubernetes-sigs/external-dns/issues/789#issuecomment-795634487):

>Some things have changed. There is now the Gandi v5 API, which has a Go client at [go-gandi/go-gandi](https://github.com/go-gandi/go-gandi). The project linked by the OP now has the Gandi provider at https://github.com/StackExchange/dnscontrol/tree/master/providers/gandiv5.
>
>/reopen


Instructions for interacting with me using PR comments are available [here](https://git.k8s.io/community/contributors/guide/pull-requests.md).  If you have questions or suggestions related to my behavior, please file an issue against the [kubernetes/test-infra](https://github.com/kubernetes/test-infra/issues/new?title=Prow%20issue:) repository.
</details>

Fix patch:
diff --git a/README.md b/README.md
index 0155aa3352..6596cf9ca7 100644
--- a/README.md
+++ b/README.md
@@ -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
 
diff --git a/docs/tutorials/gandi.md b/docs/tutorials/gandi.md
new file mode 100644
index 0000000000..c37d464fd0
--- /dev/null
+++ b/docs/tutorials/gandi.md
@@ -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.
diff --git a/go.mod b/go.mod
index 28644e9156..49da323b96 100644
--- a/go.mod
+++ b/go.mod
@@ -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
diff --git a/go.sum b/go.sum
index 8a5a6e67b1..960227ef87 100644
--- a/go.sum
+++ b/go.sum
@@ -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=
diff --git a/main.go b/main.go
index 571772b792..5d52110a9c 100644
--- a/main.go
+++ b/main.go
@@ -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)
 	}
diff --git a/pkg/apis/externaldns/types.go b/pkg/apis/externaldns/types.go
index 3420826254..b8f29cda50 100644
--- a/pkg/apis/externaldns/types.go
+++ b/pkg/apis/externaldns/types.go
@@ -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)
diff --git a/provider/gandi/client.go b/provider/gandi/client.go
new file mode 100644
index 0000000000..a5e55334f0
--- /dev/null
+++ b/provider/gandi/client.go
@@ -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
+}
diff --git a/provider/gandi/gandi.go b/provider/gandi/gandi.go
new file mode 100644
index 0000000000..d239238cc4
--- /dev/null
+++ b/provider/gandi/gandi.go
@@ -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
+}


Test patch:
diff --git a/provider/gandi/gandi_test.go b/provider/gandi/gandi_test.go
new file mode 100644
index 0000000000..64be771162
--- /dev/null
+++ b/provider/gandi/gandi_test.go
@@ -0,0 +1,759 @@
+/*
+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(),
+	}
+}
+
+const domainUriPrefix = "https://api.gandi.net/v5/domain/domains/"
+const exampleDotComUri = domainUriPrefix + "example.com"
+const exampleDotNetUri = domainUriPrefix + "example.net"
+
+func testRecords() []livedns.DomainRecord {
+	return []livedns.DomainRecord{
+		{
+			RrsetType:   endpoint.RecordTypeCNAME,
+			RrsetTTL:    600,
+			RrsetName:   "@",
+			RrsetHref:   exampleDotComUri + "/records/%40/A",
+			RrsetValues: []string{"192.168.0.1"},
+		},
+		{
+			RrsetType:   endpoint.RecordTypeCNAME,
+			RrsetTTL:    600,
+			RrsetName:   "www",
+			RrsetHref:   exampleDotComUri + "/records/www/CNAME",
+			RrsetValues: []string{"lb.example.com"},
+		},
+		{
+			RrsetType:   endpoint.RecordTypeA,
+			RrsetTTL:    600,
+			RrsetName:   "test",
+			RrsetHref:   exampleDotComUri + "/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:        exampleDotComUri,
+			ID:          "b3e9c271-1c29-4441-97d9-bc021a7ac7c3",
+			NameServer: &domain.NameServerConfig{
+				Current: gandiLiveDNSProvider,
+			},
+			TLD: "com",
+		},
+		{
+			FQDN:        "example.net",
+			FQDNUnicode: "example.net",
+			Href:        exampleDotNetUri,
+			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:        exampleDotComUri,
+			ID:          "b3e9c271-1c29-4441-97d9-bc021a7ac7c3",
+			NameServer: &domain.NameServerConfig{
+				Current: gandiLiveDNSProvider,
+			},
+			TLD: "com",
+		},
+		{
+			FQDN:        "example.net",
+			FQDNUnicode: "example.net",
+			Href:        exampleDotNetUri,
+			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:   exampleDotComUri + "/records/%40/A",
+			RrsetValues: []string{"192.168.0.1"},
+		},
+		{
+			RrsetType:   endpoint.RecordTypeCNAME,
+			RrsetTTL:    600,
+			RrsetName:   "www",
+			RrsetHref:   exampleDotComUri + "/records/www/CNAME",
+			RrsetValues: []string{"lb.example.com"},
+		},
+		{
+			RrsetType:   endpoint.RecordTypeA,
+			RrsetTTL:    600,
+			RrsetName:   "test",
+			RrsetHref:   exampleDotComUri + "/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",
+			},
+		},
+	})
+}