feat(ovh): major rewriting of the provider (#5143)

* feat: ovh: improve cache invalidation on errors + dry-run mode + relative CNAME handling + optimization

Signed-off-by: Romain Beuque <556072+rbeuque74@users.noreply.github.com>

* chore: add more tests

Signed-off-by: Romain Beuque <556072+rbeuque74@users.noreply.github.com>

* fix: align cache expiration with Default value

* chore: address comments from review + updated documentation

* chore: address comments from review

---------

Signed-off-by: Romain Beuque <556072+rbeuque74@users.noreply.github.com>
This commit is contained in:
Romain Beuque 2025-03-17 15:53:49 +01:00 committed by GitHub
parent 823ea7e6f1
commit ecd57c86f5
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 707 additions and 261 deletions

View File

@ -57,7 +57,7 @@ ExternalDNS allows you to keep selected zones (via `--domain-filter`) synchroniz
- [RFC2136](https://tools.ietf.org/html/rfc2136) - [RFC2136](https://tools.ietf.org/html/rfc2136)
- [NS1](https://ns1.com/) - [NS1](https://ns1.com/)
- [TransIP](https://www.transip.eu/domain-name/) - [TransIP](https://www.transip.eu/domain-name/)
- [OVH](https://www.ovh.com) - [OVHcloud](https://www.ovhcloud.com)
- [Scaleway](https://www.scaleway.com) - [Scaleway](https://www.scaleway.com)
- [Akamai Edge DNS](https://learn.akamai.com/en-us/products/cloud_security/edge_dns.html) - [Akamai Edge DNS](https://learn.akamai.com/en-us/products/cloud_security/edge_dns.html)
- [GoDaddy](https://www.godaddy.com) - [GoDaddy](https://www.godaddy.com)
@ -85,7 +85,7 @@ See PR #3063 for all the discussions about it.
Known providers using webhooks: Known providers using webhooks:
| Provider | Repo | | Provider | Repo |
|-----------------------|----------------------------------------------------------------------| | --------------------- | -------------------------------------------------------------------- |
| Abion | https://github.com/abiondevelopment/external-dns-webhook-abion | | Abion | https://github.com/abiondevelopment/external-dns-webhook-abion |
| Adguard Home Provider | https://github.com/muhlba91/external-dns-provider-adguard | | Adguard Home Provider | https://github.com/muhlba91/external-dns-provider-adguard |
| Anexia | https://github.com/ProbstenHias/external-dns-anexia-webhook | | Anexia | https://github.com/ProbstenHias/external-dns-anexia-webhook |
@ -145,7 +145,7 @@ The following table clarifies the current status of the providers according to t
| RFC2136 | Alpha | | | RFC2136 | Alpha | |
| NS1 | Alpha | | | NS1 | Alpha | |
| TransIP | Alpha | | | TransIP | Alpha | |
| OVH | Alpha | | | OVHcloud | Beta | @rbeuque74 |
| Scaleway DNS | Alpha | @Sh4d1 | | Scaleway DNS | Alpha | @Sh4d1 |
| UltraDNS | Alpha | | | UltraDNS | Alpha | |
| GoDaddy | Alpha | | | GoDaddy | Alpha | |
@ -207,7 +207,7 @@ The following tutorials are provided:
- [PowerDNS](docs/tutorials/pdns.md) - [PowerDNS](docs/tutorials/pdns.md)
- [RFC2136](docs/tutorials/rfc2136.md) - [RFC2136](docs/tutorials/rfc2136.md)
- [TransIP](docs/tutorials/transip.md) - [TransIP](docs/tutorials/transip.md)
- [OVH](docs/tutorials/ovh.md) - [OVHcloud](docs/tutorials/ovh.md)
- [Scaleway](docs/tutorials/scaleway.md) - [Scaleway](docs/tutorials/scaleway.md)
- [UltraDNS](docs/tutorials/ultradns.md) - [UltraDNS](docs/tutorials/ultradns.md)
- [GoDaddy](docs/tutorials/godaddy.md) - [GoDaddy](docs/tutorials/godaddy.md)

View File

@ -107,6 +107,7 @@
| `--inmemory-zone=` | Provide a list of pre-configured zones for the inmemory provider; specify multiple times for multiple zones (optional) | | `--inmemory-zone=` | Provide a list of pre-configured zones for the inmemory provider; specify multiple times for multiple zones (optional) |
| `--ovh-endpoint="ovh-eu"` | When using the OVH provider, specify the endpoint (default: ovh-eu) | | `--ovh-endpoint="ovh-eu"` | When using the OVH provider, specify the endpoint (default: ovh-eu) |
| `--ovh-api-rate-limit=20` | When using the OVH provider, specify the API request rate limit, X operations by seconds (default: 20) | | `--ovh-api-rate-limit=20` | When using the OVH provider, specify the API request rate limit, X operations by seconds (default: 20) |
| `--[no-]ovh-enable-cname-relative` | When using the OVH provider, specify if CNAME should be treated as relative on target without final dot (default: false) |
| `--pdns-server="http://localhost:8081"` | When using the PowerDNS/PDNS provider, specify the URL to the pdns server (required when --provider=pdns) | | `--pdns-server="http://localhost:8081"` | When using the PowerDNS/PDNS provider, specify the URL to the pdns server (required when --provider=pdns) |
| `--pdns-server-id="localhost"` | When using the PowerDNS/PDNS provider, specify the id of the server to retrieve. Should be `localhost` except when the server is behind a proxy (optional when --provider=pdns) (default: localhost) | | `--pdns-server-id="localhost"` | When using the PowerDNS/PDNS provider, specify the id of the server to retrieve. Should be `localhost` except when the server is behind a proxy (optional when --provider=pdns) (default: localhost) |
| `--pdns-api-key=""` | When using the PowerDNS/PDNS provider, specify the API key to use to authorize requests (required when --provider=pdns) | | `--pdns-api-key=""` | When using the PowerDNS/PDNS provider, specify the API key to use to authorize requests (required when --provider=pdns) |

View File

@ -1,30 +1,31 @@
# OVHcloud # OVHcloud
This tutorial describes how to setup ExternalDNS for use within a This tutorial describes how to setup ExternalDNS for use within a
Kubernetes cluster using OVH DNS. Kubernetes cluster using OVHcloud DNS.
Make sure to use **>=0.6** version of ExternalDNS for this tutorial. Make sure to use **>=0.6** version of ExternalDNS for this tutorial.
## Creating a zone with OVH DNS ## Creating a zone with OVHcloud DNS
If you are new to OVH, we recommend you first read the following If you are new to OVHcloud, we recommend you first read the following
instructions for creating a zone. instructions for creating a zone.
[Creating a zone using the OVH manager](https://docs.ovh.com/gb/en/domains/create_a_dns_zone_for_a_domain_which_is_not_registered_at_ovh/) [Creating a zone using the OVHcloud Manager](https://help.ovhcloud.com/csm/en-gb-dns-create-dns-zone?id=kb_article_view&sysparm_article=KB0051667/)
[Creating a zone using the OVH API](https://api.ovh.com/console/) [Creating a zone using the OVHcloud API](https://api.ovh.com/console/)
## Creating OVH Credentials ## Creating OVHcloud Credentials
You first need to create an OVH application. You first need to create an OVHcloud application: follow the
[OVHcloud documentation](https://help.ovhcloud.com/csm/en-gb-api-getting-started-ovhcloud-api?id=kb_article_view&sysparm_article=KB0042784#advanced-usage-pair-ovhcloud-apis-with-an-application)
Using the [OVH documentation](https://docs.ovh.com/gb/en/api/first-steps-with-ovh-api/#advanced-usage-pair-ovhcloud-apis-with-an-application_2) you will have your `Application key` and `Application secret` you will have your `Application key` and `Application secret`
And you will need to generate your consumer key, here the permissions needed : And you will need to generate your consumer key, here the permissions needed :
- GET on `/domain/zone` - GET on `/domain/zone`
- GET on `/domain/zone/*/record` - GET on `/domain/zone/*/record`
- GET on `/domain/zone/*/record/*` - GET on `/domain/zone/*/record/*`
- PUT on `/domain/zone/*/record/*`
- POST on `/domain/zone/*/record` - POST on `/domain/zone/*/record`
- DELETE on `/domain/zone/*/record/*` - DELETE on `/domain/zone/*/record/*`
- GET on `/domain/zone/*/soa` - GET on `/domain/zone/*/soa`
@ -51,6 +52,10 @@ curl -XPOST -H "X-Ovh-Application: <ApplicationKey>" -H "Content-type: applicati
"method": "GET", "method": "GET",
"path": "/domain/zone/*/record/*" "path": "/domain/zone/*/record/*"
}, },
{
"method": "PUT",
"path": "/domain/zone/*/record/*"
},
{ {
"method": "POST", "method": "POST",
"path": "/domain/zone/*/record" "path": "/domain/zone/*/record"
@ -223,7 +228,7 @@ spec:
**A note about annotations** **A note about annotations**
Verify that the annotation on the service uses the same hostname as the OVH DNS zone created above. The annotation may also be a subdomain of the DNS zone (e.g. 'www.example.com'). Verify that the annotation on the service uses the same hostname as the OVHcloud DNS zone created above. The annotation may also be a subdomain of the DNS zone (e.g. 'www.example.com').
The TTL annotation can be used to configure the TTL on DNS records managed by ExternalDNS and is optional. If this annotation is not set, the TTL on records managed by ExternalDNS will default to 10. The TTL annotation can be used to configure the TTL on DNS records managed by ExternalDNS and is optional. If this annotation is not set, the TTL on records managed by ExternalDNS will default to 10.
@ -235,11 +240,11 @@ ExternalDNS uses the hostname annotation to determine which services should be r
kubectl create -f nginx.yaml kubectl create -f nginx.yaml
``` ```
Depending on where you run your service, it may take some time for your cloud provider to create an external IP for the service. Once an external IP is assigned, ExternalDNS detects the new service IP address and synchronizes the OVH DNS records. Depending on where you run your service, it may take some time for your cloud provider to create an external IP for the service. Once an external IP is assigned, ExternalDNS detects the new service IP address and synchronizes the OVHcloud DNS records.
## Verifying OVH DNS records ## Verifying OVHcloud DNS records
Use the OVH manager or API to verify that the A record for your domain shows the external IP address of the services. Use the OVHcloud manager or API to verify that the A record for your domain shows the external IP address of the services.
## Cleanup ## Cleanup

View File

@ -46,7 +46,7 @@ func (f *Flags) addFlag(name, description string) {
// It generates a markdown file // It generates a markdown file
// with the supported flags and writes it to the 'docs/flags.md' file. // with the supported flags and writes it to the 'docs/flags.md' file.
// to re-generate `docs/flags.md` execute 'go run internal/gen/main.go' // to re-generate `docs/flags.md` execute 'go run internal/gen/docs/flags/main.go'
func main() { func main() {
testPath, _ := os.Getwd() testPath, _ := os.Getwd()
path := fmt.Sprintf("%s/docs/flags.md", testPath) path := fmt.Sprintf("%s/docs/flags.md", testPath)

View File

@ -267,7 +267,7 @@ func main() {
case "digitalocean": case "digitalocean":
p, err = digitalocean.NewDigitalOceanProvider(ctx, domainFilter, cfg.DryRun, cfg.DigitalOceanAPIPageSize) p, err = digitalocean.NewDigitalOceanProvider(ctx, domainFilter, cfg.DryRun, cfg.DigitalOceanAPIPageSize)
case "ovh": case "ovh":
p, err = ovh.NewOVHProvider(ctx, domainFilter, cfg.OVHEndpoint, cfg.OVHApiRateLimit, cfg.DryRun) p, err = ovh.NewOVHProvider(ctx, domainFilter, cfg.OVHEndpoint, cfg.OVHApiRateLimit, cfg.OVHEnableCNAMERelative, cfg.DryRun)
case "linode": case "linode":
p, err = linode.NewLinodeProvider(domainFilter, cfg.DryRun, externaldns.Version) p, err = linode.NewLinodeProvider(domainFilter, cfg.DryRun, externaldns.Version)
case "dnsimple": case "dnsimple":

View File

@ -131,6 +131,7 @@ type Config struct {
InMemoryZones []string InMemoryZones []string
OVHEndpoint string OVHEndpoint string
OVHApiRateLimit int OVHApiRateLimit int
OVHEnableCNAMERelative bool
PDNSServer string PDNSServer string
PDNSServerID string PDNSServerID string
PDNSAPIKey string `secure:"yes"` PDNSAPIKey string `secure:"yes"`
@ -295,6 +296,7 @@ var defaultConfig = &Config{
InMemoryZones: []string{}, InMemoryZones: []string{},
OVHEndpoint: "ovh-eu", OVHEndpoint: "ovh-eu",
OVHApiRateLimit: 20, OVHApiRateLimit: 20,
OVHEnableCNAMERelative: false,
PDNSServer: "http://localhost:8081", PDNSServer: "http://localhost:8081",
PDNSServerID: "localhost", PDNSServerID: "localhost",
PDNSAPIKey: "", PDNSAPIKey: "",
@ -544,6 +546,7 @@ func App(cfg *Config) *kingpin.Application {
app.Flag("inmemory-zone", "Provide a list of pre-configured zones for the inmemory provider; specify multiple times for multiple zones (optional)").Default("").StringsVar(&cfg.InMemoryZones) app.Flag("inmemory-zone", "Provide a list of pre-configured zones for the inmemory provider; specify multiple times for multiple zones (optional)").Default("").StringsVar(&cfg.InMemoryZones)
app.Flag("ovh-endpoint", "When using the OVH provider, specify the endpoint (default: ovh-eu)").Default(defaultConfig.OVHEndpoint).StringVar(&cfg.OVHEndpoint) app.Flag("ovh-endpoint", "When using the OVH provider, specify the endpoint (default: ovh-eu)").Default(defaultConfig.OVHEndpoint).StringVar(&cfg.OVHEndpoint)
app.Flag("ovh-api-rate-limit", "When using the OVH provider, specify the API request rate limit, X operations by seconds (default: 20)").Default(strconv.Itoa(defaultConfig.OVHApiRateLimit)).IntVar(&cfg.OVHApiRateLimit) app.Flag("ovh-api-rate-limit", "When using the OVH provider, specify the API request rate limit, X operations by seconds (default: 20)").Default(strconv.Itoa(defaultConfig.OVHApiRateLimit)).IntVar(&cfg.OVHApiRateLimit)
app.Flag("ovh-enable-cname-relative", "When using the OVH provider, specify if CNAME should be treated as relative on target without final dot (default: false)").Default(strconv.FormatBool(defaultConfig.OVHEnableCNAMERelative)).BoolVar(&cfg.OVHEnableCNAMERelative)
app.Flag("pdns-server", "When using the PowerDNS/PDNS provider, specify the URL to the pdns server (required when --provider=pdns)").Default(defaultConfig.PDNSServer).StringVar(&cfg.PDNSServer) app.Flag("pdns-server", "When using the PowerDNS/PDNS provider, specify the URL to the pdns server (required when --provider=pdns)").Default(defaultConfig.PDNSServer).StringVar(&cfg.PDNSServer)
app.Flag("pdns-server-id", "When using the PowerDNS/PDNS provider, specify the id of the server to retrieve. Should be `localhost` except when the server is behind a proxy (optional when --provider=pdns) (default: localhost)").Default(defaultConfig.PDNSServerID).StringVar(&cfg.PDNSServerID) app.Flag("pdns-server-id", "When using the PowerDNS/PDNS provider, specify the id of the server to retrieve. Should be `localhost` except when the server is behind a proxy (optional when --provider=pdns) (default: localhost)").Default(defaultConfig.PDNSServerID).StringVar(&cfg.PDNSServerID)
app.Flag("pdns-api-key", "When using the PowerDNS/PDNS provider, specify the API key to use to authorize requests (required when --provider=pdns)").Default(defaultConfig.PDNSAPIKey).StringVar(&cfg.PDNSAPIKey) app.Flag("pdns-api-key", "When using the PowerDNS/PDNS provider, specify the API key to use to authorize requests (required when --provider=pdns)").Default(defaultConfig.PDNSAPIKey).StringVar(&cfg.PDNSAPIKey)

View File

@ -20,6 +20,9 @@ import (
"context" "context"
"errors" "errors"
"fmt" "fmt"
"net/url"
"slices"
"strconv"
"strings" "strings"
"time" "time"
@ -41,13 +44,12 @@ const (
ovhDefaultTTL = 0 ovhDefaultTTL = 0
ovhCreate = iota ovhCreate = iota
ovhDelete ovhDelete
ovhUpdate
) )
var ( var (
// ErrRecordToMutateNotFound when ApplyChange has to update/delete and didn't found the record in the existing zone (Change with no record ID) // ErrRecordToMutateNotFound when ApplyChange has to update/delete and didn't found the record in the existing zone (Change with no record ID)
ErrRecordToMutateNotFound = errors.New("record to mutate not found in current zone") ErrRecordToMutateNotFound = errors.New("record to mutate not found in current zone")
// ErrNoDryRun No dry run support for the moment
ErrNoDryRun = errors.New("dry run not supported")
) )
// OVHProvider is an implementation of Provider for OVH DNS. // OVHProvider is an implementation of Provider for OVH DNS.
@ -59,7 +61,15 @@ type OVHProvider struct {
apiRateLimiter ratelimit.Limiter apiRateLimiter ratelimit.Limiter
domainFilter endpoint.DomainFilter domainFilter endpoint.DomainFilter
DryRun bool
// DryRun enables dry-run mode
DryRun bool
// EnableCNAMERelativeTarget controls if CNAME target should be sent with relative format.
// Previous implementations of the OVHProvider always added a final dot as for absolut format.
// Default value is false, all CNAME are transformed into absolut format.
// Setting this to true will allow relative format to be sent to DNS zone.
EnableCNAMERelativeTarget bool
// UseCache controls if the OVHProvider will cache records in memory, and serve them // UseCache controls if the OVHProvider will cache records in memory, and serve them
// without recontacting the OVHcloud API if the SOA of the domain zone hasn't changed. // without recontacting the OVHcloud API if the SOA of the domain zone hasn't changed.
@ -67,16 +77,19 @@ type OVHProvider struct {
// your refresh rate/number of records is too big, which might cause issue with the // your refresh rate/number of records is too big, which might cause issue with the
// provider. // provider.
// Default value: true // Default value: true
UseCache bool UseCache bool
lastRunRecords []ovhRecord
lastRunZones []string
cacheInstance *cache.Cache cacheInstance *cache.Cache
dnsClient dnsClient dnsClient dnsClient
} }
type ovhClient interface { type ovhClient interface {
Post(string, interface{}, interface{}) error PostWithContext(context.Context, string, any, any) error
Get(string, interface{}) error PutWithContext(context.Context, string, any, any) error
Delete(string, interface{}) error GetWithContext(context.Context, string, any) error
DeleteWithContext(context.Context, string, any) error
} }
type dnsClient interface { type dnsClient interface {
@ -84,7 +97,11 @@ type dnsClient interface {
} }
type ovhRecordFields struct { type ovhRecordFields struct {
ovhRecordFieldUpdate
FieldType string `json:"fieldType"` FieldType string `json:"fieldType"`
}
type ovhRecordFieldUpdate struct {
SubDomain string `json:"subDomain"` SubDomain string `json:"subDomain"`
TTL int64 `json:"ttl"` TTL int64 `json:"ttl"`
Target string `json:"target"` Target string `json:"target"`
@ -96,86 +113,160 @@ type ovhRecord struct {
Zone string `json:"zone"` Zone string `json:"zone"`
} }
func (r ovhRecord) String() string {
return "record#" + strconv.Itoa(int(r.ID)) + ": " + r.FieldType + " | " + r.SubDomain + " => " + r.Target + " (" + strconv.Itoa(int(r.TTL)) + ")"
}
type ovhChange struct { type ovhChange struct {
ovhRecord ovhRecord
Action int Action int
} }
// NewOVHProvider initializes a new OVH DNS based Provider. // NewOVHProvider initializes a new OVH DNS based Provider.
func NewOVHProvider(ctx context.Context, domainFilter endpoint.DomainFilter, endpoint string, apiRateLimit int, dryRun bool) (*OVHProvider, error) { func NewOVHProvider(ctx context.Context, domainFilter endpoint.DomainFilter, endpoint string, apiRateLimit int, enableCNAMERelative, dryRun bool) (*OVHProvider, error) {
client, err := ovh.NewEndpointClient(endpoint) client, err := ovh.NewEndpointClient(endpoint)
if err != nil { if err != nil {
return nil, err return nil, err
} }
client.UserAgent = externaldns.Version client.UserAgent = "ExternalDNS/" + externaldns.Version
// TODO: Add Dry Run support
if dryRun {
return nil, ErrNoDryRun
}
return &OVHProvider{ return &OVHProvider{
client: client, client: client,
domainFilter: domainFilter, domainFilter: domainFilter,
apiRateLimiter: ratelimit.New(apiRateLimit), apiRateLimiter: ratelimit.New(apiRateLimit),
DryRun: dryRun, DryRun: dryRun,
cacheInstance: cache.New(cache.NoExpiration, cache.NoExpiration), cacheInstance: cache.New(cache.NoExpiration, cache.NoExpiration),
dnsClient: new(dns.Client), dnsClient: new(dns.Client),
UseCache: true, UseCache: true,
EnableCNAMERelativeTarget: enableCNAMERelative,
}, nil }, nil
} }
// Records returns the list of records in all relevant zones. // Records returns the list of records in all relevant zones.
func (p *OVHProvider) Records(ctx context.Context) ([]*endpoint.Endpoint, error) { func (p *OVHProvider) Records(ctx context.Context) ([]*endpoint.Endpoint, error) {
_, records, err := p.zonesRecords(ctx) zones, records, err := p.zonesRecords(ctx)
if err != nil { if err != nil {
return nil, err return nil, err
} }
p.lastRunRecords = records
p.lastRunZones = zones
endpoints := ovhGroupByNameAndType(records) endpoints := ovhGroupByNameAndType(records)
log.Infof("OVH: %d endpoints have been found", len(endpoints)) log.Infof("OVH: %d endpoints have been found", len(endpoints))
return endpoints, nil return endpoints, nil
} }
// ApplyChanges applies a given set of changes in a given zone. func planChangesByZoneName(zones []string, changes *plan.Changes) map[string]*plan.Changes {
func (p *OVHProvider) ApplyChanges(ctx context.Context, changes *plan.Changes) (err error) { zoneNameIDMapper := provider.ZoneIDName{}
zones, records, err := p.zonesRecords(ctx) for _, zone := range zones {
if err != nil { zoneNameIDMapper.Add(zone, zone)
return provider.NewSoftError(err)
} }
zonesChangeUniques := map[string]bool{} output := map[string]*plan.Changes{}
for _, endpt := range changes.Delete {
// Always refresh zones even in case of errors. _, zoneName := zoneNameIDMapper.FindZone(endpt.DNSName)
defer func() { if _, ok := output[zoneName]; !ok {
log.Debugf("OVH: %d zones will be refreshed", len(zonesChangeUniques)) output[zoneName] = &plan.Changes{}
eg, _ := errgroup.WithContext(ctx)
for zone := range zonesChangeUniques {
// This is necessary because the loop variable zone is reused in each iteration of the loop,
// and without this line, the goroutines launched by eg.Go would all reference the same zone variable.
zone := zone
eg.Go(func() error { return p.refresh(zone) })
} }
output[zoneName].Delete = append(output[zoneName].Delete, endpt)
if e := eg.Wait(); e != nil && err == nil { // return the error only if there is no error during the changes }
err = provider.NewSoftError(e) for _, endpt := range changes.Create {
_, zoneName := zoneNameIDMapper.FindZone(endpt.DNSName)
if _, ok := output[zoneName]; !ok {
output[zoneName] = &plan.Changes{}
} }
}() output[zoneName].Create = append(output[zoneName].Create, endpt)
}
for _, endpt := range changes.UpdateOld {
_, zoneName := zoneNameIDMapper.FindZone(endpt.DNSName)
if _, ok := output[zoneName]; !ok {
output[zoneName] = &plan.Changes{}
}
output[zoneName].UpdateOld = append(output[zoneName].UpdateOld, endpt)
}
for _, endpt := range changes.UpdateNew {
_, zoneName := zoneNameIDMapper.FindZone(endpt.DNSName)
if _, ok := output[zoneName]; !ok {
output[zoneName] = &plan.Changes{}
}
output[zoneName].UpdateNew = append(output[zoneName].UpdateNew, endpt)
}
allChanges := make([]ovhChange, 0, countTargets(changes.Create, changes.UpdateNew, changes.UpdateOld, changes.Delete)) return output
allChanges = append(allChanges, newOvhChange(ovhCreate, changes.Create, zones, records)...) }
allChanges = append(allChanges, newOvhChange(ovhCreate, changes.UpdateNew, zones, records)...)
allChanges = append(allChanges, newOvhChange(ovhDelete, changes.UpdateOld, zones, records)...)
allChanges = append(allChanges, newOvhChange(ovhDelete, changes.Delete, zones, records)...)
log.Infof("OVH: %d changes will be done", len(allChanges)) func (p OVHProvider) computeSingleZoneChanges(_ context.Context, zoneName string, existingRecords []ovhRecord, changes *plan.Changes) []ovhChange {
allChanges := []ovhChange{}
var computedChanges []ovhChange
eg, _ := errgroup.WithContext(ctx) computedChanges, existingRecords = p.newOvhChangeCreateDelete(ovhCreate, changes.Create, zoneName, existingRecords)
allChanges = append(allChanges, computedChanges...)
computedChanges, existingRecords = p.newOvhChangeCreateDelete(ovhDelete, changes.Delete, zoneName, existingRecords)
allChanges = append(allChanges, computedChanges...)
computedChanges = p.newOvhChangeUpdate(changes.UpdateOld, changes.UpdateNew, zoneName, existingRecords)
allChanges = append(allChanges, computedChanges...)
return allChanges
}
func (p *OVHProvider) handleSingleZoneUpdate(ctx context.Context, zoneName string, existingRecords []ovhRecord, changes *plan.Changes) error {
allChanges := p.computeSingleZoneChanges(ctx, zoneName, existingRecords, changes)
log.Infof("OVH: %q: %d changes will be done", zoneName, len(allChanges))
eg, ctxErrGroup := errgroup.WithContext(ctx)
for _, change := range allChanges { for _, change := range allChanges {
change := change change := change
zonesChangeUniques[change.Zone] = true eg.Go(func() error {
eg.Go(func() error { return p.change(change) }) return p.change(ctxErrGroup, change)
})
} }
err := eg.Wait()
// do not refresh zone if errors: some records might haven't been processed yet, hence the zone will be in an inconsistent state
// if modification of the zone was in error, invalidating the cache to make sure next run will start freshly
if err == nil {
err = p.refresh(ctx, zoneName)
} else {
p.invalidateCache(zoneName)
}
return err
}
// ApplyChanges applies a given set of changes in a given zone.
func (p *OVHProvider) ApplyChanges(ctx context.Context, changes *plan.Changes) (err error) {
zones, records := p.lastRunZones, p.lastRunRecords
defer func() {
p.lastRunRecords = []ovhRecord{}
p.lastRunZones = []string{}
}()
if log.IsLevelEnabled(log.DebugLevel) {
for _, change := range changes.Create {
log.Debugf("OVH: changes CREATE dns:%q / targets:%v / type:%s", change.DNSName, change.Targets, change.RecordType)
}
for _, change := range changes.UpdateOld {
log.Debugf("OVH: changes UPDATEOLD dns:%q / targets:%v / type:%s", change.DNSName, change.Targets, change.RecordType)
}
for _, change := range changes.UpdateNew {
log.Debugf("OVH: changes UPDATENEW dns:%q / targets:%v / type:%s", change.DNSName, change.Targets, change.RecordType)
}
for _, change := range changes.Delete {
log.Debugf("OVH: changes DELETE dns:%q / targets:%v / type:%s", change.DNSName, change.Targets, change.RecordType)
}
}
changesByZoneName := planChangesByZoneName(zones, changes)
eg, ctx := errgroup.WithContext(ctx)
for zoneName, changes := range changesByZoneName {
eg.Go(func() error {
return p.handleSingleZoneUpdate(ctx, zoneName, records, changes)
})
}
if err := eg.Wait(); err != nil { if err := eg.Wait(); err != nil {
return provider.NewSoftError(err) return provider.NewSoftError(err)
} }
@ -183,7 +274,7 @@ func (p *OVHProvider) ApplyChanges(ctx context.Context, changes *plan.Changes) (
return nil return nil
} }
func (p *OVHProvider) refresh(zone string) error { func (p *OVHProvider) refresh(ctx context.Context, zone string) error {
log.Debugf("OVH: Refresh %s zone", zone) log.Debugf("OVH: Refresh %s zone", zone)
// Zone has been altered so we invalidate the cache // Zone has been altered so we invalidate the cache
@ -191,26 +282,50 @@ func (p *OVHProvider) refresh(zone string) error {
p.invalidateCache(zone) p.invalidateCache(zone)
p.apiRateLimiter.Take() p.apiRateLimiter.Take()
if err := p.client.Post(fmt.Sprintf("/domain/zone/%s/refresh", zone), nil, nil); err != nil { if p.DryRun {
log.Infof("OVH: Dry-run: Would have refresh DNS zone %q", zone)
return nil
}
if err := p.client.PostWithContext(ctx, fmt.Sprintf("/domain/zone/%s/refresh", url.PathEscape(zone)), nil, nil); err != nil {
return provider.NewSoftError(err) return provider.NewSoftError(err)
} }
return nil return nil
} }
func (p *OVHProvider) change(change ovhChange) error { func (p *OVHProvider) change(ctx context.Context, change ovhChange) error {
p.apiRateLimiter.Take() p.apiRateLimiter.Take()
switch change.Action { switch change.Action {
case ovhCreate: case ovhCreate:
log.Debugf("OVH: Add an entry to %s", change.String()) log.Debugf("OVH: Add an entry to %s", change.String())
return p.client.Post(fmt.Sprintf("/domain/zone/%s/record", change.Zone), change.ovhRecordFields, nil) if p.DryRun {
log.Infof("OVH: Dry-run: Would have created a DNS record for zone %s", change.Zone)
return nil
}
return p.client.PostWithContext(ctx, fmt.Sprintf("/domain/zone/%s/record", url.PathEscape(change.Zone)), change.ovhRecordFields, nil)
case ovhDelete: case ovhDelete:
if change.ID == 0 { if change.ID == 0 {
return ErrRecordToMutateNotFound return ErrRecordToMutateNotFound
} }
log.Debugf("OVH: Delete an entry to %s", change.String()) log.Debugf("OVH: Delete an entry to %s", change.String())
return p.client.Delete(fmt.Sprintf("/domain/zone/%s/record/%d", change.Zone, change.ID), nil) if p.DryRun {
log.Infof("OVH: Dry-run: Would have deleted a DNS record for zone %s", change.Zone)
return nil
}
return p.client.DeleteWithContext(ctx, fmt.Sprintf("/domain/zone/%s/record/%d", url.PathEscape(change.Zone), change.ID), nil)
case ovhUpdate:
if change.ID == 0 {
return ErrRecordToMutateNotFound
}
log.Debugf("OVH: Update an entry to %s", change.String())
if p.DryRun {
log.Infof("OVH: Dry-run: Would have updated a DNS record for zone %s", change.Zone)
return nil
}
return p.client.PutWithContext(ctx, fmt.Sprintf("/domain/zone/%s/record/%d", url.PathEscape(change.Zone), change.ID), change.ovhRecordFieldUpdate, nil)
} }
return nil return nil
} }
@ -220,7 +335,7 @@ func (p *OVHProvider) invalidateCache(zone string) {
func (p *OVHProvider) zonesRecords(ctx context.Context) ([]string, []ovhRecord, error) { func (p *OVHProvider) zonesRecords(ctx context.Context) ([]string, []ovhRecord, error) {
var allRecords []ovhRecord var allRecords []ovhRecord
zones, err := p.zones() zones, err := p.zones(ctx)
if err != nil { if err != nil {
return nil, nil, provider.NewSoftError(err) return nil, nil, provider.NewSoftError(err)
} }
@ -229,7 +344,7 @@ func (p *OVHProvider) zonesRecords(ctx context.Context) ([]string, []ovhRecord,
eg, ctx := errgroup.WithContext(ctx) eg, ctx := errgroup.WithContext(ctx)
for _, zone := range zones { for _, zone := range zones {
zone := zone zone := zone
eg.Go(func() error { return p.records(&ctx, &zone, chRecords) }) eg.Go(func() error { return p.records(ctx, &zone, chRecords) })
} }
if err := eg.Wait(); err != nil { if err := eg.Wait(); err != nil {
return nil, nil, provider.NewSoftError(err) return nil, nil, provider.NewSoftError(err)
@ -241,12 +356,12 @@ func (p *OVHProvider) zonesRecords(ctx context.Context) ([]string, []ovhRecord,
return zones, allRecords, nil return zones, allRecords, nil
} }
func (p *OVHProvider) zones() ([]string, error) { func (p *OVHProvider) zones(ctx context.Context) ([]string, error) {
zones := []string{} zones := []string{}
filteredZones := []string{} filteredZones := []string{}
p.apiRateLimiter.Take() p.apiRateLimiter.Take()
if err := p.client.Get("/domain/zone", &zones); err != nil { if err := p.client.GetWithContext(ctx, "/domain/zone", &zones); err != nil {
return nil, err return nil, err
} }
@ -265,22 +380,24 @@ type ovhSoa struct {
records []ovhRecord records []ovhRecord
} }
func (p *OVHProvider) records(ctx *context.Context, zone *string, records chan<- []ovhRecord) error { func (p *OVHProvider) records(ctx context.Context, zone *string, records chan<- []ovhRecord) error {
var recordsIds []uint64 var recordsIds []uint64
ovhRecords := make([]ovhRecord, len(recordsIds)) ovhRecords := make([]ovhRecord, len(recordsIds))
eg, _ := errgroup.WithContext(*ctx) eg, ctxErrGroup := errgroup.WithContext(ctx)
if p.UseCache { if p.UseCache {
if cachedSoaItf, ok := p.cacheInstance.Get(*zone + "#soa"); ok { if cachedSoaItf, ok := p.cacheInstance.Get(*zone + "#soa"); ok {
cachedSoa := cachedSoaItf.(ovhSoa) cachedSoa := cachedSoaItf.(ovhSoa)
log.Debugf("OVH: zone %s: Checking SOA against %v", *zone, cachedSoa.Serial)
m := new(dns.Msg) m := new(dns.Msg)
m.SetQuestion(dns.Fqdn(*zone), dns.TypeSOA) m.SetQuestion(dns.Fqdn(*zone), dns.TypeSOA)
in, _, err := p.dnsClient.ExchangeContext(*ctx, m, strings.TrimSuffix(cachedSoa.Server, ".")+":53") in, _, err := p.dnsClient.ExchangeContext(ctx, m, strings.TrimSuffix(cachedSoa.Server, ".")+":53")
if err == nil { if err == nil {
if s, ok := in.Answer[0].(*dns.SOA); ok { if s, ok := in.Answer[0].(*dns.SOA); ok {
// do something with t.Txt
if s.Serial == cachedSoa.Serial { if s.Serial == cachedSoa.Serial {
log.Debugf("OVH: zone %s: SOA from cache is valid", *zone)
records <- cachedSoa.records records <- cachedSoa.records
return nil return nil
} }
@ -291,23 +408,23 @@ func (p *OVHProvider) records(ctx *context.Context, zone *string, records chan<-
} }
} }
log.Debugf("OVH: Getting records for %s", *zone) log.Debugf("OVH: Getting records for %s from API", *zone)
p.apiRateLimiter.Take() p.apiRateLimiter.Take()
var soa ovhSoa var soa ovhSoa
if p.UseCache { if p.UseCache {
if err := p.client.Get("/domain/zone/"+*zone+"/soa", &soa); err != nil { if err := p.client.GetWithContext(ctx, "/domain/zone/"+url.PathEscape(*zone)+"/soa", &soa); err != nil {
return err return err
} }
} }
if err := p.client.Get(fmt.Sprintf("/domain/zone/%s/record", *zone), &recordsIds); err != nil { if err := p.client.GetWithContext(ctx, fmt.Sprintf("/domain/zone/%s/record", url.PathEscape(*zone)), &recordsIds); err != nil {
return err return err
} }
chRecords := make(chan ovhRecord, len(recordsIds)) chRecords := make(chan ovhRecord, len(recordsIds))
for _, id := range recordsIds { for _, id := range recordsIds {
id := id id := id
eg.Go(func() error { return p.record(zone, id, chRecords) }) eg.Go(func() error { return p.record(ctxErrGroup, zone, id, chRecords) })
} }
if err := eg.Wait(); err != nil { if err := eg.Wait(); err != nil {
return err return err
@ -319,20 +436,20 @@ func (p *OVHProvider) records(ctx *context.Context, zone *string, records chan<-
if p.UseCache { if p.UseCache {
soa.records = ovhRecords soa.records = ovhRecords
_ = p.cacheInstance.Add(*zone+"#soa", soa, time.Hour) _ = p.cacheInstance.Add(*zone+"#soa", soa, cache.DefaultExpiration)
} }
records <- ovhRecords records <- ovhRecords
return nil return nil
} }
func (p *OVHProvider) record(zone *string, id uint64, records chan<- ovhRecord) error { func (p *OVHProvider) record(ctx context.Context, zone *string, id uint64, records chan<- ovhRecord) error {
record := ovhRecord{} record := ovhRecord{}
log.Debugf("OVH: Getting record %d for %s", id, *zone) log.Debugf("OVH: Getting record %d for %s", id, *zone)
p.apiRateLimiter.Take() p.apiRateLimiter.Take()
if err := p.client.Get(fmt.Sprintf("/domain/zone/%s/record/%d", *zone, id), &record); err != nil { if err := p.client.GetWithContext(ctx, fmt.Sprintf("/domain/zone/%s/record/%d", url.PathEscape(*zone), id), &record); err != nil {
return err return err
} }
if provider.SupportedRecordType(record.FieldType) { if provider.SupportedRecordType(record.FieldType) {
@ -349,7 +466,7 @@ func ovhGroupByNameAndType(records []ovhRecord) []*endpoint.Endpoint {
groups := map[string][]ovhRecord{} groups := map[string][]ovhRecord{}
for _, r := range records { for _, r := range records {
groupBy := r.Zone + r.SubDomain + r.FieldType groupBy := r.Zone + "//" + r.SubDomain + "//" + r.FieldType
if _, ok := groups[groupBy]; !ok { if _, ok := groups[groupBy]; !ok {
groups[groupBy] = []ovhRecord{} groups[groupBy] = []ovhRecord{}
} }
@ -375,74 +492,214 @@ func ovhGroupByNameAndType(records []ovhRecord) []*endpoint.Endpoint {
return endpoints return endpoints
} }
func newOvhChange(action int, endpoints []*endpoint.Endpoint, zones []string, records []ovhRecord) []ovhChange { func (p OVHProvider) newOvhChangeCreateDelete(action int, endpoints []*endpoint.Endpoint, zone string, existingRecords []ovhRecord) ([]ovhChange, []ovhRecord) {
// Copy the records because we need to mutate the list. ovhChanges := []ovhChange{}
newRecords := make([]ovhRecord, len(records)) toDeleteIds := []int{}
copy(newRecords, records)
zoneNameIDMapper := provider.ZoneIDName{}
ovhChanges := make([]ovhChange, 0, countTargets(endpoints))
for _, zone := range zones {
zoneNameIDMapper.Add(zone, zone)
}
for _, e := range endpoints { for _, e := range endpoints {
zone, _ := zoneNameIDMapper.FindZone(e.DNSName)
if zone == "" {
log.Debugf("Skipping record %s because no hosted zone matching record DNS Name was detected", e.DNSName)
continue
}
for _, target := range e.Targets { for _, target := range e.Targets {
if e.RecordType == endpoint.RecordTypeCNAME {
target = target + "."
}
change := ovhChange{ change := ovhChange{
Action: action, Action: action,
ovhRecord: ovhRecord{ ovhRecord: ovhRecord{
Zone: zone, Zone: zone,
ovhRecordFields: ovhRecordFields{ ovhRecordFields: ovhRecordFields{
FieldType: e.RecordType, FieldType: e.RecordType,
SubDomain: strings.TrimSuffix(e.DNSName, "."+zone), ovhRecordFieldUpdate: ovhRecordFieldUpdate{
TTL: ovhDefaultTTL, SubDomain: convertDNSNameIntoSubDomain(e.DNSName, zone),
Target: target, TTL: ovhDefaultTTL,
Target: target,
},
}, },
}, },
} }
p.formatCNAMETarget(&change)
if e.RecordTTL.IsConfigured() { if e.RecordTTL.IsConfigured() {
change.TTL = int64(e.RecordTTL) change.TTL = int64(e.RecordTTL)
} }
// The Zone might have multiple records with the same target. In order to avoid applying the action to the // The Zone might have multiple records with the same target. In order to avoid applying the action to the
// same OVH record, we remove a record from the list when a match is found. // same OVH record, we remove a record from the list when a match is found.
for i := 0; i < len(newRecords); i++ { if action == ovhDelete {
rec := newRecords[i] for i, rec := range existingRecords {
if rec.Zone == change.Zone && rec.SubDomain == change.SubDomain && rec.FieldType == change.FieldType && rec.Target == change.Target { if rec.Zone == change.Zone && rec.SubDomain == change.SubDomain && rec.FieldType == change.FieldType && rec.Target == change.Target && !slices.Contains(toDeleteIds, i) {
change.ID = rec.ID change.ID = rec.ID
// Deleting this record from the list to avoid retargetting it later if a change with a similar target exists. toDeleteIds = append(toDeleteIds, i)
newRecords = append(newRecords[:i], newRecords[i+1:]...) break
break }
} }
} }
ovhChanges = append(ovhChanges, change) ovhChanges = append(ovhChanges, change)
} }
} }
return ovhChanges if len(toDeleteIds) > 0 {
} // Copy the records because we need to mutate the list.
existingRecords = slices.Clone(existingRecords)
func countTargets(allEndpoints ...[]*endpoint.Endpoint) int { alreadyRemoved := 0
count := 0 for _, id := range toDeleteIds {
for _, endpoints := range allEndpoints { existingRecords = slices.Delete(existingRecords, id-alreadyRemoved, id-alreadyRemoved+1)
for _, endpoint := range endpoints { alreadyRemoved++
count += len(endpoint.Targets)
} }
} }
return count
return ovhChanges, existingRecords
}
func convertDNSNameIntoSubDomain(DNSName string, zoneName string) string {
if DNSName == zoneName {
return ""
}
return strings.TrimSuffix(DNSName, "."+zoneName)
}
func (p OVHProvider) newOvhChangeUpdate(endpointsOld []*endpoint.Endpoint, endpointsNew []*endpoint.Endpoint, zone string, existingRecords []ovhRecord) []ovhChange {
zoneNameIDMapper := provider.ZoneIDName{}
zoneNameIDMapper.Add(zone, zone)
oldEndpointByTypeAndName := map[string]*endpoint.Endpoint{}
newEndpointByTypeAndName := map[string]*endpoint.Endpoint{}
oldRecordsInZone := map[string][]ovhRecord{}
for _, e := range endpointsOld {
sub := convertDNSNameIntoSubDomain(e.DNSName, zone)
oldEndpointByTypeAndName[e.RecordType+"//"+sub] = e
}
for _, e := range endpointsNew {
sub := convertDNSNameIntoSubDomain(e.DNSName, zone)
newEndpointByTypeAndName[e.RecordType+"//"+sub] = e
}
for id := range oldEndpointByTypeAndName {
for _, record := range existingRecords {
if id == record.FieldType+"//"+record.SubDomain {
oldRecordsInZone[id] = append(oldRecordsInZone[id], record)
}
}
}
changes := []ovhChange{}
for id := range oldEndpointByTypeAndName {
oldRecords := slices.Clone(oldRecordsInZone[id])
endpointsNew := newEndpointByTypeAndName[id]
toInsertTarget := []string{}
for _, target := range endpointsNew.Targets {
var toDelete int = -1
for i, record := range oldRecords {
if target == record.Target {
toDelete = i
break
}
}
if toDelete >= 0 {
oldRecords = slices.Delete(oldRecords, toDelete, toDelete+1)
} else {
toInsertTarget = append(toInsertTarget, target)
}
}
toInsertTargetToDelete := []int{}
for i, target := range toInsertTarget {
if len(oldRecords) == 0 {
break
}
record := oldRecords[0]
oldRecords = slices.Delete(oldRecords, 0, 1)
record.Target = target
if endpointsNew.RecordTTL.IsConfigured() {
record.TTL = int64(endpointsNew.RecordTTL)
} else {
record.TTL = ovhDefaultTTL
}
change := ovhChange{
Action: ovhUpdate,
ovhRecord: record,
}
p.formatCNAMETarget(&change)
changes = append(changes, change)
toInsertTargetToDelete = append(toInsertTargetToDelete, i)
}
for _, i := range toInsertTargetToDelete {
toInsertTarget = slices.Delete(toInsertTarget, i, i+1)
}
if len(toInsertTarget) > 0 {
for _, target := range toInsertTarget {
recordTTL := int64(ovhDefaultTTL)
if endpointsNew.RecordTTL.IsConfigured() {
recordTTL = int64(endpointsNew.RecordTTL)
}
change := ovhChange{
Action: ovhCreate,
ovhRecord: ovhRecord{
Zone: zone,
ovhRecordFields: ovhRecordFields{
FieldType: endpointsNew.RecordType,
ovhRecordFieldUpdate: ovhRecordFieldUpdate{
SubDomain: convertDNSNameIntoSubDomain(endpointsNew.DNSName, zone),
TTL: recordTTL,
Target: target,
},
},
},
}
p.formatCNAMETarget(&change)
changes = append(changes, change)
}
}
if len(oldRecords) > 0 {
for i := range oldRecords {
changes = append(changes, ovhChange{
Action: ovhDelete,
ovhRecord: oldRecords[i],
})
}
}
}
return changes
} }
func (c *ovhChange) String() string { func (c *ovhChange) String() string {
if c.ID != 0 { var action string
return fmt.Sprintf("%s zone (ID : %d) : %s %d IN %s %s", c.Zone, c.ID, c.SubDomain, c.TTL, c.FieldType, c.Target) switch c.Action {
case ovhCreate:
action = "create"
case ovhUpdate:
action = "update"
case ovhDelete:
action = "delete"
} }
return fmt.Sprintf("%s zone : %s %d IN %s %s", c.Zone, c.SubDomain, c.TTL, c.FieldType, c.Target)
if c.ID != 0 {
return fmt.Sprintf("%s zone (ID : %d) action(%s) : %s %d IN %s %s", c.Zone, c.ID, action, c.SubDomain, c.TTL, c.FieldType, c.Target)
}
return fmt.Sprintf("%s zone action(%s) : %s %d IN %s %s", c.Zone, action, c.SubDomain, c.TTL, c.FieldType, c.Target)
}
func (p OVHProvider) formatCNAMETarget(change *ovhChange) {
if change.FieldType != endpoint.RecordTypeCNAME {
return
}
if p.EnableCNAMERelativeTarget {
return
}
if strings.HasSuffix(change.Target, ".") {
return
}
change.Target += "."
} }

View File

@ -24,6 +24,7 @@ import (
"testing" "testing"
"time" "time"
"github.com/maxatome/go-testdeep/td"
"github.com/miekg/dns" "github.com/miekg/dns"
"github.com/ovh/go-ovh/ovh" "github.com/ovh/go-ovh/ovh"
"github.com/patrickmn/go-cache" "github.com/patrickmn/go-cache"
@ -38,21 +39,28 @@ type mockOvhClient struct {
mock.Mock mock.Mock
} }
func (c *mockOvhClient) Post(endpoint string, input interface{}, output interface{}) error { func (c *mockOvhClient) PostWithContext(ctx context.Context, endpoint string, input interface{}, output interface{}) error {
stub := c.Called(endpoint, input) stub := c.Called(endpoint, input)
data, _ := json.Marshal(stub.Get(0)) data, _ := json.Marshal(stub.Get(0))
json.Unmarshal(data, output) json.Unmarshal(data, output)
return stub.Error(1) return stub.Error(1)
} }
func (c *mockOvhClient) Get(endpoint string, output interface{}) error { func (c *mockOvhClient) PutWithContext(ctx context.Context, endpoint string, input interface{}, output interface{}) error {
stub := c.Called(endpoint, input)
data, _ := json.Marshal(stub.Get(0))
json.Unmarshal(data, output)
return stub.Error(1)
}
func (c *mockOvhClient) GetWithContext(ctx context.Context, endpoint string, output interface{}) error {
stub := c.Called(endpoint) stub := c.Called(endpoint)
data, _ := json.Marshal(stub.Get(0)) data, _ := json.Marshal(stub.Get(0))
json.Unmarshal(data, output) json.Unmarshal(data, output)
return stub.Error(1) return stub.Error(1)
} }
func (c *mockOvhClient) Delete(endpoint string, output interface{}) error { func (c *mockOvhClient) DeleteWithContext(ctx context.Context, endpoint string, output interface{}) error {
stub := c.Called(endpoint) stub := c.Called(endpoint)
data, _ := json.Marshal(stub.Get(0)) data, _ := json.Marshal(stub.Get(0))
json.Unmarshal(data, output) json.Unmarshal(data, output)
@ -84,16 +92,16 @@ func TestOvhZones(t *testing.T) {
} }
// Basic zones // Basic zones
client.On("Get", "/domain/zone").Return([]string{"example.com", "example.net"}, nil).Once() client.On("GetWithContext", "/domain/zone").Return([]string{"example.com", "example.net"}, nil).Once()
domains, err := provider.zones() domains, err := provider.zones(t.Context())
assert.NoError(err) assert.NoError(err)
assert.Contains(domains, "example.com") assert.Contains(domains, "example.com")
assert.NotContains(domains, "example.net") assert.NotContains(domains, "example.net")
client.AssertExpectations(t) client.AssertExpectations(t)
// Error on getting zones // Error on getting zones
client.On("Get", "/domain/zone").Return(nil, ovh.ErrAPIDown).Once() client.On("GetWithContext", "/domain/zone").Return(nil, ovh.ErrAPIDown).Once()
domains, err = provider.zones() domains, err = provider.zones(t.Context())
assert.Error(err) assert.Error(err)
assert.Nil(domains) assert.Nil(domains)
client.AssertExpectations(t) client.AssertExpectations(t)
@ -106,21 +114,21 @@ func TestOvhZoneRecords(t *testing.T) {
// Basic zones records // Basic zones records
t.Log("Basic zones records") t.Log("Basic zones records")
client.On("Get", "/domain/zone").Return([]string{"example.org"}, nil).Once() client.On("GetWithContext", "/domain/zone").Return([]string{"example.org"}, nil).Once()
client.On("Get", "/domain/zone/example.org/soa").Return(ovhSoa{Server: "ns.example.org.", Serial: 2022090901}, nil).Once() client.On("GetWithContext", "/domain/zone/example.org/soa").Return(ovhSoa{Server: "ns.example.org.", Serial: 2022090901}, nil).Once()
client.On("Get", "/domain/zone/example.org/record").Return([]uint64{24, 42}, nil).Once() client.On("GetWithContext", "/domain/zone/example.org/record").Return([]uint64{24, 42}, nil).Once()
client.On("Get", "/domain/zone/example.org/record/24").Return(ovhRecord{ID: 24, Zone: "example.org", ovhRecordFields: ovhRecordFields{SubDomain: "ovh", FieldType: "NS", TTL: 10, Target: "203.0.113.42"}}, nil).Once() client.On("GetWithContext", "/domain/zone/example.org/record/24").Return(ovhRecord{ID: 24, Zone: "example.org", ovhRecordFields: ovhRecordFields{FieldType: "NS", ovhRecordFieldUpdate: ovhRecordFieldUpdate{SubDomain: "ovh", TTL: 10, Target: "203.0.113.42"}}}, nil).Once()
client.On("Get", "/domain/zone/example.org/record/42").Return(ovhRecord{ID: 42, Zone: "example.org", ovhRecordFields: ovhRecordFields{SubDomain: "ovh", FieldType: "A", TTL: 10, Target: "203.0.113.42"}}, nil).Once() client.On("GetWithContext", "/domain/zone/example.org/record/42").Return(ovhRecord{ID: 42, Zone: "example.org", ovhRecordFields: ovhRecordFields{FieldType: "A", ovhRecordFieldUpdate: ovhRecordFieldUpdate{SubDomain: "ovh", TTL: 10, Target: "203.0.113.42"}}}, nil).Once()
zones, records, err := provider.zonesRecords(context.TODO()) zones, records, err := provider.zonesRecords(t.Context())
assert.NoError(err) assert.NoError(err)
assert.ElementsMatch(zones, []string{"example.org"}) assert.ElementsMatch(zones, []string{"example.org"})
assert.ElementsMatch(records, []ovhRecord{{ID: 42, Zone: "example.org", ovhRecordFields: ovhRecordFields{SubDomain: "ovh", FieldType: "A", TTL: 10, Target: "203.0.113.42"}}, {ID: 24, Zone: "example.org", ovhRecordFields: ovhRecordFields{SubDomain: "ovh", FieldType: "NS", TTL: 10, Target: "203.0.113.42"}}}) assert.ElementsMatch(records, []ovhRecord{{ID: 42, Zone: "example.org", ovhRecordFields: ovhRecordFields{FieldType: "A", ovhRecordFieldUpdate: ovhRecordFieldUpdate{SubDomain: "ovh", TTL: 10, Target: "203.0.113.42"}}}, {ID: 24, Zone: "example.org", ovhRecordFields: ovhRecordFields{FieldType: "NS", ovhRecordFieldUpdate: ovhRecordFieldUpdate{SubDomain: "ovh", TTL: 10, Target: "203.0.113.42"}}}})
client.AssertExpectations(t) client.AssertExpectations(t)
// Error on getting zones list // Error on getting zones list
t.Log("Error on getting zones list") t.Log("Error on getting zones list")
client.On("Get", "/domain/zone").Return(nil, ovh.ErrAPIDown).Once() client.On("GetWithContext", "/domain/zone").Return(nil, ovh.ErrAPIDown).Once()
zones, records, err = provider.zonesRecords(context.TODO()) zones, records, err = provider.zonesRecords(t.Context())
assert.Error(err) assert.Error(err)
assert.Nil(zones) assert.Nil(zones)
assert.Nil(records) assert.Nil(records)
@ -129,9 +137,9 @@ func TestOvhZoneRecords(t *testing.T) {
// Error on getting zone SOA // Error on getting zone SOA
t.Log("Error on getting zone SOA") t.Log("Error on getting zone SOA")
provider.cacheInstance = cache.New(cache.NoExpiration, cache.NoExpiration) provider.cacheInstance = cache.New(cache.NoExpiration, cache.NoExpiration)
client.On("Get", "/domain/zone").Return([]string{"example.org"}, nil).Once() client.On("GetWithContext", "/domain/zone").Return([]string{"example.org"}, nil).Once()
client.On("Get", "/domain/zone/example.org/soa").Return(nil, ovh.ErrAPIDown).Once() client.On("GetWithContext", "/domain/zone/example.org/soa").Return(nil, ovh.ErrAPIDown).Once()
zones, records, err = provider.zonesRecords(context.TODO()) zones, records, err = provider.zonesRecords(t.Context())
assert.Error(err) assert.Error(err)
assert.Nil(zones) assert.Nil(zones)
assert.Nil(records) assert.Nil(records)
@ -139,10 +147,10 @@ func TestOvhZoneRecords(t *testing.T) {
// Error on getting zone records // Error on getting zone records
t.Log("Error on getting zone records") t.Log("Error on getting zone records")
client.On("Get", "/domain/zone").Return([]string{"example.org"}, nil).Once() client.On("GetWithContext", "/domain/zone").Return([]string{"example.org"}, nil).Once()
client.On("Get", "/domain/zone/example.org/soa").Return(ovhSoa{Server: "ns.example.org.", Serial: 2022090902}, nil).Once() client.On("GetWithContext", "/domain/zone/example.org/soa").Return(ovhSoa{Server: "ns.example.org.", Serial: 2022090902}, nil).Once()
client.On("Get", "/domain/zone/example.org/record").Return(nil, ovh.ErrAPIDown).Once() client.On("GetWithContext", "/domain/zone/example.org/record").Return(nil, ovh.ErrAPIDown).Once()
zones, records, err = provider.zonesRecords(context.TODO()) zones, records, err = provider.zonesRecords(t.Context())
assert.Error(err) assert.Error(err)
assert.Nil(zones) assert.Nil(zones)
assert.Nil(records) assert.Nil(records)
@ -150,11 +158,11 @@ func TestOvhZoneRecords(t *testing.T) {
// Error on getting zone record detail // Error on getting zone record detail
t.Log("Error on getting zone record detail") t.Log("Error on getting zone record detail")
client.On("Get", "/domain/zone").Return([]string{"example.org"}, nil).Once() client.On("GetWithContext", "/domain/zone").Return([]string{"example.org"}, nil).Once()
client.On("Get", "/domain/zone/example.org/soa").Return(ovhSoa{Server: "ns.example.org.", Serial: 2022090902}, nil).Once() client.On("GetWithContext", "/domain/zone/example.org/soa").Return(ovhSoa{Server: "ns.example.org.", Serial: 2022090902}, nil).Once()
client.On("Get", "/domain/zone/example.org/record").Return([]uint64{42}, nil).Once() client.On("GetWithContext", "/domain/zone/example.org/record").Return([]uint64{42}, nil).Once()
client.On("Get", "/domain/zone/example.org/record/42").Return(nil, ovh.ErrAPIDown).Once() client.On("GetWithContext", "/domain/zone/example.org/record/42").Return(nil, ovh.ErrAPIDown).Once()
zones, records, err = provider.zonesRecords(context.TODO()) zones, records, err = provider.zonesRecords(t.Context())
assert.Error(err) assert.Error(err)
assert.Nil(zones) assert.Nil(zones)
assert.Nil(records) assert.Nil(records)
@ -169,16 +177,16 @@ func TestOvhZoneRecordsCache(t *testing.T) {
// First call, cache miss // First call, cache miss
t.Log("First call, cache miss") t.Log("First call, cache miss")
client.On("Get", "/domain/zone").Return([]string{"example.org"}, nil).Once() client.On("GetWithContext", "/domain/zone").Return([]string{"example.org"}, nil).Once()
client.On("Get", "/domain/zone/example.org/soa").Return(ovhSoa{Server: "ns.example.org.", Serial: 2022090901}, nil).Once() client.On("GetWithContext", "/domain/zone/example.org/soa").Return(ovhSoa{Server: "ns.example.org.", Serial: 2022090901}, nil).Once()
client.On("Get", "/domain/zone/example.org/record").Return([]uint64{24, 42}, nil).Once() client.On("GetWithContext", "/domain/zone/example.org/record").Return([]uint64{24, 42}, nil).Once()
client.On("Get", "/domain/zone/example.org/record/24").Return(ovhRecord{ID: 24, Zone: "example.org", ovhRecordFields: ovhRecordFields{SubDomain: "ovh", FieldType: "NS", TTL: 10, Target: "203.0.113.42"}}, nil).Once() client.On("GetWithContext", "/domain/zone/example.org/record/24").Return(ovhRecord{ID: 24, Zone: "example.org", ovhRecordFields: ovhRecordFields{FieldType: "NS", ovhRecordFieldUpdate: ovhRecordFieldUpdate{SubDomain: "ovh", TTL: 10, Target: "203.0.113.42"}}}, nil).Once()
client.On("Get", "/domain/zone/example.org/record/42").Return(ovhRecord{ID: 42, Zone: "example.org", ovhRecordFields: ovhRecordFields{SubDomain: "ovh", FieldType: "A", TTL: 10, Target: "203.0.113.42"}}, nil).Once() client.On("GetWithContext", "/domain/zone/example.org/record/42").Return(ovhRecord{ID: 42, Zone: "example.org", ovhRecordFields: ovhRecordFields{FieldType: "A", ovhRecordFieldUpdate: ovhRecordFieldUpdate{SubDomain: "ovh", TTL: 10, Target: "203.0.113.42"}}}, nil).Once()
zones, records, err := provider.zonesRecords(context.TODO()) zones, records, err := provider.zonesRecords(t.Context())
assert.NoError(err) assert.NoError(err)
assert.ElementsMatch(zones, []string{"example.org"}) assert.ElementsMatch(zones, []string{"example.org"})
assert.ElementsMatch(records, []ovhRecord{{ID: 42, Zone: "example.org", ovhRecordFields: ovhRecordFields{SubDomain: "ovh", FieldType: "A", TTL: 10, Target: "203.0.113.42"}}, {ID: 24, Zone: "example.org", ovhRecordFields: ovhRecordFields{SubDomain: "ovh", FieldType: "NS", TTL: 10, Target: "203.0.113.42"}}}) assert.ElementsMatch(records, []ovhRecord{{ID: 42, Zone: "example.org", ovhRecordFields: ovhRecordFields{FieldType: "A", ovhRecordFieldUpdate: ovhRecordFieldUpdate{SubDomain: "ovh", TTL: 10, Target: "203.0.113.42"}}}, {ID: 24, Zone: "example.org", ovhRecordFields: ovhRecordFields{FieldType: "NS", ovhRecordFieldUpdate: ovhRecordFieldUpdate{SubDomain: "ovh", TTL: 10, Target: "203.0.113.42"}}}})
client.AssertExpectations(t) client.AssertExpectations(t)
dnsClient.AssertExpectations(t) dnsClient.AssertExpectations(t)
@ -189,13 +197,13 @@ func TestOvhZoneRecordsCache(t *testing.T) {
// second call, cache hit // second call, cache hit
t.Log("second call, cache hit") t.Log("second call, cache hit")
client.On("Get", "/domain/zone").Return([]string{"example.org"}, nil).Once() client.On("GetWithContext", "/domain/zone").Return([]string{"example.org"}, nil).Once()
dnsClient.On("ExchangeContext", mock.AnythingOfType("*context.cancelCtx"), mock.AnythingOfType("*dns.Msg"), "ns.example.org:53"). dnsClient.On("ExchangeContext", mock.AnythingOfType("*context.cancelCtx"), mock.AnythingOfType("*dns.Msg"), "ns.example.org:53").
Return(&dns.Msg{Answer: []dns.RR{&dns.SOA{Serial: 2022090901}}}, nil) Return(&dns.Msg{Answer: []dns.RR{&dns.SOA{Serial: 2022090901}}}, nil)
zones, records, err = provider.zonesRecords(context.TODO()) zones, records, err = provider.zonesRecords(t.Context())
assert.NoError(err) assert.NoError(err)
assert.ElementsMatch(zones, []string{"example.org"}) assert.ElementsMatch(zones, []string{"example.org"})
assert.ElementsMatch(records, []ovhRecord{{ID: 42, Zone: "example.org", ovhRecordFields: ovhRecordFields{SubDomain: "ovh", FieldType: "A", TTL: 10, Target: "203.0.113.42"}}, {ID: 24, Zone: "example.org", ovhRecordFields: ovhRecordFields{SubDomain: "ovh", FieldType: "NS", TTL: 10, Target: "203.0.113.42"}}}) assert.ElementsMatch(records, []ovhRecord{{ID: 42, Zone: "example.org", ovhRecordFields: ovhRecordFields{FieldType: "A", ovhRecordFieldUpdate: ovhRecordFieldUpdate{SubDomain: "ovh", TTL: 10, Target: "203.0.113.42"}}}, {ID: 24, Zone: "example.org", ovhRecordFields: ovhRecordFields{FieldType: "NS", ovhRecordFieldUpdate: ovhRecordFieldUpdate{SubDomain: "ovh", TTL: 10, Target: "203.0.113.42"}}}})
client.AssertExpectations(t) client.AssertExpectations(t)
dnsClient.AssertExpectations(t) dnsClient.AssertExpectations(t)
@ -206,17 +214,17 @@ func TestOvhZoneRecordsCache(t *testing.T) {
// third call, cache out of date // third call, cache out of date
t.Log("third call, cache out of date") t.Log("third call, cache out of date")
client.On("Get", "/domain/zone").Return([]string{"example.org"}, nil).Once() client.On("GetWithContext", "/domain/zone").Return([]string{"example.org"}, nil).Once()
dnsClient.On("ExchangeContext", mock.AnythingOfType("*context.cancelCtx"), mock.AnythingOfType("*dns.Msg"), "ns.example.org:53"). dnsClient.On("ExchangeContext", mock.AnythingOfType("*context.cancelCtx"), mock.AnythingOfType("*dns.Msg"), "ns.example.org:53").
Return(&dns.Msg{Answer: []dns.RR{&dns.SOA{Serial: 2022090902}}}, nil) Return(&dns.Msg{Answer: []dns.RR{&dns.SOA{Serial: 2022090902}}}, nil)
client.On("Get", "/domain/zone/example.org/soa").Return(ovhSoa{Server: "ns.example.org.", Serial: 2022090902}, nil).Once() client.On("GetWithContext", "/domain/zone/example.org/soa").Return(ovhSoa{Server: "ns.example.org.", Serial: 2022090902}, nil).Once()
client.On("Get", "/domain/zone/example.org/record").Return([]uint64{24}, nil).Once() client.On("GetWithContext", "/domain/zone/example.org/record").Return([]uint64{24}, nil).Once()
client.On("Get", "/domain/zone/example.org/record/24").Return(ovhRecord{ID: 24, Zone: "example.org", ovhRecordFields: ovhRecordFields{SubDomain: "ovh", FieldType: "NS", TTL: 10, Target: "203.0.113.42"}}, nil).Once() client.On("GetWithContext", "/domain/zone/example.org/record/24").Return(ovhRecord{ID: 24, Zone: "example.org", ovhRecordFields: ovhRecordFields{FieldType: "NS", ovhRecordFieldUpdate: ovhRecordFieldUpdate{SubDomain: "ovh", TTL: 10, Target: "203.0.113.42"}}}, nil).Once()
zones, records, err = provider.zonesRecords(context.TODO()) zones, records, err = provider.zonesRecords(t.Context())
assert.NoError(err) assert.NoError(err)
assert.ElementsMatch(zones, []string{"example.org"}) assert.ElementsMatch(zones, []string{"example.org"})
assert.ElementsMatch(records, []ovhRecord{{ID: 24, Zone: "example.org", ovhRecordFields: ovhRecordFields{SubDomain: "ovh", FieldType: "NS", TTL: 10, Target: "203.0.113.42"}}}) assert.ElementsMatch(records, []ovhRecord{{ID: 24, Zone: "example.org", ovhRecordFields: ovhRecordFields{FieldType: "NS", ovhRecordFieldUpdate: ovhRecordFieldUpdate{SubDomain: "ovh", TTL: 10, Target: "203.0.113.42"}}}})
client.AssertExpectations(t) client.AssertExpectations(t)
dnsClient.AssertExpectations(t) dnsClient.AssertExpectations(t)
@ -227,14 +235,14 @@ func TestOvhZoneRecordsCache(t *testing.T) {
// fourth call, cache hit // fourth call, cache hit
t.Log("fourth call, cache hit") t.Log("fourth call, cache hit")
client.On("Get", "/domain/zone").Return([]string{"example.org"}, nil).Once() client.On("GetWithContext", "/domain/zone").Return([]string{"example.org"}, nil).Once()
dnsClient.On("ExchangeContext", mock.AnythingOfType("*context.cancelCtx"), mock.AnythingOfType("*dns.Msg"), "ns.example.org:53"). dnsClient.On("ExchangeContext", mock.AnythingOfType("*context.cancelCtx"), mock.AnythingOfType("*dns.Msg"), "ns.example.org:53").
Return(&dns.Msg{Answer: []dns.RR{&dns.SOA{Serial: 2022090902}}}, nil) Return(&dns.Msg{Answer: []dns.RR{&dns.SOA{Serial: 2022090902}}}, nil)
zones, records, err = provider.zonesRecords(context.TODO()) zones, records, err = provider.zonesRecords(t.Context())
assert.NoError(err) assert.NoError(err)
assert.ElementsMatch(zones, []string{"example.org"}) assert.ElementsMatch(zones, []string{"example.org"})
assert.ElementsMatch(records, []ovhRecord{{ID: 24, Zone: "example.org", ovhRecordFields: ovhRecordFields{SubDomain: "ovh", FieldType: "NS", TTL: 10, Target: "203.0.113.42"}}}) assert.ElementsMatch(records, []ovhRecord{{ID: 24, Zone: "example.org", ovhRecordFields: ovhRecordFields{FieldType: "NS", ovhRecordFieldUpdate: ovhRecordFieldUpdate{SubDomain: "ovh", TTL: 10, Target: "203.0.113.42"}}}})
client.AssertExpectations(t) client.AssertExpectations(t)
dnsClient.AssertExpectations(t) dnsClient.AssertExpectations(t)
@ -245,18 +253,18 @@ func TestOvhZoneRecordsCache(t *testing.T) {
// fifth call, dns issue // fifth call, dns issue
t.Log("fourth call, cache hit") t.Log("fourth call, cache hit")
client.On("Get", "/domain/zone").Return([]string{"example.org"}, nil).Once() client.On("GetWithContext", "/domain/zone").Return([]string{"example.org"}, nil).Once()
dnsClient.On("ExchangeContext", mock.AnythingOfType("*context.cancelCtx"), mock.AnythingOfType("*dns.Msg"), "ns.example.org:53"). dnsClient.On("ExchangeContext", mock.AnythingOfType("*context.cancelCtx"), mock.AnythingOfType("*dns.Msg"), "ns.example.org:53").
Return(&dns.Msg{Answer: []dns.RR{}}, errors.New("dns issue")) Return(&dns.Msg{Answer: []dns.RR{}}, errors.New("dns issue"))
client.On("Get", "/domain/zone/example.org/soa").Return(ovhSoa{Server: "ns.example.org.", Serial: 2022090903}, nil).Once() client.On("GetWithContext", "/domain/zone/example.org/soa").Return(ovhSoa{Server: "ns.example.org.", Serial: 2022090903}, nil).Once()
client.On("Get", "/domain/zone/example.org/record").Return([]uint64{24, 42}, nil).Once() client.On("GetWithContext", "/domain/zone/example.org/record").Return([]uint64{24, 42}, nil).Once()
client.On("Get", "/domain/zone/example.org/record/24").Return(ovhRecord{ID: 24, Zone: "example.org", ovhRecordFields: ovhRecordFields{SubDomain: "ovh", FieldType: "NS", TTL: 10, Target: "203.0.113.42"}}, nil).Once() client.On("GetWithContext", "/domain/zone/example.org/record/24").Return(ovhRecord{ID: 24, Zone: "example.org", ovhRecordFields: ovhRecordFields{FieldType: "NS", ovhRecordFieldUpdate: ovhRecordFieldUpdate{SubDomain: "ovh", TTL: 10, Target: "203.0.113.42"}}}, nil).Once()
client.On("Get", "/domain/zone/example.org/record/42").Return(ovhRecord{ID: 42, Zone: "example.org", ovhRecordFields: ovhRecordFields{SubDomain: "ovh", FieldType: "A", TTL: 10, Target: "203.0.113.42"}}, nil).Once() client.On("GetWithContext", "/domain/zone/example.org/record/42").Return(ovhRecord{ID: 42, Zone: "example.org", ovhRecordFields: ovhRecordFields{FieldType: "A", ovhRecordFieldUpdate: ovhRecordFieldUpdate{SubDomain: "ovh", TTL: 10, Target: "203.0.113.42"}}}, nil).Once()
zones, records, err = provider.zonesRecords(context.TODO()) zones, records, err = provider.zonesRecords(t.Context())
assert.NoError(err) assert.NoError(err)
assert.ElementsMatch(zones, []string{"example.org"}) assert.ElementsMatch(zones, []string{"example.org"})
assert.ElementsMatch(records, []ovhRecord{{ID: 42, Zone: "example.org", ovhRecordFields: ovhRecordFields{SubDomain: "ovh", FieldType: "A", TTL: 10, Target: "203.0.113.42"}}, {ID: 24, Zone: "example.org", ovhRecordFields: ovhRecordFields{SubDomain: "ovh", FieldType: "NS", TTL: 10, Target: "203.0.113.42"}}}) assert.ElementsMatch(records, []ovhRecord{{ID: 42, Zone: "example.org", ovhRecordFields: ovhRecordFields{FieldType: "A", ovhRecordFieldUpdate: ovhRecordFieldUpdate{SubDomain: "ovh", TTL: 10, Target: "203.0.113.42"}}}, {ID: 24, Zone: "example.org", ovhRecordFields: ovhRecordFields{FieldType: "NS", ovhRecordFieldUpdate: ovhRecordFieldUpdate{SubDomain: "ovh", TTL: 10, Target: "203.0.113.42"}}}})
client.AssertExpectations(t) client.AssertExpectations(t)
dnsClient.AssertExpectations(t) dnsClient.AssertExpectations(t)
} }
@ -267,14 +275,14 @@ func TestOvhRecords(t *testing.T) {
provider := &OVHProvider{client: client, apiRateLimiter: ratelimit.New(10), cacheInstance: cache.New(cache.NoExpiration, cache.NoExpiration)} provider := &OVHProvider{client: client, apiRateLimiter: ratelimit.New(10), cacheInstance: cache.New(cache.NoExpiration, cache.NoExpiration)}
// Basic zones records // Basic zones records
client.On("Get", "/domain/zone").Return([]string{"example.org", "example.net"}, nil).Once() client.On("GetWithContext", "/domain/zone").Return([]string{"example.org", "example.net"}, nil).Once()
client.On("Get", "/domain/zone/example.org/record").Return([]uint64{24, 42}, nil).Once() client.On("GetWithContext", "/domain/zone/example.org/record").Return([]uint64{24, 42}, nil).Once()
client.On("Get", "/domain/zone/example.org/record/24").Return(ovhRecord{ID: 24, Zone: "example.org", ovhRecordFields: ovhRecordFields{SubDomain: "", FieldType: "A", TTL: 10, Target: "203.0.113.42"}}, nil).Once() client.On("GetWithContext", "/domain/zone/example.org/record/24").Return(ovhRecord{ID: 24, Zone: "example.org", ovhRecordFields: ovhRecordFields{FieldType: "A", ovhRecordFieldUpdate: ovhRecordFieldUpdate{SubDomain: "", TTL: 10, Target: "203.0.113.42"}}}, nil).Once()
client.On("Get", "/domain/zone/example.org/record/42").Return(ovhRecord{ID: 42, Zone: "example.org", ovhRecordFields: ovhRecordFields{SubDomain: "www", FieldType: "CNAME", TTL: 10, Target: "example.org."}}, nil).Once() client.On("GetWithContext", "/domain/zone/example.org/record/42").Return(ovhRecord{ID: 42, Zone: "example.org", ovhRecordFields: ovhRecordFields{FieldType: "CNAME", ovhRecordFieldUpdate: ovhRecordFieldUpdate{SubDomain: "www", TTL: 10, Target: "example.org."}}}, nil).Once()
client.On("Get", "/domain/zone/example.net/record").Return([]uint64{24, 42}, nil).Once() client.On("GetWithContext", "/domain/zone/example.net/record").Return([]uint64{24, 42}, nil).Once()
client.On("Get", "/domain/zone/example.net/record/24").Return(ovhRecord{ID: 24, Zone: "example.net", ovhRecordFields: ovhRecordFields{SubDomain: "ovh", FieldType: "A", TTL: 10, Target: "203.0.113.42"}}, nil).Once() client.On("GetWithContext", "/domain/zone/example.net/record/24").Return(ovhRecord{ID: 24, Zone: "example.net", ovhRecordFields: ovhRecordFields{FieldType: "A", ovhRecordFieldUpdate: ovhRecordFieldUpdate{SubDomain: "ovh", TTL: 10, Target: "203.0.113.42"}}}, nil).Once()
client.On("Get", "/domain/zone/example.net/record/42").Return(ovhRecord{ID: 42, Zone: "example.net", ovhRecordFields: ovhRecordFields{SubDomain: "ovh", FieldType: "A", TTL: 10, Target: "203.0.113.43"}}, nil).Once() client.On("GetWithContext", "/domain/zone/example.net/record/42").Return(ovhRecord{ID: 42, Zone: "example.net", ovhRecordFields: ovhRecordFields{FieldType: "A", ovhRecordFieldUpdate: ovhRecordFieldUpdate{SubDomain: "ovh", TTL: 10, Target: "203.0.113.43"}}}, nil).Once()
endpoints, err := provider.Records(context.TODO()) endpoints, err := provider.Records(t.Context())
assert.NoError(err) assert.NoError(err)
// Little fix for multi targets endpoint // Little fix for multi targets endpoint
for _, endpoint := range endpoints { for _, endpoint := range endpoints {
@ -288,25 +296,70 @@ func TestOvhRecords(t *testing.T) {
client.AssertExpectations(t) client.AssertExpectations(t)
// Error getting zone // Error getting zone
client.On("Get", "/domain/zone").Return(nil, ovh.ErrAPIDown).Once() client.On("GetWithContext", "/domain/zone").Return(nil, ovh.ErrAPIDown).Once()
endpoints, err = provider.Records(context.TODO()) endpoints, err = provider.Records(t.Context())
assert.Error(err) assert.Error(err)
assert.Nil(endpoints) assert.Nil(endpoints)
client.AssertExpectations(t) client.AssertExpectations(t)
} }
func TestOvhComputeChanges(t *testing.T) {
existingRecords := []ovhRecord{
{
ID: 1,
Zone: "example.net",
ovhRecordFields: ovhRecordFields{
FieldType: "A",
ovhRecordFieldUpdate: ovhRecordFieldUpdate{
SubDomain: "",
Target: "203.0.113.42",
},
},
},
}
changes := plan.Changes{
UpdateOld: []*endpoint.Endpoint{
{DNSName: "example.net", RecordType: "A", Targets: []string{"203.0.113.42"}},
},
UpdateNew: []*endpoint.Endpoint{
{DNSName: "example.net", RecordType: "A", Targets: []string{"203.0.113.43", "203.0.113.42"}},
},
}
provider := &OVHProvider{client: nil, apiRateLimiter: ratelimit.New(10), cacheInstance: cache.New(cache.NoExpiration, cache.NoExpiration)}
ovhChanges := provider.computeSingleZoneChanges(t.Context(), "example.net", existingRecords, &changes)
td.Cmp(t, ovhChanges, []ovhChange{
{
Action: ovhCreate,
ovhRecord: ovhRecord{
Zone: "example.net",
ovhRecordFields: ovhRecordFields{
FieldType: "A",
ovhRecordFieldUpdate: ovhRecordFieldUpdate{
SubDomain: "",
Target: "203.0.113.43",
},
},
},
},
})
}
func TestOvhRefresh(t *testing.T) { func TestOvhRefresh(t *testing.T) {
client := new(mockOvhClient) client := new(mockOvhClient)
provider := &OVHProvider{client: client, apiRateLimiter: ratelimit.New(10), cacheInstance: cache.New(cache.NoExpiration, cache.NoExpiration)} provider := &OVHProvider{client: client, apiRateLimiter: ratelimit.New(10), cacheInstance: cache.New(cache.NoExpiration, cache.NoExpiration)}
// Basic zone refresh // Basic zone refresh
client.On("Post", "/domain/zone/example.net/refresh", nil).Return(nil, nil).Once() client.On("PostWithContext", "/domain/zone/example.net/refresh", nil).Return(nil, nil).Once()
provider.refresh("example.net") provider.refresh(t.Context(), "example.net")
client.AssertExpectations(t) client.AssertExpectations(t)
} }
func TestOvhNewChange(t *testing.T) { func TestOvhNewChange(t *testing.T) {
assert := assert.New(t) provider := &OVHProvider{client: nil, apiRateLimiter: ratelimit.New(10), cacheInstance: cache.New(cache.NoExpiration, cache.NoExpiration)}
endpoints := []*endpoint.Endpoint{ endpoints := []*endpoint.Endpoint{
{DNSName: ".example.net", RecordType: "A", RecordTTL: 10, Targets: []string{"203.0.113.42"}}, {DNSName: ".example.net", RecordType: "A", RecordTTL: 10, Targets: []string{"203.0.113.42"}},
{DNSName: "ovh.example.net", RecordType: "A", Targets: []string{"203.0.113.43"}}, {DNSName: "ovh.example.net", RecordType: "A", Targets: []string{"203.0.113.43"}},
@ -315,11 +368,11 @@ func TestOvhNewChange(t *testing.T) {
} }
// Create change // Create change
changes := newOvhChange(ovhCreate, endpoints, []string{"example.net"}, []ovhRecord{}) changes, _ := provider.newOvhChangeCreateDelete(ovhCreate, endpoints, "example.net", []ovhRecord{})
assert.ElementsMatch(changes, []ovhChange{ td.Cmp(t, changes, []ovhChange{
{Action: ovhCreate, ovhRecord: ovhRecord{Zone: "example.net", ovhRecordFields: ovhRecordFields{SubDomain: "", FieldType: "A", TTL: 10, Target: "203.0.113.42"}}}, {Action: ovhCreate, ovhRecord: ovhRecord{Zone: "example.net", ovhRecordFields: ovhRecordFields{FieldType: "A", ovhRecordFieldUpdate: ovhRecordFieldUpdate{SubDomain: "", TTL: 10, Target: "203.0.113.42"}}}},
{Action: ovhCreate, ovhRecord: ovhRecord{Zone: "example.net", ovhRecordFields: ovhRecordFields{SubDomain: "ovh", FieldType: "A", TTL: ovhDefaultTTL, Target: "203.0.113.43"}}}, {Action: ovhCreate, ovhRecord: ovhRecord{Zone: "example.net", ovhRecordFields: ovhRecordFields{FieldType: "A", ovhRecordFieldUpdate: ovhRecordFieldUpdate{SubDomain: "ovh", TTL: ovhDefaultTTL, Target: "203.0.113.43"}}}},
{Action: ovhCreate, ovhRecord: ovhRecord{Zone: "example.net", ovhRecordFields: ovhRecordFields{SubDomain: "ovh2", FieldType: "CNAME", TTL: ovhDefaultTTL, Target: "ovh.example.net."}}}, {Action: ovhCreate, ovhRecord: ovhRecord{Zone: "example.net", ovhRecordFields: ovhRecordFields{FieldType: "CNAME", ovhRecordFieldUpdate: ovhRecordFieldUpdate{SubDomain: "ovh2", TTL: ovhDefaultTTL, Target: "ovh.example.net."}}}},
}) })
// Delete change // Delete change
@ -327,70 +380,196 @@ func TestOvhNewChange(t *testing.T) {
{DNSName: "ovh.example.net", RecordType: "A", Targets: []string{"203.0.113.42", "203.0.113.42", "203.0.113.42"}}, {DNSName: "ovh.example.net", RecordType: "A", Targets: []string{"203.0.113.42", "203.0.113.42", "203.0.113.42"}},
} }
records := []ovhRecord{ records := []ovhRecord{
{ID: 42, Zone: "example.net", ovhRecordFields: ovhRecordFields{FieldType: "A", SubDomain: "ovh", Target: "203.0.113.42"}}, {ID: 42, Zone: "example.net", ovhRecordFields: ovhRecordFields{FieldType: "A", ovhRecordFieldUpdate: ovhRecordFieldUpdate{SubDomain: "ovh", Target: "203.0.113.42"}}},
{ID: 43, Zone: "example.net", ovhRecordFields: ovhRecordFields{FieldType: "A", SubDomain: "ovh", Target: "203.0.113.42"}}, {ID: 43, Zone: "example.net", ovhRecordFields: ovhRecordFields{FieldType: "A", ovhRecordFieldUpdate: ovhRecordFieldUpdate{SubDomain: "ovh", Target: "203.0.113.42"}}},
{ID: 44, Zone: "example.net", ovhRecordFields: ovhRecordFields{FieldType: "A", SubDomain: "ovh", Target: "203.0.113.42"}}, {ID: 44, Zone: "example.net", ovhRecordFields: ovhRecordFields{FieldType: "A", ovhRecordFieldUpdate: ovhRecordFieldUpdate{SubDomain: "ovh", Target: "203.0.113.42"}}},
} }
changes = newOvhChange(ovhDelete, endpoints, []string{"example.net"}, records) changes, _ = provider.newOvhChangeCreateDelete(ovhDelete, endpoints, "example.net", records)
assert.ElementsMatch(changes, []ovhChange{ td.Cmp(t, changes, []ovhChange{
{Action: ovhDelete, ovhRecord: ovhRecord{ID: 42, Zone: "example.net", ovhRecordFields: ovhRecordFields{SubDomain: "ovh", FieldType: "A", TTL: ovhDefaultTTL, Target: "203.0.113.42"}}}, {Action: ovhDelete, ovhRecord: ovhRecord{ID: 42, Zone: "example.net", ovhRecordFields: ovhRecordFields{FieldType: "A", ovhRecordFieldUpdate: ovhRecordFieldUpdate{SubDomain: "ovh", TTL: ovhDefaultTTL, Target: "203.0.113.42"}}}},
{Action: ovhDelete, ovhRecord: ovhRecord{ID: 43, Zone: "example.net", ovhRecordFields: ovhRecordFields{SubDomain: "ovh", FieldType: "A", TTL: ovhDefaultTTL, Target: "203.0.113.42"}}}, {Action: ovhDelete, ovhRecord: ovhRecord{ID: 43, Zone: "example.net", ovhRecordFields: ovhRecordFields{FieldType: "A", ovhRecordFieldUpdate: ovhRecordFieldUpdate{SubDomain: "ovh", TTL: ovhDefaultTTL, Target: "203.0.113.42"}}}},
{Action: ovhDelete, ovhRecord: ovhRecord{ID: 44, Zone: "example.net", ovhRecordFields: ovhRecordFields{SubDomain: "ovh", FieldType: "A", TTL: ovhDefaultTTL, Target: "203.0.113.42"}}}, {Action: ovhDelete, ovhRecord: ovhRecord{ID: 44, Zone: "example.net", ovhRecordFields: ovhRecordFields{FieldType: "A", ovhRecordFieldUpdate: ovhRecordFieldUpdate{SubDomain: "ovh", TTL: ovhDefaultTTL, Target: "203.0.113.42"}}}},
})
// Create change with CNAME relative
endpoints = []*endpoint.Endpoint{
{DNSName: ".example.net", RecordType: "A", RecordTTL: 10, Targets: []string{"203.0.113.42"}},
{DNSName: "ovh.example.net", RecordType: "A", Targets: []string{"203.0.113.43"}},
{DNSName: "ovh2.example.net", RecordType: "CNAME", Targets: []string{"ovh"}},
{DNSName: "test.example.org"},
}
provider = &OVHProvider{client: nil, EnableCNAMERelativeTarget: true, apiRateLimiter: ratelimit.New(10), cacheInstance: cache.New(cache.NoExpiration, cache.NoExpiration)}
changes, _ = provider.newOvhChangeCreateDelete(ovhCreate, endpoints, "example.net", []ovhRecord{})
td.Cmp(t, changes, []ovhChange{
{Action: ovhCreate, ovhRecord: ovhRecord{Zone: "example.net", ovhRecordFields: ovhRecordFields{FieldType: "A", ovhRecordFieldUpdate: ovhRecordFieldUpdate{SubDomain: "", TTL: 10, Target: "203.0.113.42"}}}},
{Action: ovhCreate, ovhRecord: ovhRecord{Zone: "example.net", ovhRecordFields: ovhRecordFields{FieldType: "A", ovhRecordFieldUpdate: ovhRecordFieldUpdate{SubDomain: "ovh", TTL: ovhDefaultTTL, Target: "203.0.113.43"}}}},
{Action: ovhCreate, ovhRecord: ovhRecord{Zone: "example.net", ovhRecordFields: ovhRecordFields{FieldType: "CNAME", ovhRecordFieldUpdate: ovhRecordFieldUpdate{SubDomain: "ovh2", TTL: ovhDefaultTTL, Target: "ovh"}}}},
})
// Test with CNAME when target has already final dot
endpoints = []*endpoint.Endpoint{
{DNSName: ".example.net", RecordType: "A", RecordTTL: 10, Targets: []string{"203.0.113.42"}},
{DNSName: "ovh.example.net", RecordType: "A", Targets: []string{"203.0.113.43"}},
{DNSName: "ovh2.example.net", RecordType: "CNAME", Targets: []string{"ovh.example.com."}},
{DNSName: "test.example.org"},
}
provider = &OVHProvider{client: nil, EnableCNAMERelativeTarget: false, apiRateLimiter: ratelimit.New(10), cacheInstance: cache.New(cache.NoExpiration, cache.NoExpiration)}
changes, _ = provider.newOvhChangeCreateDelete(ovhCreate, endpoints, "example.net", []ovhRecord{})
td.Cmp(t, changes, []ovhChange{
{Action: ovhCreate, ovhRecord: ovhRecord{Zone: "example.net", ovhRecordFields: ovhRecordFields{FieldType: "A", ovhRecordFieldUpdate: ovhRecordFieldUpdate{SubDomain: "", TTL: 10, Target: "203.0.113.42"}}}},
{Action: ovhCreate, ovhRecord: ovhRecord{Zone: "example.net", ovhRecordFields: ovhRecordFields{FieldType: "A", ovhRecordFieldUpdate: ovhRecordFieldUpdate{SubDomain: "ovh", TTL: ovhDefaultTTL, Target: "203.0.113.43"}}}},
{Action: ovhCreate, ovhRecord: ovhRecord{Zone: "example.net", ovhRecordFields: ovhRecordFields{FieldType: "CNAME", ovhRecordFieldUpdate: ovhRecordFieldUpdate{SubDomain: "ovh2", TTL: ovhDefaultTTL, Target: "ovh.example.com."}}}},
}) })
} }
func TestOvhApplyChanges(t *testing.T) { func TestOvhApplyChanges(t *testing.T) {
assert := assert.New(t)
client := new(mockOvhClient) client := new(mockOvhClient)
provider := &OVHProvider{client: client, apiRateLimiter: ratelimit.New(10), cacheInstance: cache.New(cache.NoExpiration, cache.NoExpiration)} provider := &OVHProvider{client: client, apiRateLimiter: ratelimit.New(10), cacheInstance: cache.New(cache.NoExpiration, cache.NoExpiration)}
changes := plan.Changes{ changes := plan.Changes{
Create: []*endpoint.Endpoint{ Create: []*endpoint.Endpoint{
{DNSName: ".example.net", RecordType: "A", RecordTTL: 10, Targets: []string{"203.0.113.42"}}, {DNSName: "example.net", RecordType: "A", RecordTTL: 10, Targets: []string{"203.0.113.42"}},
}, },
Delete: []*endpoint.Endpoint{ Delete: []*endpoint.Endpoint{
{DNSName: "ovh.example.net", RecordType: "A", Targets: []string{"203.0.113.43"}}, {DNSName: "ovh.example.net", RecordType: "A", Targets: []string{"203.0.113.43"}},
}, },
} }
client.On("Get", "/domain/zone").Return([]string{"example.net"}, nil).Once() client.On("GetWithContext", "/domain/zone").Return([]string{"example.net"}, nil).Once()
client.On("Get", "/domain/zone/example.net/record").Return([]uint64{42}, nil).Once() client.On("GetWithContext", "/domain/zone/example.net/record").Return([]uint64{42}, nil).Once()
client.On("Get", "/domain/zone/example.net/record/42").Return(ovhRecord{ID: 42, Zone: "example.net", ovhRecordFields: ovhRecordFields{SubDomain: "ovh", FieldType: "A", TTL: 10, Target: "203.0.113.43"}}, nil).Once() client.On("GetWithContext", "/domain/zone/example.net/record/42").Return(ovhRecord{ID: 42, Zone: "example.net", ovhRecordFields: ovhRecordFields{FieldType: "A", ovhRecordFieldUpdate: ovhRecordFieldUpdate{SubDomain: "ovh", TTL: 10, Target: "203.0.113.43"}}}, nil).Once()
client.On("Post", "/domain/zone/example.net/record", ovhRecordFields{SubDomain: "", FieldType: "A", TTL: 10, Target: "203.0.113.42"}).Return(nil, nil).Once() client.On("PostWithContext", "/domain/zone/example.net/record", ovhRecordFields{FieldType: "A", ovhRecordFieldUpdate: ovhRecordFieldUpdate{SubDomain: "", TTL: 10, Target: "203.0.113.42"}}).Return(nil, nil).Once()
client.On("Delete", "/domain/zone/example.net/record/42").Return(nil, nil).Once() client.On("DeleteWithContext", "/domain/zone/example.net/record/42").Return(nil, nil).Once()
client.On("Post", "/domain/zone/example.net/refresh", nil).Return(nil, nil).Once() client.On("PostWithContext", "/domain/zone/example.net/refresh", nil).Return(nil, nil).Once()
_, err := provider.Records(t.Context())
td.CmpNoError(t, err)
// Basic changes // Basic changes
assert.NoError(provider.ApplyChanges(context.TODO(), &changes)) td.CmpNoError(t, provider.ApplyChanges(t.Context(), &changes))
client.AssertExpectations(t)
// Getting zones failed
client.On("Get", "/domain/zone").Return(nil, ovh.ErrAPIDown).Once()
assert.Error(provider.ApplyChanges(context.TODO(), &changes))
client.AssertExpectations(t) client.AssertExpectations(t)
// Apply change failed // Apply change failed
client.On("Get", "/domain/zone").Return([]string{"example.net"}, nil).Once() client = new(mockOvhClient)
client.On("Get", "/domain/zone/example.net/record").Return([]uint64{}, nil).Once() provider.client = client
client.On("Post", "/domain/zone/example.net/record", ovhRecordFields{SubDomain: "", FieldType: "A", TTL: 10, Target: "203.0.113.42"}).Return(nil, ovh.ErrAPIDown).Once() client.On("GetWithContext", "/domain/zone").Return([]string{"example.net"}, nil).Once()
client.On("Post", "/domain/zone/example.net/refresh", nil).Return(nil, nil).Once() client.On("GetWithContext", "/domain/zone/example.net/record").Return([]uint64{}, nil).Once()
assert.Error(provider.ApplyChanges(context.TODO(), &plan.Changes{ client.On("PostWithContext", "/domain/zone/example.net/record", ovhRecordFields{FieldType: "A", ovhRecordFieldUpdate: ovhRecordFieldUpdate{SubDomain: "", TTL: 10, Target: "203.0.113.42"}}).Return(nil, ovh.ErrAPIDown).Once()
_, err = provider.Records(t.Context())
td.CmpNoError(t, err)
td.CmpError(t, provider.ApplyChanges(t.Context(), &plan.Changes{
Create: []*endpoint.Endpoint{ Create: []*endpoint.Endpoint{
{DNSName: ".example.net", RecordType: "A", RecordTTL: 10, Targets: []string{"203.0.113.42"}}, {DNSName: "example.net", RecordType: "A", RecordTTL: 10, Targets: []string{"203.0.113.42"}},
}, },
})) }))
client.AssertExpectations(t) client.AssertExpectations(t)
// Refresh failed // Refresh failed
client.On("Get", "/domain/zone").Return([]string{"example.net"}, nil).Once() client = new(mockOvhClient)
client.On("Get", "/domain/zone/example.net/record").Return([]uint64{}, nil).Once() provider.client = client
client.On("Post", "/domain/zone/example.net/record", ovhRecordFields{SubDomain: "", FieldType: "A", TTL: 10, Target: "203.0.113.42"}).Return(nil, nil).Once() client.On("GetWithContext", "/domain/zone").Return([]string{"example.net"}, nil).Once()
client.On("Post", "/domain/zone/example.net/refresh", nil).Return(nil, ovh.ErrAPIDown).Once() client.On("GetWithContext", "/domain/zone/example.net/record").Return([]uint64{}, nil).Once()
assert.Error(provider.ApplyChanges(context.TODO(), &plan.Changes{ client.On("PostWithContext", "/domain/zone/example.net/record", ovhRecordFields{FieldType: "A", ovhRecordFieldUpdate: ovhRecordFieldUpdate{SubDomain: "", TTL: 10, Target: "203.0.113.42"}}).Return(nil, nil).Once()
client.On("PostWithContext", "/domain/zone/example.net/refresh", nil).Return(nil, ovh.ErrAPIDown).Once()
_, err = provider.Records(t.Context())
td.CmpNoError(t, err)
td.CmpError(t, provider.ApplyChanges(t.Context(), &plan.Changes{
Create: []*endpoint.Endpoint{ Create: []*endpoint.Endpoint{
{DNSName: ".example.net", RecordType: "A", RecordTTL: 10, Targets: []string{"203.0.113.42"}}, {DNSName: "example.net", RecordType: "A", RecordTTL: 10, Targets: []string{"203.0.113.42"}},
}, },
})) }))
client.AssertExpectations(t) client.AssertExpectations(t)
// Test Dry-Run
client = new(mockOvhClient)
provider = &OVHProvider{client: client, apiRateLimiter: ratelimit.New(10), cacheInstance: cache.New(cache.NoExpiration, cache.NoExpiration), DryRun: true}
changes = plan.Changes{
Create: []*endpoint.Endpoint{
{DNSName: "example.net", RecordType: "A", RecordTTL: 10, Targets: []string{"203.0.113.42"}},
},
Delete: []*endpoint.Endpoint{
{DNSName: "ovh.example.net", RecordType: "A", Targets: []string{"203.0.113.43"}},
},
}
client.On("GetWithContext", "/domain/zone").Return([]string{"example.net"}, nil).Once()
client.On("GetWithContext", "/domain/zone/example.net/record").Return([]uint64{42}, nil).Once()
client.On("GetWithContext", "/domain/zone/example.net/record/42").Return(ovhRecord{ID: 42, Zone: "example.net", ovhRecordFields: ovhRecordFields{FieldType: "A", ovhRecordFieldUpdate: ovhRecordFieldUpdate{SubDomain: "ovh", TTL: 10, Target: "203.0.113.43"}}}, nil).Once()
_, err = provider.Records(t.Context())
td.CmpNoError(t, err)
td.CmpNoError(t, provider.ApplyChanges(t.Context(), &changes))
client.AssertExpectations(t)
// Test Update
client = new(mockOvhClient)
provider = &OVHProvider{client: client, apiRateLimiter: ratelimit.New(10), cacheInstance: cache.New(cache.NoExpiration, cache.NoExpiration), DryRun: false}
changes = plan.Changes{
UpdateOld: []*endpoint.Endpoint{
{DNSName: "example.net", RecordType: "A", RecordTTL: 10, Targets: []string{"203.0.113.42"}},
},
UpdateNew: []*endpoint.Endpoint{
{DNSName: "example.net", RecordType: "A", RecordTTL: 10, Targets: []string{"203.0.113.43"}},
},
}
client.On("GetWithContext", "/domain/zone").Return([]string{"example.net"}, nil).Once()
client.On("GetWithContext", "/domain/zone/example.net/record").Return([]uint64{42}, nil).Once()
client.On("GetWithContext", "/domain/zone/example.net/record/42").Return(ovhRecord{ID: 42, Zone: "example.net", ovhRecordFields: ovhRecordFields{FieldType: "A", ovhRecordFieldUpdate: ovhRecordFieldUpdate{SubDomain: "", TTL: 10, Target: "203.0.113.42"}}}, nil).Once()
client.On("PutWithContext", "/domain/zone/example.net/record/42", ovhRecordFieldUpdate{SubDomain: "", TTL: 10, Target: "203.0.113.43"}).Return(nil, nil).Once()
client.On("PostWithContext", "/domain/zone/example.net/refresh", nil).Return(nil, nil).Once()
_, err = provider.Records(t.Context())
td.CmpNoError(t, err)
td.CmpNoError(t, provider.ApplyChanges(t.Context(), &changes))
client.AssertExpectations(t)
// Test Update DryRun
client = new(mockOvhClient)
provider = &OVHProvider{client: client, apiRateLimiter: ratelimit.New(10), cacheInstance: cache.New(cache.NoExpiration, cache.NoExpiration), DryRun: true}
changes = plan.Changes{
UpdateOld: []*endpoint.Endpoint{
{DNSName: "example.net", RecordType: "A", RecordTTL: 10, Targets: []string{"203.0.113.42"}},
},
UpdateNew: []*endpoint.Endpoint{
{DNSName: "example.net", RecordType: "A", RecordTTL: 10, Targets: []string{"203.0.113.43"}},
},
}
client.On("GetWithContext", "/domain/zone").Return([]string{"example.net"}, nil).Once()
client.On("GetWithContext", "/domain/zone/example.net/record").Return([]uint64{42}, nil).Once()
client.On("GetWithContext", "/domain/zone/example.net/record/42").Return(ovhRecord{ID: 42, Zone: "example.net", ovhRecordFields: ovhRecordFields{FieldType: "A", ovhRecordFieldUpdate: ovhRecordFieldUpdate{SubDomain: "", TTL: 10, Target: "203.0.113.42"}}}, nil).Once()
_, err = provider.Records(t.Context())
td.CmpNoError(t, err)
td.CmpNoError(t, provider.ApplyChanges(t.Context(), &changes))
client.AssertExpectations(t)
// Test Update 2 records => 1 record
client = new(mockOvhClient)
provider = &OVHProvider{client: client, apiRateLimiter: ratelimit.New(10), cacheInstance: cache.New(cache.NoExpiration, cache.NoExpiration), DryRun: false}
changes = plan.Changes{
UpdateOld: []*endpoint.Endpoint{
{DNSName: "example.net", RecordType: "A", RecordTTL: 10, Targets: []string{"203.0.113.42", "203.0.113.43"}},
},
UpdateNew: []*endpoint.Endpoint{
{DNSName: "example.net", RecordType: "A", RecordTTL: 10, Targets: []string{"203.0.113.43"}},
},
}
client.On("GetWithContext", "/domain/zone").Return([]string{"example.net"}, nil).Once()
client.On("GetWithContext", "/domain/zone/example.net/record").Return([]uint64{42, 43}, nil).Once()
client.On("GetWithContext", "/domain/zone/example.net/record/42").Return(ovhRecord{ID: 42, Zone: "example.net", ovhRecordFields: ovhRecordFields{FieldType: "A", ovhRecordFieldUpdate: ovhRecordFieldUpdate{SubDomain: "", TTL: 10, Target: "203.0.113.42"}}}, nil).Once()
client.On("GetWithContext", "/domain/zone/example.net/record/43").Return(ovhRecord{ID: 43, Zone: "example.net", ovhRecordFields: ovhRecordFields{FieldType: "A", ovhRecordFieldUpdate: ovhRecordFieldUpdate{SubDomain: "", TTL: 10, Target: "203.0.113.43"}}}, nil).Once()
client.On("DeleteWithContext", "/domain/zone/example.net/record/42").Return(nil, nil).Once()
client.On("PostWithContext", "/domain/zone/example.net/refresh", nil).Return(nil, nil).Once()
_, err = provider.Records(t.Context())
td.CmpNoError(t, err)
td.CmpNoError(t, provider.ApplyChanges(t.Context(), &changes))
client.AssertExpectations(t)
} }
func TestOvhChange(t *testing.T) { func TestOvhChange(t *testing.T) {
@ -399,43 +578,44 @@ func TestOvhChange(t *testing.T) {
provider := &OVHProvider{client: client, apiRateLimiter: ratelimit.New(10), cacheInstance: cache.New(cache.NoExpiration, cache.NoExpiration)} provider := &OVHProvider{client: client, apiRateLimiter: ratelimit.New(10), cacheInstance: cache.New(cache.NoExpiration, cache.NoExpiration)}
// Record creation // Record creation
client.On("Post", "/domain/zone/example.net/record", ovhRecordFields{SubDomain: "ovh"}).Return(nil, nil).Once() client.On("PostWithContext", "/domain/zone/example.net/record", ovhRecordFields{ovhRecordFieldUpdate: ovhRecordFieldUpdate{SubDomain: "ovh"}}).Return(nil, nil).Once()
assert.NoError(provider.change(ovhChange{ assert.NoError(provider.change(t.Context(), ovhChange{
Action: ovhCreate, Action: ovhCreate,
ovhRecord: ovhRecord{Zone: "example.net", ovhRecordFields: ovhRecordFields{SubDomain: "ovh"}}, ovhRecord: ovhRecord{Zone: "example.net", ovhRecordFields: ovhRecordFields{ovhRecordFieldUpdate: ovhRecordFieldUpdate{SubDomain: "ovh"}}},
})) }))
client.AssertExpectations(t) client.AssertExpectations(t)
// Record deletion // Record deletion
client.On("Delete", "/domain/zone/example.net/record/42").Return(nil, nil).Once() client.On("DeleteWithContext", "/domain/zone/example.net/record/42").Return(nil, nil).Once()
assert.NoError(provider.change(ovhChange{ assert.NoError(provider.change(t.Context(), ovhChange{
Action: ovhDelete, Action: ovhDelete,
ovhRecord: ovhRecord{ID: 42, Zone: "example.net", ovhRecordFields: ovhRecordFields{SubDomain: "ovh"}}, ovhRecord: ovhRecord{ID: 42, Zone: "example.net", ovhRecordFields: ovhRecordFields{ovhRecordFieldUpdate: ovhRecordFieldUpdate{SubDomain: "ovh"}}},
})) }))
client.AssertExpectations(t) client.AssertExpectations(t)
// Record deletion error // Record deletion error
assert.Error(provider.change(ovhChange{ assert.Error(provider.change(t.Context(), ovhChange{
Action: ovhDelete, Action: ovhDelete,
ovhRecord: ovhRecord{Zone: "example.net", ovhRecordFields: ovhRecordFields{SubDomain: "ovh"}}, ovhRecord: ovhRecord{Zone: "example.net", ovhRecordFields: ovhRecordFields{ovhRecordFieldUpdate: ovhRecordFieldUpdate{SubDomain: "ovh"}}},
})) }))
client.AssertExpectations(t) client.AssertExpectations(t)
} }
func TestOvhCountTargets(t *testing.T) { func TestOvhRecordString(t *testing.T) {
cases := []struct { record := ovhRecord{ID: 24, Zone: "example.org", ovhRecordFields: ovhRecordFields{FieldType: "A", ovhRecordFieldUpdate: ovhRecordFieldUpdate{SubDomain: "ovh", TTL: 10, Target: "203.0.113.42"}}}
endpoints [][]*endpoint.Endpoint
count int td.Cmp(t, record.String(), "record#24: A | ovh => 203.0.113.42 (10)")
}{ }
{[][]*endpoint.Endpoint{{{DNSName: "ovh.example.net", Targets: endpoint.Targets{"target"}}}}, 1},
{[][]*endpoint.Endpoint{{{DNSName: "ovh.example.net", Targets: endpoint.Targets{"target"}}, {DNSName: "ovh.example.net", Targets: endpoint.Targets{"target"}}}}, 2}, func TestNewOvhProvider(t *testing.T) {
{[][]*endpoint.Endpoint{{{DNSName: "ovh.example.net", Targets: endpoint.Targets{"target", "target", "target"}}}}, 3}, var domainFilter endpoint.DomainFilter
{[][]*endpoint.Endpoint{{{DNSName: "ovh.example.net", Targets: endpoint.Targets{"target", "target"}}}, {{DNSName: "ovh.example.net", Targets: endpoint.Targets{"target", "target"}}}}, 4}, _, err := NewOVHProvider(t.Context(), domainFilter, "ovh-eu", 20, false, true)
} td.CmpError(t, err)
for _, test := range cases {
count := countTargets(test.endpoints...) t.Setenv("OVH_APPLICATION_KEY", "aaaaaa")
if count != test.count { t.Setenv("OVH_APPLICATION_SECRET", "bbbbbb")
t.Errorf("Wrong targets counts (Should be %d, get %d)", test.count, count) t.Setenv("OVH_CONSUMER_KEY", "cccccc")
}
} _, err = NewOVHProvider(t.Context(), domainFilter, "ovh-eu", 20, false, true)
td.CmpNoError(t, err)
} }