external-dns/provider/ovh/ovh.go

719 lines
21 KiB
Go

/*
Copyright 2020 The Kubernetes Authors.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
package ovh
import (
"context"
"errors"
"fmt"
"net/url"
"slices"
"strconv"
"strings"
"time"
"github.com/miekg/dns"
"github.com/ovh/go-ovh/ovh"
"github.com/patrickmn/go-cache"
log "github.com/sirupsen/logrus"
"golang.org/x/sync/errgroup"
"sigs.k8s.io/external-dns/endpoint"
"sigs.k8s.io/external-dns/pkg/apis/externaldns"
"sigs.k8s.io/external-dns/plan"
"sigs.k8s.io/external-dns/provider"
"go.uber.org/ratelimit"
)
const (
defaultTTL = 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")
)
// OVHProvider is an implementation of Provider for OVH DNS.
type OVHProvider struct {
provider.BaseProvider
client ovhClient
apiRateLimiter ratelimit.Limiter
domainFilter *endpoint.DomainFilter
// 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.
// Note that, when disabling cache, OVHcloud API has rate-limiting that will hit if
// your refresh rate/number of records is too big, which might cause issue with the
// provider.
// Default value: true
UseCache bool
lastRunRecords []ovhRecord
lastRunZones []string
cacheInstance *cache.Cache
dnsClient dnsClient
}
type ovhClient interface {
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 {
ExchangeContext(ctx context.Context, m *dns.Msg, a string) (*dns.Msg, time.Duration, error)
}
type ovhRecordFields struct {
ovhRecordFieldUpdate
FieldType string `json:"fieldType"`
}
type ovhRecordFieldUpdate struct {
SubDomain string `json:"subDomain"`
TTL int64 `json:"ttl"`
Target string `json:"target"`
}
type ovhRecord struct {
ovhRecordFields
ID uint64 `json:"id"`
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, enableCNAMERelative, dryRun bool) (*OVHProvider, error) {
client, err := ovh.NewEndpointClient(endpoint)
if err != nil {
return nil, err
}
client.UserAgent = externaldns.UserAgent()
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,
EnableCNAMERelativeTarget: enableCNAMERelative,
}, nil
}
// Records returns the list of records in all relevant zones.
func (p *OVHProvider) Records(ctx context.Context) ([]*endpoint.Endpoint, error) {
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
}
func planChangesByZoneName(zones []string, changes *plan.Changes) map[string]*plan.Changes {
zoneNameIDMapper := provider.ZoneIDName{}
for _, zone := range zones {
zoneNameIDMapper.Add(zone, 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{}
}
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 _, change := range changes.Update {
// NOTE: DNSName of `change.Old` and `change.New` will be equivalent
_, zoneName := zoneNameIDMapper.FindZone(change.Old.DNSName)
if _, ok := output[zoneName]; !ok {
output[zoneName] = &plan.Changes{}
}
output[zoneName].Update = append(output[zoneName].Update, change)
}
return output
}
func (p *OVHProvider) computeSingleZoneChanges(_ context.Context, zoneName string, existingRecords []ovhRecord, changes *plan.Changes) ([]ovhChange, error) {
allChanges := []ovhChange{}
var computedChanges []ovhChange
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...)
var err error
computedChanges, err = p.newOvhChangeUpdate(changes.Update, zoneName, existingRecords)
if err != nil {
return nil, err
}
allChanges = append(allChanges, computedChanges...)
return allChanges, nil
}
func (p *OVHProvider) handleSingleZoneUpdate(ctx context.Context, zoneName string, existingRecords []ovhRecord, changes *plan.Changes) error {
allChanges, err := p.computeSingleZoneChanges(ctx, zoneName, existingRecords, changes)
if err != nil {
return err
}
log.Infof("OVH: %q: %d changes will be done", zoneName, len(allChanges))
eg, ctxErrGroup := errgroup.WithContext(ctx)
for _, change := range allChanges {
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) 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)
}
return nil
}
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
// so that the next run will reload it.
p.invalidateCache(zone)
p.apiRateLimiter.Take()
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(ctx context.Context, change ovhChange) error {
p.apiRateLimiter.Take()
switch change.Action {
case ovhCreate:
log.Debugf("OVH: Add an entry to %s", change.String())
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())
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)
default:
return nil
}
}
func (p *OVHProvider) invalidateCache(zone string) {
p.cacheInstance.Delete(zone + "#soa")
}
func (p *OVHProvider) zonesRecords(ctx context.Context) ([]string, []ovhRecord, error) {
var allRecords []ovhRecord
zones, err := p.zones(ctx)
if err != nil {
return nil, nil, provider.NewSoftError(err)
}
chRecords := make(chan []ovhRecord, len(zones))
eg, ctx := errgroup.WithContext(ctx)
for _, zone := range zones {
eg.Go(func() error { return p.records(ctx, &zone, chRecords) })
}
if err := eg.Wait(); err != nil {
return nil, nil, provider.NewSoftError(err)
}
close(chRecords)
for records := range chRecords {
allRecords = append(allRecords, records...)
}
return zones, allRecords, nil
}
func (p *OVHProvider) zones(ctx context.Context) ([]string, error) {
var zones []string
var filteredZones []string
p.apiRateLimiter.Take()
if err := p.client.GetWithContext(ctx, "/domain/zone", &zones); err != nil {
return nil, err
}
for _, zoneName := range zones {
if p.domainFilter == nil || p.domainFilter.Match(zoneName) {
filteredZones = append(filteredZones, zoneName)
}
}
log.Infof("OVH: %d zones found", len(filteredZones))
return filteredZones, nil
}
type ovhSoa struct {
Server string `json:"server"`
Serial uint32 `json:"serial"`
records []ovhRecord
}
func (p *OVHProvider) records(ctx context.Context, zone *string, records chan<- []ovhRecord) error {
var recordsIds []uint64
ovhRecords := make([]ovhRecord, len(recordsIds))
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")
if err == nil {
if s, ok := in.Answer[0].(*dns.SOA); ok {
if s.Serial == cachedSoa.Serial {
log.Debugf("OVH: zone %s: SOA from cache is valid", *zone)
records <- cachedSoa.records
return nil
}
}
}
p.invalidateCache(*zone)
}
}
log.Debugf("OVH: Getting records for %s from API", *zone)
p.apiRateLimiter.Take()
var soa ovhSoa
if p.UseCache {
if err := p.client.GetWithContext(ctx, "/domain/zone/"+url.PathEscape(*zone)+"/soa", &soa); err != nil {
return err
}
}
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 {
eg.Go(func() error { return p.record(ctxErrGroup, zone, id, chRecords) })
}
if err := eg.Wait(); err != nil {
return err
}
close(chRecords)
for record := range chRecords {
ovhRecords = append(ovhRecords, record)
}
if p.UseCache {
soa.records = ovhRecords
_ = p.cacheInstance.Add(*zone+"#soa", soa, cache.DefaultExpiration)
}
records <- ovhRecords
return nil
}
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.GetWithContext(ctx, fmt.Sprintf("/domain/zone/%s/record/%d", url.PathEscape(*zone), id), &record); err != nil {
return err
}
if provider.SupportedRecordType(record.FieldType) {
log.Debugf("OVH: Record %d for %s is %+v", id, *zone, record)
records <- record
}
return nil
}
func ovhGroupByNameAndType(records []ovhRecord) []*endpoint.Endpoint {
endpoints := []*endpoint.Endpoint{}
// group supported records by name and type
groups := map[string][]ovhRecord{}
for _, r := range records {
groupBy := r.Zone + "//" + r.SubDomain + "//" + r.FieldType
if _, ok := groups[groupBy]; !ok {
groups[groupBy] = []ovhRecord{}
}
groups[groupBy] = append(groups[groupBy], r)
}
// create single endpoint with all the targets for each name/type
for _, records := range groups {
var targets []string
for _, record := range records {
targets = append(targets, record.Target)
}
ep := endpoint.NewEndpointWithTTL(
strings.TrimPrefix(records[0].SubDomain+"."+records[0].Zone, "."),
records[0].FieldType,
endpoint.TTL(records[0].TTL),
targets...,
)
endpoints = append(endpoints, ep)
}
return endpoints
}
func (p *OVHProvider) newOvhChangeCreateDelete(action int, endpoints []*endpoint.Endpoint, zone string, existingRecords []ovhRecord) ([]ovhChange, []ovhRecord) {
var ovhChanges []ovhChange
var toDeleteIds []int
for _, e := range endpoints {
for _, target := range e.Targets {
change := ovhChange{
Action: action,
ovhRecord: ovhRecord{
Zone: zone,
ovhRecordFields: ovhRecordFields{
FieldType: e.RecordType,
ovhRecordFieldUpdate: ovhRecordFieldUpdate{
SubDomain: convertDNSNameIntoSubDomain(e.DNSName, zone),
TTL: defaultTTL,
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.
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)
}
}
if len(toDeleteIds) > 0 {
// Copy the records because we need to mutate the list.
newExistingRecords := make([]ovhRecord, 0, len(existingRecords)-len(toDeleteIds))
for id := range existingRecords {
if slices.Contains(toDeleteIds, id) {
continue
}
newExistingRecords = append(newExistingRecords, existingRecords[id])
}
existingRecords = newExistingRecords
}
return ovhChanges, existingRecords
}
func convertDNSNameIntoSubDomain(DNSName string, zoneName string) string {
if DNSName == zoneName {
return ""
}
return strings.TrimSuffix(DNSName, "."+zoneName)
}
func normalizeDNSName(dnsName string) string {
return strings.TrimSpace(strings.ToLower(dnsName))
}
func (p *OVHProvider) newOvhChangeUpdate(updates []*plan.Update, zone string, existingRecords []ovhRecord) ([]ovhChange, error) {
zoneNameIDMapper := provider.ZoneIDName{}
zoneNameIDMapper.Add(zone, zone)
oldEndpointByTypeAndName := map[string]*endpoint.Endpoint{}
newEndpointByTypeAndName := map[string]*endpoint.Endpoint{}
oldRecordsInZone := map[string][]ovhRecord{}
for _, update := range updates {
// NOTE: DNSName and RecordType of `update.Old` and `update.New` will be equivalent
sub := convertDNSNameIntoSubDomain(update.Old.DNSName, zone)
oldEndpointByTypeAndName[normalizeDNSName(update.Old.RecordType+"//"+sub)] = update.Old
newEndpointByTypeAndName[normalizeDNSName(update.New.RecordType+"//"+sub)] = update.New
}
for id := range oldEndpointByTypeAndName {
for _, record := range existingRecords {
if id == normalizeDNSName(record.FieldType+"//"+record.SubDomain) {
oldRecordsInZone[id] = append(oldRecordsInZone[id], record)
}
}
}
var changes []ovhChange
for id := range oldEndpointByTypeAndName {
oldRecords := slices.Clone(oldRecordsInZone[id])
endpointsNew, ok := newEndpointByTypeAndName[id]
if !ok {
return nil, errors.New("unrecoverable error: couldn't find the matching record in the update.New")
}
var toInsertTarget []string
for _, target := range endpointsNew.Targets {
var toDelete = -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)
}
}
createChangeConvertedToUpdateChange := []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 = defaultTTL
}
change := ovhChange{
Action: ovhUpdate,
ovhRecord: record,
}
p.formatCNAMETarget(&change)
changes = append(changes, change)
createChangeConvertedToUpdateChange = append(createChangeConvertedToUpdateChange, i)
}
newToInsertTarget := make([]string, 0, len(toInsertTarget)-len(createChangeConvertedToUpdateChange))
for i := range toInsertTarget {
if slices.Contains(createChangeConvertedToUpdateChange, i) {
continue
}
newToInsertTarget = append(newToInsertTarget, toInsertTarget[i])
}
toInsertTarget = newToInsertTarget
if len(toInsertTarget) > 0 {
for _, target := range toInsertTarget {
recordTTL := int64(defaultTTL)
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, nil
}
func (c *ovhChange) String() string {
var action string
switch c.Action {
case ovhCreate:
action = "create"
case ovhUpdate:
action = "update"
case ovhDelete:
action = "delete"
default:
action = "unknown"
}
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 += "."
}