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)
- [NS1](https://ns1.com/)
- [TransIP](https://www.transip.eu/domain-name/)
- [OVH](https://www.ovh.com)
- [OVHcloud](https://www.ovhcloud.com)
- [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)
@ -85,7 +85,7 @@ See PR #3063 for all the discussions about it.
Known providers using webhooks:
| Provider | Repo |
|-----------------------|----------------------------------------------------------------------|
| --------------------- | -------------------------------------------------------------------- |
| Abion | https://github.com/abiondevelopment/external-dns-webhook-abion |
| Adguard Home Provider | https://github.com/muhlba91/external-dns-provider-adguard |
| 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 | |
| NS1 | Alpha | |
| TransIP | Alpha | |
| OVH | Alpha | |
| OVHcloud | Beta | @rbeuque74 |
| Scaleway DNS | Alpha | @Sh4d1 |
| UltraDNS | Alpha | |
| GoDaddy | Alpha | |
@ -207,7 +207,7 @@ The following tutorials are provided:
- [PowerDNS](docs/tutorials/pdns.md)
- [RFC2136](docs/tutorials/rfc2136.md)
- [TransIP](docs/tutorials/transip.md)
- [OVH](docs/tutorials/ovh.md)
- [OVHcloud](docs/tutorials/ovh.md)
- [Scaleway](docs/tutorials/scaleway.md)
- [UltraDNS](docs/tutorials/ultradns.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) |
| `--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) |
| `--[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-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) |

View File

@ -1,30 +1,31 @@
# OVHcloud
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.
## 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.
[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.
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 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)
you will have your `Application key` and `Application secret`
And you will need to generate your consumer key, here the permissions needed :
- GET on `/domain/zone`
- GET on `/domain/zone/*/record`
- GET on `/domain/zone/*/record/*`
- PUT on `/domain/zone/*/record/*`
- POST on `/domain/zone/*/record`
- DELETE on `/domain/zone/*/record/*`
- GET on `/domain/zone/*/soa`
@ -51,6 +52,10 @@ curl -XPOST -H "X-Ovh-Application: <ApplicationKey>" -H "Content-type: applicati
"method": "GET",
"path": "/domain/zone/*/record/*"
},
{
"method": "PUT",
"path": "/domain/zone/*/record/*"
},
{
"method": "POST",
"path": "/domain/zone/*/record"
@ -223,7 +228,7 @@ spec:
**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.
@ -235,11 +240,11 @@ ExternalDNS uses the hostname annotation to determine which services should be r
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

View File

@ -46,7 +46,7 @@ func (f *Flags) addFlag(name, description string) {
// It generates a markdown 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() {
testPath, _ := os.Getwd()
path := fmt.Sprintf("%s/docs/flags.md", testPath)

View File

@ -267,7 +267,7 @@ func main() {
case "digitalocean":
p, err = digitalocean.NewDigitalOceanProvider(ctx, domainFilter, cfg.DryRun, cfg.DigitalOceanAPIPageSize)
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":
p, err = linode.NewLinodeProvider(domainFilter, cfg.DryRun, externaldns.Version)
case "dnsimple":

View File

@ -131,6 +131,7 @@ type Config struct {
InMemoryZones []string
OVHEndpoint string
OVHApiRateLimit int
OVHEnableCNAMERelative bool
PDNSServer string
PDNSServerID string
PDNSAPIKey string `secure:"yes"`
@ -295,6 +296,7 @@ var defaultConfig = &Config{
InMemoryZones: []string{},
OVHEndpoint: "ovh-eu",
OVHApiRateLimit: 20,
OVHEnableCNAMERelative: false,
PDNSServer: "http://localhost:8081",
PDNSServerID: "localhost",
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("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-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-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)

View File

@ -20,6 +20,9 @@ import (
"context"
"errors"
"fmt"
"net/url"
"slices"
"strconv"
"strings"
"time"
@ -41,13 +44,12 @@ const (
ovhDefaultTTL = 0
ovhCreate = iota
ovhDelete
ovhUpdate
)
var (
// 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")
// ErrNoDryRun No dry run support for the moment
ErrNoDryRun = errors.New("dry run not supported")
)
// OVHProvider is an implementation of Provider for OVH DNS.
@ -59,7 +61,15 @@ type OVHProvider struct {
apiRateLimiter ratelimit.Limiter
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
// 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
// provider.
// Default value: true
UseCache bool
UseCache bool
lastRunRecords []ovhRecord
lastRunZones []string
cacheInstance *cache.Cache
dnsClient dnsClient
}
type ovhClient interface {
Post(string, interface{}, interface{}) error
Get(string, interface{}) error
Delete(string, interface{}) error
PostWithContext(context.Context, string, any, any) error
PutWithContext(context.Context, string, any, any) error
GetWithContext(context.Context, string, any) error
DeleteWithContext(context.Context, string, any) error
}
type dnsClient interface {
@ -84,7 +97,11 @@ type dnsClient interface {
}
type ovhRecordFields struct {
ovhRecordFieldUpdate
FieldType string `json:"fieldType"`
}
type ovhRecordFieldUpdate struct {
SubDomain string `json:"subDomain"`
TTL int64 `json:"ttl"`
Target string `json:"target"`
@ -96,86 +113,160 @@ type ovhRecord struct {
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 {
ovhRecord
Action int
}
// 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)
if err != nil {
return nil, err
}
client.UserAgent = externaldns.Version
client.UserAgent = "ExternalDNS/" + externaldns.Version
// TODO: Add Dry Run support
if dryRun {
return nil, ErrNoDryRun
}
return &OVHProvider{
client: client,
domainFilter: domainFilter,
apiRateLimiter: ratelimit.New(apiRateLimit),
DryRun: dryRun,
cacheInstance: cache.New(cache.NoExpiration, cache.NoExpiration),
dnsClient: new(dns.Client),
UseCache: true,
client: client,
domainFilter: domainFilter,
apiRateLimiter: ratelimit.New(apiRateLimit),
DryRun: dryRun,
cacheInstance: cache.New(cache.NoExpiration, cache.NoExpiration),
dnsClient: new(dns.Client),
UseCache: true,
EnableCNAMERelativeTarget: enableCNAMERelative,
}, nil
}
// Records returns the list of records in all relevant zones.
func (p *OVHProvider) Records(ctx context.Context) ([]*endpoint.Endpoint, error) {
_, records, err := p.zonesRecords(ctx)
zones, records, err := p.zonesRecords(ctx)
if err != nil {
return nil, err
}
p.lastRunRecords = records
p.lastRunZones = zones
endpoints := ovhGroupByNameAndType(records)
log.Infof("OVH: %d endpoints have been found", len(endpoints))
return endpoints, nil
}
// 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, err := p.zonesRecords(ctx)
if err != nil {
return provider.NewSoftError(err)
func planChangesByZoneName(zones []string, changes *plan.Changes) map[string]*plan.Changes {
zoneNameIDMapper := provider.ZoneIDName{}
for _, zone := range zones {
zoneNameIDMapper.Add(zone, zone)
}
zonesChangeUniques := map[string]bool{}
// Always refresh zones even in case of errors.
defer func() {
log.Debugf("OVH: %d zones will be refreshed", len(zonesChangeUniques))
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 := map[string]*plan.Changes{}
for _, endpt := range changes.Delete {
_, zoneName := zoneNameIDMapper.FindZone(endpt.DNSName)
if _, ok := output[zoneName]; !ok {
output[zoneName] = &plan.Changes{}
}
if e := eg.Wait(); e != nil && err == nil { // return the error only if there is no error during the changes
err = provider.NewSoftError(e)
output[zoneName].Delete = append(output[zoneName].Delete, endpt)
}
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))
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)...)
return output
}
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 {
change := change
zonesChangeUniques[change.Zone] = true
eg.Go(func() error { return p.change(change) })
eg.Go(func() error {
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 {
return provider.NewSoftError(err)
}
@ -183,7 +274,7 @@ func (p *OVHProvider) ApplyChanges(ctx context.Context, changes *plan.Changes) (
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)
// Zone has been altered so we invalidate the cache
@ -191,26 +282,50 @@ func (p *OVHProvider) refresh(zone string) error {
p.invalidateCache(zone)
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 nil
}
func (p *OVHProvider) change(change ovhChange) error {
func (p *OVHProvider) change(ctx context.Context, change ovhChange) error {
p.apiRateLimiter.Take()
switch change.Action {
case ovhCreate:
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:
if change.ID == 0 {
return ErrRecordToMutateNotFound
}
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
}
@ -220,7 +335,7 @@ func (p *OVHProvider) invalidateCache(zone string) {
func (p *OVHProvider) zonesRecords(ctx context.Context) ([]string, []ovhRecord, error) {
var allRecords []ovhRecord
zones, err := p.zones()
zones, err := p.zones(ctx)
if err != nil {
return nil, nil, provider.NewSoftError(err)
}
@ -229,7 +344,7 @@ func (p *OVHProvider) zonesRecords(ctx context.Context) ([]string, []ovhRecord,
eg, ctx := errgroup.WithContext(ctx)
for _, zone := range zones {
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 {
return nil, nil, provider.NewSoftError(err)
@ -241,12 +356,12 @@ func (p *OVHProvider) zonesRecords(ctx context.Context) ([]string, []ovhRecord,
return zones, allRecords, nil
}
func (p *OVHProvider) zones() ([]string, error) {
func (p *OVHProvider) zones(ctx context.Context) ([]string, error) {
zones := []string{}
filteredZones := []string{}
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
}
@ -265,22 +380,24 @@ type ovhSoa struct {
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
ovhRecords := make([]ovhRecord, len(recordsIds))
eg, _ := errgroup.WithContext(*ctx)
eg, ctxErrGroup := errgroup.WithContext(ctx)
if p.UseCache {
if cachedSoaItf, ok := p.cacheInstance.Get(*zone + "#soa"); ok {
cachedSoa := cachedSoaItf.(ovhSoa)
log.Debugf("OVH: zone %s: Checking SOA against %v", *zone, cachedSoa.Serial)
m := new(dns.Msg)
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 s, ok := in.Answer[0].(*dns.SOA); ok {
// do something with t.Txt
if s.Serial == cachedSoa.Serial {
log.Debugf("OVH: zone %s: SOA from cache is valid", *zone)
records <- cachedSoa.records
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()
var soa ovhSoa
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
}
}
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
}
chRecords := make(chan ovhRecord, len(recordsIds))
for _, id := range recordsIds {
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 {
return err
@ -319,20 +436,20 @@ func (p *OVHProvider) records(ctx *context.Context, zone *string, records chan<-
if p.UseCache {
soa.records = ovhRecords
_ = p.cacheInstance.Add(*zone+"#soa", soa, time.Hour)
_ = p.cacheInstance.Add(*zone+"#soa", soa, cache.DefaultExpiration)
}
records <- ovhRecords
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{}
log.Debugf("OVH: Getting record %d for %s", id, *zone)
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
}
if provider.SupportedRecordType(record.FieldType) {
@ -349,7 +466,7 @@ func ovhGroupByNameAndType(records []ovhRecord) []*endpoint.Endpoint {
groups := map[string][]ovhRecord{}
for _, r := range records {
groupBy := r.Zone + r.SubDomain + r.FieldType
groupBy := r.Zone + "//" + r.SubDomain + "//" + r.FieldType
if _, ok := groups[groupBy]; !ok {
groups[groupBy] = []ovhRecord{}
}
@ -375,74 +492,214 @@ func ovhGroupByNameAndType(records []ovhRecord) []*endpoint.Endpoint {
return endpoints
}
func newOvhChange(action int, endpoints []*endpoint.Endpoint, zones []string, records []ovhRecord) []ovhChange {
// Copy the records because we need to mutate the list.
newRecords := make([]ovhRecord, len(records))
copy(newRecords, records)
zoneNameIDMapper := provider.ZoneIDName{}
ovhChanges := make([]ovhChange, 0, countTargets(endpoints))
for _, zone := range zones {
zoneNameIDMapper.Add(zone, zone)
}
func (p OVHProvider) newOvhChangeCreateDelete(action int, endpoints []*endpoint.Endpoint, zone string, existingRecords []ovhRecord) ([]ovhChange, []ovhRecord) {
ovhChanges := []ovhChange{}
toDeleteIds := []int{}
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 {
if e.RecordType == endpoint.RecordTypeCNAME {
target = target + "."
}
change := ovhChange{
Action: action,
ovhRecord: ovhRecord{
Zone: zone,
ovhRecordFields: ovhRecordFields{
FieldType: e.RecordType,
SubDomain: strings.TrimSuffix(e.DNSName, "."+zone),
TTL: ovhDefaultTTL,
Target: target,
ovhRecordFieldUpdate: ovhRecordFieldUpdate{
SubDomain: convertDNSNameIntoSubDomain(e.DNSName, zone),
TTL: ovhDefaultTTL,
Target: target,
},
},
},
}
p.formatCNAMETarget(&change)
if e.RecordTTL.IsConfigured() {
change.TTL = int64(e.RecordTTL)
}
// 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.
for i := 0; i < len(newRecords); i++ {
rec := newRecords[i]
if rec.Zone == change.Zone && rec.SubDomain == change.SubDomain && rec.FieldType == change.FieldType && rec.Target == change.Target {
change.ID = rec.ID
// Deleting this record from the list to avoid retargetting it later if a change with a similar target exists.
newRecords = append(newRecords[:i], newRecords[i+1:]...)
break
if action == ovhDelete {
for i, rec := range existingRecords {
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
toDeleteIds = append(toDeleteIds, i)
break
}
}
}
ovhChanges = append(ovhChanges, change)
}
}
return ovhChanges
}
func countTargets(allEndpoints ...[]*endpoint.Endpoint) int {
count := 0
for _, endpoints := range allEndpoints {
for _, endpoint := range endpoints {
count += len(endpoint.Targets)
if len(toDeleteIds) > 0 {
// Copy the records because we need to mutate the list.
existingRecords = slices.Clone(existingRecords)
alreadyRemoved := 0
for _, id := range toDeleteIds {
existingRecords = slices.Delete(existingRecords, id-alreadyRemoved, id-alreadyRemoved+1)
alreadyRemoved++
}
}
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 {
if c.ID != 0 {
return fmt.Sprintf("%s zone (ID : %d) : %s %d IN %s %s", c.Zone, c.ID, c.SubDomain, c.TTL, c.FieldType, c.Target)
var action string
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"
"time"
"github.com/maxatome/go-testdeep/td"
"github.com/miekg/dns"
"github.com/ovh/go-ovh/ovh"
"github.com/patrickmn/go-cache"
@ -38,21 +39,28 @@ type mockOvhClient struct {
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)
data, _ := json.Marshal(stub.Get(0))
json.Unmarshal(data, output)
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)
data, _ := json.Marshal(stub.Get(0))
json.Unmarshal(data, output)
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)
data, _ := json.Marshal(stub.Get(0))
json.Unmarshal(data, output)
@ -84,16 +92,16 @@ func TestOvhZones(t *testing.T) {
}
// Basic zones
client.On("Get", "/domain/zone").Return([]string{"example.com", "example.net"}, nil).Once()
domains, err := provider.zones()
client.On("GetWithContext", "/domain/zone").Return([]string{"example.com", "example.net"}, nil).Once()
domains, err := provider.zones(t.Context())
assert.NoError(err)
assert.Contains(domains, "example.com")
assert.NotContains(domains, "example.net")
client.AssertExpectations(t)
// Error on getting zones
client.On("Get", "/domain/zone").Return(nil, ovh.ErrAPIDown).Once()
domains, err = provider.zones()
client.On("GetWithContext", "/domain/zone").Return(nil, ovh.ErrAPIDown).Once()
domains, err = provider.zones(t.Context())
assert.Error(err)
assert.Nil(domains)
client.AssertExpectations(t)
@ -106,21 +114,21 @@ func TestOvhZoneRecords(t *testing.T) {
// Basic zones records
t.Log("Basic zones records")
client.On("Get", "/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("Get", "/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("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()
zones, records, err := provider.zonesRecords(context.TODO())
client.On("GetWithContext", "/domain/zone").Return([]string{"example.org"}, nil).Once()
client.On("GetWithContext", "/domain/zone/example.org/soa").Return(ovhSoa{Server: "ns.example.org.", Serial: 2022090901}, nil).Once()
client.On("GetWithContext", "/domain/zone/example.org/record").Return([]uint64{24, 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("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(t.Context())
assert.NoError(err)
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)
// Error on getting zones list
t.Log("Error on getting zones list")
client.On("Get", "/domain/zone").Return(nil, ovh.ErrAPIDown).Once()
zones, records, err = provider.zonesRecords(context.TODO())
client.On("GetWithContext", "/domain/zone").Return(nil, ovh.ErrAPIDown).Once()
zones, records, err = provider.zonesRecords(t.Context())
assert.Error(err)
assert.Nil(zones)
assert.Nil(records)
@ -129,9 +137,9 @@ func TestOvhZoneRecords(t *testing.T) {
// Error on getting zone SOA
t.Log("Error on getting zone SOA")
provider.cacheInstance = cache.New(cache.NoExpiration, cache.NoExpiration)
client.On("Get", "/domain/zone").Return([]string{"example.org"}, nil).Once()
client.On("Get", "/domain/zone/example.org/soa").Return(nil, ovh.ErrAPIDown).Once()
zones, records, err = provider.zonesRecords(context.TODO())
client.On("GetWithContext", "/domain/zone").Return([]string{"example.org"}, nil).Once()
client.On("GetWithContext", "/domain/zone/example.org/soa").Return(nil, ovh.ErrAPIDown).Once()
zones, records, err = provider.zonesRecords(t.Context())
assert.Error(err)
assert.Nil(zones)
assert.Nil(records)
@ -139,10 +147,10 @@ func TestOvhZoneRecords(t *testing.T) {
// 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("Get", "/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()
zones, records, err = provider.zonesRecords(context.TODO())
client.On("GetWithContext", "/domain/zone").Return([]string{"example.org"}, nil).Once()
client.On("GetWithContext", "/domain/zone/example.org/soa").Return(ovhSoa{Server: "ns.example.org.", Serial: 2022090902}, nil).Once()
client.On("GetWithContext", "/domain/zone/example.org/record").Return(nil, ovh.ErrAPIDown).Once()
zones, records, err = provider.zonesRecords(t.Context())
assert.Error(err)
assert.Nil(zones)
assert.Nil(records)
@ -150,11 +158,11 @@ func TestOvhZoneRecords(t *testing.T) {
// 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("Get", "/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("Get", "/domain/zone/example.org/record/42").Return(nil, ovh.ErrAPIDown).Once()
zones, records, err = provider.zonesRecords(context.TODO())
client.On("GetWithContext", "/domain/zone").Return([]string{"example.org"}, nil).Once()
client.On("GetWithContext", "/domain/zone/example.org/soa").Return(ovhSoa{Server: "ns.example.org.", Serial: 2022090902}, nil).Once()
client.On("GetWithContext", "/domain/zone/example.org/record").Return([]uint64{42}, nil).Once()
client.On("GetWithContext", "/domain/zone/example.org/record/42").Return(nil, ovh.ErrAPIDown).Once()
zones, records, err = provider.zonesRecords(t.Context())
assert.Error(err)
assert.Nil(zones)
assert.Nil(records)
@ -169,16 +177,16 @@ func TestOvhZoneRecordsCache(t *testing.T) {
// First call, cache miss
t.Log("First call, cache miss")
client.On("Get", "/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("Get", "/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("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").Return([]string{"example.org"}, nil).Once()
client.On("GetWithContext", "/domain/zone/example.org/soa").Return(ovhSoa{Server: "ns.example.org.", Serial: 2022090901}, nil).Once()
client.On("GetWithContext", "/domain/zone/example.org/record").Return([]uint64{24, 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("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.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)
dnsClient.AssertExpectations(t)
@ -189,13 +197,13 @@ func TestOvhZoneRecordsCache(t *testing.T) {
// 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").
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.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)
dnsClient.AssertExpectations(t)
@ -206,17 +214,17 @@ func TestOvhZoneRecordsCache(t *testing.T) {
// 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").
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("Get", "/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/soa").Return(ovhSoa{Server: "ns.example.org.", Serial: 2022090902}, nil).Once()
client.On("GetWithContext", "/domain/zone/example.org/record").Return([]uint64{24}, 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.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)
dnsClient.AssertExpectations(t)
@ -227,14 +235,14 @@ func TestOvhZoneRecordsCache(t *testing.T) {
// 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").
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.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)
dnsClient.AssertExpectations(t)
@ -245,18 +253,18 @@ func TestOvhZoneRecordsCache(t *testing.T) {
// fifth call, dns issue
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").
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("Get", "/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("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/soa").Return(ovhSoa{Server: "ns.example.org.", Serial: 2022090903}, nil).Once()
client.On("GetWithContext", "/domain/zone/example.org/record").Return([]uint64{24, 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("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.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)
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)}
// Basic zones records
client.On("Get", "/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("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("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("Get", "/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("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()
endpoints, err := provider.Records(context.TODO())
client.On("GetWithContext", "/domain/zone").Return([]string{"example.org", "example.net"}, nil).Once()
client.On("GetWithContext", "/domain/zone/example.org/record").Return([]uint64{24, 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("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("GetWithContext", "/domain/zone/example.net/record").Return([]uint64{24, 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("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(t.Context())
assert.NoError(err)
// Little fix for multi targets endpoint
for _, endpoint := range endpoints {
@ -288,25 +296,70 @@ func TestOvhRecords(t *testing.T) {
client.AssertExpectations(t)
// Error getting zone
client.On("Get", "/domain/zone").Return(nil, ovh.ErrAPIDown).Once()
endpoints, err = provider.Records(context.TODO())
client.On("GetWithContext", "/domain/zone").Return(nil, ovh.ErrAPIDown).Once()
endpoints, err = provider.Records(t.Context())
assert.Error(err)
assert.Nil(endpoints)
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) {
client := new(mockOvhClient)
provider := &OVHProvider{client: client, apiRateLimiter: ratelimit.New(10), cacheInstance: cache.New(cache.NoExpiration, cache.NoExpiration)}
// Basic zone refresh
client.On("Post", "/domain/zone/example.net/refresh", nil).Return(nil, nil).Once()
provider.refresh("example.net")
client.On("PostWithContext", "/domain/zone/example.net/refresh", nil).Return(nil, nil).Once()
provider.refresh(t.Context(), "example.net")
client.AssertExpectations(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{
{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"}},
@ -315,11 +368,11 @@ func TestOvhNewChange(t *testing.T) {
}
// Create change
changes := newOvhChange(ovhCreate, endpoints, []string{"example.net"}, []ovhRecord{})
assert.ElementsMatch(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{SubDomain: "ovh", FieldType: "A", 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."}}},
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.net."}}}},
})
// 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"}},
}
records := []ovhRecord{
{ID: 42, Zone: "example.net", ovhRecordFields: ovhRecordFields{FieldType: "A", SubDomain: "ovh", Target: "203.0.113.42"}},
{ID: 43, Zone: "example.net", ovhRecordFields: ovhRecordFields{FieldType: "A", SubDomain: "ovh", Target: "203.0.113.42"}},
{ID: 44, 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", ovhRecordFieldUpdate: ovhRecordFieldUpdate{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)
assert.ElementsMatch(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: 43, 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{SubDomain: "ovh", FieldType: "A", TTL: ovhDefaultTTL, Target: "203.0.113.42"}}},
changes, _ = provider.newOvhChangeCreateDelete(ovhDelete, endpoints, "example.net", records)
td.Cmp(t, changes, []ovhChange{
{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{FieldType: "A", ovhRecordFieldUpdate: ovhRecordFieldUpdate{SubDomain: "ovh", 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) {
assert := assert.New(t)
client := new(mockOvhClient)
provider := &OVHProvider{client: client, apiRateLimiter: ratelimit.New(10), cacheInstance: cache.New(cache.NoExpiration, cache.NoExpiration)}
changes := plan.Changes{
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{
{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("Get", "/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("Post", "/domain/zone/example.net/record", ovhRecordFields{SubDomain: "", FieldType: "A", 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("Post", "/domain/zone/example.net/refresh", nil).Return(nil, nil).Once()
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()
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("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)
// Basic changes
assert.NoError(provider.ApplyChanges(context.TODO(), &changes))
client.AssertExpectations(t)
// Getting zones failed
client.On("Get", "/domain/zone").Return(nil, ovh.ErrAPIDown).Once()
assert.Error(provider.ApplyChanges(context.TODO(), &changes))
td.CmpNoError(t, provider.ApplyChanges(t.Context(), &changes))
client.AssertExpectations(t)
// Apply change failed
client.On("Get", "/domain/zone").Return([]string{"example.net"}, nil).Once()
client.On("Get", "/domain/zone/example.net/record").Return([]uint64{}, nil).Once()
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("Post", "/domain/zone/example.net/refresh", nil).Return(nil, nil).Once()
assert.Error(provider.ApplyChanges(context.TODO(), &plan.Changes{
client = new(mockOvhClient)
provider.client = client
client.On("GetWithContext", "/domain/zone").Return([]string{"example.net"}, nil).Once()
client.On("GetWithContext", "/domain/zone/example.net/record").Return([]uint64{}, 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, ovh.ErrAPIDown).Once()
_, err = provider.Records(t.Context())
td.CmpNoError(t, err)
td.CmpError(t, provider.ApplyChanges(t.Context(), &plan.Changes{
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)
// Refresh failed
client.On("Get", "/domain/zone").Return([]string{"example.net"}, nil).Once()
client.On("Get", "/domain/zone/example.net/record").Return([]uint64{}, 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("Post", "/domain/zone/example.net/refresh", nil).Return(nil, ovh.ErrAPIDown).Once()
assert.Error(provider.ApplyChanges(context.TODO(), &plan.Changes{
client = new(mockOvhClient)
provider.client = client
client.On("GetWithContext", "/domain/zone").Return([]string{"example.net"}, nil).Once()
client.On("GetWithContext", "/domain/zone/example.net/record").Return([]uint64{}, 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("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{
{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)
// 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) {
@ -399,43 +578,44 @@ func TestOvhChange(t *testing.T) {
provider := &OVHProvider{client: client, apiRateLimiter: ratelimit.New(10), cacheInstance: cache.New(cache.NoExpiration, cache.NoExpiration)}
// Record creation
client.On("Post", "/domain/zone/example.net/record", ovhRecordFields{SubDomain: "ovh"}).Return(nil, nil).Once()
assert.NoError(provider.change(ovhChange{
client.On("PostWithContext", "/domain/zone/example.net/record", ovhRecordFields{ovhRecordFieldUpdate: ovhRecordFieldUpdate{SubDomain: "ovh"}}).Return(nil, nil).Once()
assert.NoError(provider.change(t.Context(), ovhChange{
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)
// Record deletion
client.On("Delete", "/domain/zone/example.net/record/42").Return(nil, nil).Once()
assert.NoError(provider.change(ovhChange{
client.On("DeleteWithContext", "/domain/zone/example.net/record/42").Return(nil, nil).Once()
assert.NoError(provider.change(t.Context(), ovhChange{
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)
// Record deletion error
assert.Error(provider.change(ovhChange{
assert.Error(provider.change(t.Context(), ovhChange{
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)
}
func TestOvhCountTargets(t *testing.T) {
cases := []struct {
endpoints [][]*endpoint.Endpoint
count int
}{
{[][]*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},
{[][]*endpoint.Endpoint{{{DNSName: "ovh.example.net", Targets: endpoint.Targets{"target", "target", "target"}}}}, 3},
{[][]*endpoint.Endpoint{{{DNSName: "ovh.example.net", Targets: endpoint.Targets{"target", "target"}}}, {{DNSName: "ovh.example.net", Targets: endpoint.Targets{"target", "target"}}}}, 4},
}
for _, test := range cases {
count := countTargets(test.endpoints...)
if count != test.count {
t.Errorf("Wrong targets counts (Should be %d, get %d)", test.count, count)
}
}
func TestOvhRecordString(t *testing.T) {
record := ovhRecord{ID: 24, Zone: "example.org", ovhRecordFields: ovhRecordFields{FieldType: "A", ovhRecordFieldUpdate: ovhRecordFieldUpdate{SubDomain: "ovh", TTL: 10, Target: "203.0.113.42"}}}
td.Cmp(t, record.String(), "record#24: A | ovh => 203.0.113.42 (10)")
}
func TestNewOvhProvider(t *testing.T) {
var domainFilter endpoint.DomainFilter
_, err := NewOVHProvider(t.Context(), domainFilter, "ovh-eu", 20, false, true)
td.CmpError(t, err)
t.Setenv("OVH_APPLICATION_KEY", "aaaaaa")
t.Setenv("OVH_APPLICATION_SECRET", "bbbbbb")
t.Setenv("OVH_CONSUMER_KEY", "cccccc")
_, err = NewOVHProvider(t.Context(), domainFilter, "ovh-eu", 20, false, true)
td.CmpNoError(t, err)
}