external-dns/provider/digitalocean/digital_ocean.go
Arthur Le Roux e17b5ed07b
feat(cloudflare): add support for MX records (#5283)
* feat(cloudflare): add support for MX records

Signed-off-by: Arthur Le Roux <arthurleroux@protonmail.com>

* test(txt): add additional TXT and MX record test cases

Signed-off-by: Arthur Le Roux <arthurleroux@protonmail.com>

* feat(endpoint): implement parsing for MX and SRV records with structured targets

Signed-off-by: Arthur Le Roux <arthurleroux@protonmail.com>

* fix(txt): remove TXT record type from supported types in NewTXTRegistry

Signed-off-by: Arthur Le Roux <arthurleroux@protonmail.com>

* refactor(digitalocean): streamline MX record handling

Signed-off-by: Arthur Le Roux <arthurleroux@protonmail.com>

* refactor(cloudflare): improve error handling in change creation

Signed-off-by: Arthur Le Roux <arthurleroux@protonmail.com>

* fix(endpoint): return all parsed SRV targets instead of a single target

Signed-off-by: Arthur Le Roux <arthurleroux@protonmail.com>

* test(endpoint): add parsing tests for MX and SRV records

Signed-off-by: Arthur Le Roux <arthurleroux@protonmail.com>

* fix(endpoint): streamline MX and SRV record validation and parsing

Signed-off-by: Arthur Le Roux <arthurleroux@protonmail.com>

* fix(digital_ocean): simplify MX record parsing

Signed-off-by: Arthur Le Roux <arthurleroux@protonmail.com>

* fix(docs): update link to CRD source in MX record documentation

Signed-off-by: Arthur Le Roux <arthurleroux@protonmail.com>

* fix(cloudflare): improve error handling for MX record parsing

Signed-off-by: Arthur Le Roux <arthurleroux@protonmail.com>

* fix(cloudflare): improve error message formatting for MX record parsing

Signed-off-by: Arthur Le Roux <arthurleroux@protonmail.com>

* refactor(endpoint): rename ParseMXRecord to NewMXTarget and update references

Signed-off-by: Arthur Le Roux <arthurleroux@protonmail.com>

* fix(endpoint): update NewMXTarget to return pointer and adjust tests accordingly

Signed-off-by: Arthur Le Roux <arthurleroux@protonmail.com>

* refactor(cloudflare): consolidate proxyEnabled and proxyDisabled variable declarations

Signed-off-by: Arthur Le Roux <arthurleroux@protonmail.com>

* fix(endpoint): update TestNewMXTarget to reflect changes in MXTarget struct fields and add missing test case for host validation

Signed-off-by: Arthur Le Roux <arthurleroux@protonmail.com>

* fix(digitalocean): improve MX record handling by adjusting error handling and ensuring proper priority and host retrieval

Signed-off-by: Arthur Le Roux <arthurleroux@protonmail.com>

* refactor(endpoint): change MXTarget fields to unexported and update NewMXTarget to use them

Signed-off-by: Arthur Le Roux <arthurleroux@protonmail.com>

* refactor(cloudflare): update groupByNameAndTypeWithCustomHostnames to use provider methods and enhance MX record handling in tests

Signed-off-by: Arthur Le Roux <arthurleroux@protonmail.com>

* test(cloudflare): enhance test cover

Signed-off-by: Arthur Le Roux <arthurleroux@protonmail.com>

* refactor(endpoint): remove unused SRVTarget struct from endpoint.go

Signed-off-by: Arthur Le Roux <arthurleroux@protonmail.com>

* refactor(endpoint): rename NewMXTarget to NewMXRecord for clarity and update references

Signed-off-by: Arthur Le Roux <arthurleroux@protonmail.com>

* Update docs/sources/mx-record.md

Co-authored-by: Michel Loiseleur <97035654+mloiseleur@users.noreply.github.com>

---------

Signed-off-by: Arthur Le Roux <arthurleroux@protonmail.com>
Co-authored-by: Michel Loiseleur <97035654+mloiseleur@users.noreply.github.com>
2025-06-23 23:32:28 -07:00

662 lines
17 KiB
Go

/*
Copyright 2017 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 digitalocean
import (
"context"
"fmt"
"os"
"strings"
"github.com/digitalocean/godo"
log "github.com/sirupsen/logrus"
"golang.org/x/oauth2"
"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"
)
const (
// defaultTTL is the default TTL value
defaultTTL = 300
)
// DigitalOceanProvider is an implementation of Provider for Digital Ocean's DNS.
type DigitalOceanProvider struct {
provider.BaseProvider
Client godo.DomainsService
// only consider hosted zones managing domains ending in this suffix
domainFilter *endpoint.DomainFilter
// page size when querying paginated APIs
apiPageSize int
DryRun bool
}
type digitalOceanChangeCreate struct {
Domain string
Options *godo.DomainRecordEditRequest
}
type digitalOceanChangeUpdate struct {
Domain string
DomainRecord godo.DomainRecord
Options *godo.DomainRecordEditRequest
}
type digitalOceanChangeDelete struct {
Domain string
RecordID int
}
// DigitalOceanChange contains all changes to apply to DNS
type digitalOceanChanges struct {
Creates []*digitalOceanChangeCreate
Updates []*digitalOceanChangeUpdate
Deletes []*digitalOceanChangeDelete
}
func (c *digitalOceanChanges) Empty() bool {
return len(c.Creates) == 0 && len(c.Updates) == 0 && len(c.Deletes) == 0
}
// NewDigitalOceanProvider initializes a new DigitalOcean DNS based Provider.
func NewDigitalOceanProvider(ctx context.Context, domainFilter *endpoint.DomainFilter, dryRun bool, apiPageSize int) (*DigitalOceanProvider, error) {
token, ok := os.LookupEnv("DO_TOKEN")
if !ok {
return nil, fmt.Errorf("no token found")
}
oauthClient := oauth2.NewClient(ctx, oauth2.StaticTokenSource(&oauth2.Token{
AccessToken: token,
}))
client, err := godo.New(oauthClient, godo.SetUserAgent(externaldns.UserAgent()))
if err != nil {
return nil, err
}
p := &DigitalOceanProvider{
Client: client.Domains,
domainFilter: domainFilter,
apiPageSize: apiPageSize,
DryRun: dryRun,
}
return p, nil
}
// Zones returns the list of hosted zones.
func (p *DigitalOceanProvider) Zones(ctx context.Context) ([]godo.Domain, error) {
result := []godo.Domain{}
zones, err := p.fetchZones(ctx)
if err != nil {
return nil, err
}
for _, zone := range zones {
if p.domainFilter.Match(zone.Name) {
result = append(result, zone)
}
}
return result, nil
}
// Merge Endpoints with the same Name and Type into a single endpoint with multiple Targets.
func mergeEndpointsByNameType(endpoints []*endpoint.Endpoint) []*endpoint.Endpoint {
endpointsByNameType := map[string][]*endpoint.Endpoint{}
for _, e := range endpoints {
key := fmt.Sprintf("%s-%s", e.DNSName, e.RecordType)
endpointsByNameType[key] = append(endpointsByNameType[key], e)
}
// If no merge occurred, just return the existing endpoints.
if len(endpointsByNameType) == len(endpoints) {
return endpoints
}
// Otherwise, construct a new list of endpoints with the endpoints merged.
var result []*endpoint.Endpoint
for _, endpoints := range endpointsByNameType {
dnsName := endpoints[0].DNSName
recordType := endpoints[0].RecordType
targets := make([]string, len(endpoints))
for i, e := range endpoints {
targets[i] = e.Targets[0]
}
e := endpoint.NewEndpoint(dnsName, recordType, targets...)
result = append(result, e)
}
return result
}
// Records returns the list of records in a given zone.
func (p *DigitalOceanProvider) Records(ctx context.Context) ([]*endpoint.Endpoint, error) {
zones, err := p.Zones(ctx)
if err != nil {
return nil, err
}
endpoints := []*endpoint.Endpoint{}
for _, zone := range zones {
records, err := p.fetchRecords(ctx, zone.Name)
if err != nil {
return nil, err
}
for _, r := range records {
if p.SupportedRecordType(r.Type) {
name := r.Name + "." + zone.Name
data := r.Data
// root name is identified by @ and should be
// translated to zone name for the endpoint entry.
if r.Name == "@" {
name = zone.Name
}
if r.Type == endpoint.RecordTypeMX {
data = fmt.Sprintf("%d %s", r.Priority, r.Data)
}
ep := endpoint.NewEndpointWithTTL(name, r.Type, endpoint.TTL(r.TTL), data)
endpoints = append(endpoints, ep)
}
}
}
// Merge endpoints with the same name and type (e.g., multiple A records for a single
// DNS name) into one endpoint with multiple targets.
endpoints = mergeEndpointsByNameType(endpoints)
// Log the endpoints that were found.
log.WithFields(log.Fields{
"endpoints": endpoints,
}).Debug("Endpoints generated from DigitalOcean DNS")
return endpoints, nil
}
func (p *DigitalOceanProvider) fetchRecords(ctx context.Context, zoneName string) ([]godo.DomainRecord, error) {
allRecords := []godo.DomainRecord{}
listOptions := &godo.ListOptions{PerPage: p.apiPageSize}
for {
records, resp, err := p.Client.Records(ctx, zoneName, listOptions)
if err != nil {
return nil, err
}
allRecords = append(allRecords, records...)
if resp == nil || resp.Links == nil || resp.Links.IsLastPage() {
break
}
page, err := resp.Links.CurrentPage()
if err != nil {
return nil, err
}
listOptions.Page = page + 1
}
return allRecords, nil
}
func (p *DigitalOceanProvider) fetchZones(ctx context.Context) ([]godo.Domain, error) {
allZones := []godo.Domain{}
listOptions := &godo.ListOptions{PerPage: p.apiPageSize}
for {
zones, resp, err := p.Client.List(ctx, listOptions)
if err != nil {
return nil, err
}
allZones = append(allZones, zones...)
if resp == nil || resp.Links == nil || resp.Links.IsLastPage() {
break
}
page, err := resp.Links.CurrentPage()
if err != nil {
return nil, err
}
listOptions.Page = page + 1
}
return allZones, nil
}
func (p *DigitalOceanProvider) getRecordsByDomain(ctx context.Context) (map[string][]godo.DomainRecord, provider.ZoneIDName, error) {
recordsByDomain := map[string][]godo.DomainRecord{}
zones, err := p.Zones(ctx)
if err != nil {
return nil, nil, err
}
zonesByDomain := make(map[string]godo.Domain)
zoneNameIDMapper := provider.ZoneIDName{}
for _, z := range zones {
zoneNameIDMapper.Add(z.Name, z.Name)
zonesByDomain[z.Name] = z
}
// Fetch records for each zone
for _, zone := range zones {
records, err := p.fetchRecords(ctx, zone.Name)
if err != nil {
return nil, nil, err
}
recordsByDomain[zone.Name] = append(recordsByDomain[zone.Name], records...)
}
return recordsByDomain, zoneNameIDMapper, nil
}
// Make a DomainRecordEditRequest that conforms to DigitalOcean API requirements:
// - Records at root of the zone have `@` as the name
// - CNAME records must end in a `.`
func makeDomainEditRequest(domain, name, recordType, data string, ttl int) *godo.DomainRecordEditRequest {
// Trim the domain off the name if present.
adjustedName := strings.TrimSuffix(name, "."+domain)
// Record at the root should be defined as @ instead of the full domain name.
if adjustedName == domain {
adjustedName = "@"
}
// For some reason the DO API requires the '.' at the end of "data" in case of CNAME request.
// Example: {"type":"CNAME","name":"hello","data":"www.example.com."}
if (recordType == endpoint.RecordTypeCNAME || recordType == endpoint.RecordTypeMX) && !strings.HasSuffix(data, ".") {
data += "."
}
request := &godo.DomainRecordEditRequest{
Name: adjustedName,
Type: recordType,
Data: data,
TTL: ttl,
}
if recordType == endpoint.RecordTypeMX {
mxRecord, err := endpoint.NewMXRecord(data)
if err != nil {
log.WithFields(log.Fields{
"domain": domain,
"dnsName": name,
"recordType": recordType,
"data": data,
}).Warn("Unable to parse MX target")
return request
}
request.Priority = int(*mxRecord.GetPriority())
request.Data = provider.EnsureTrailingDot(*mxRecord.GetHost())
}
return request
}
// submitChanges applies an instance of `digitalOceanChanges` to the DigitalOcean API.
func (p *DigitalOceanProvider) submitChanges(ctx context.Context, changes *digitalOceanChanges) error {
// return early if there is nothing to change
if changes.Empty() {
return nil
}
for _, c := range changes.Creates {
logFields := log.Fields{
"domain": c.Domain,
"dnsName": c.Options.Name,
"recordType": c.Options.Type,
"data": c.Options.Data,
"ttl": c.Options.TTL,
}
if c.Options.Type == endpoint.RecordTypeMX {
logFields["priority"] = c.Options.Priority
}
log.WithFields(logFields).Debug("Creating domain record")
if p.DryRun {
continue
}
_, _, err := p.Client.CreateRecord(ctx, c.Domain, c.Options)
if err != nil {
return err
}
}
for _, u := range changes.Updates {
logFields := log.Fields{
"domain": u.Domain,
"dnsName": u.Options.Name,
"recordType": u.Options.Type,
"data": u.Options.Data,
"ttl": u.Options.TTL,
}
if u.Options.Type == endpoint.RecordTypeMX {
logFields["priority"] = u.Options.Priority
}
log.WithFields(logFields).Debug("Updating domain record")
if p.DryRun {
continue
}
_, _, err := p.Client.EditRecord(ctx, u.Domain, u.DomainRecord.ID, u.Options)
if err != nil {
return err
}
}
for _, d := range changes.Deletes {
log.WithFields(log.Fields{
"domain": d.Domain,
"recordId": d.RecordID,
}).Debug("Deleting domain record")
if p.DryRun {
continue
}
_, err := p.Client.DeleteRecord(ctx, d.Domain, d.RecordID)
if err != nil {
return err
}
}
return nil
}
func getTTLFromEndpoint(ep *endpoint.Endpoint) int {
if ep.RecordTTL.IsConfigured() {
return int(ep.RecordTTL)
}
return defaultTTL
}
func endpointsByZone(zoneNameIDMapper provider.ZoneIDName, endpoints []*endpoint.Endpoint) map[string][]*endpoint.Endpoint {
endpointsByZone := make(map[string][]*endpoint.Endpoint)
for _, ep := range endpoints {
zoneID, _ := zoneNameIDMapper.FindZone(ep.DNSName)
if zoneID == "" {
log.Debugf("Skipping record %s because no hosted zone matching record DNS Name was detected", ep.DNSName)
continue
}
endpointsByZone[zoneID] = append(endpointsByZone[zoneID], ep)
}
return endpointsByZone
}
func getMatchingDomainRecords(records []godo.DomainRecord, domain string, ep *endpoint.Endpoint) []godo.DomainRecord {
var name string
if ep.DNSName != domain {
name = strings.TrimSuffix(ep.DNSName, "."+domain)
} else {
name = "@"
}
var result []godo.DomainRecord
for _, r := range records {
if r.Name == name && r.Type == ep.RecordType {
result = append(result, r)
}
}
return result
}
func processCreateActions(
recordsByDomain map[string][]godo.DomainRecord,
createsByDomain map[string][]*endpoint.Endpoint,
changes *digitalOceanChanges,
) error {
// Process endpoints that need to be created.
for domain, endpoints := range createsByDomain {
if len(endpoints) == 0 {
log.WithFields(log.Fields{
"domain": domain,
}).Debug("Skipping domain, no creates found.")
continue
}
records := recordsByDomain[domain]
for _, ep := range endpoints {
// Warn if there are existing records since we expect to create only new records.
matchingRecords := getMatchingDomainRecords(records, domain, ep)
if len(matchingRecords) > 0 {
log.WithFields(log.Fields{
"domain": domain,
"dnsName": ep.DNSName,
"recordType": ep.RecordType,
}).Warn("Preexisting records exist which should not exist for creation actions.")
}
ttl := getTTLFromEndpoint(ep)
for _, target := range ep.Targets {
changes.Creates = append(changes.Creates, &digitalOceanChangeCreate{
Domain: domain,
Options: makeDomainEditRequest(domain, ep.DNSName, ep.RecordType, target, ttl),
})
}
}
}
return nil
}
func processUpdateActions(
recordsByDomain map[string][]godo.DomainRecord,
updatesByDomain map[string][]*endpoint.Endpoint,
changes *digitalOceanChanges,
) error {
// Generate creates and updates based on existing
for domain, updates := range updatesByDomain {
if len(updates) == 0 {
log.WithFields(log.Fields{
"domain": domain,
}).Debug("Skipping Zone, no updates found.")
continue
}
records := recordsByDomain[domain]
log.WithFields(log.Fields{
"domain": domain,
"records": records,
}).Debug("Records for domain")
for _, ep := range updates {
matchingRecords := getMatchingDomainRecords(records, domain, ep)
log.WithFields(log.Fields{
"endpoint": ep,
"matchingRecords": matchingRecords,
}).Debug("matching records")
if len(matchingRecords) == 0 {
log.WithFields(log.Fields{
"domain": domain,
"dnsName": ep.DNSName,
"recordType": ep.RecordType,
}).Warn("Planning an update but no existing records found.")
}
matchingRecordsByTarget := map[string]godo.DomainRecord{}
for _, r := range matchingRecords {
matchingRecordsByTarget[r.Data] = r
}
ttl := getTTLFromEndpoint(ep)
// Generate create and delete actions based on existence of a record for each target.
for _, target := range ep.Targets {
if record, ok := matchingRecordsByTarget[target]; ok {
log.WithFields(log.Fields{
"domain": domain,
"dnsName": ep.DNSName,
"recordType": ep.RecordType,
"target": target,
}).Warn("Updating existing target")
changes.Updates = append(changes.Updates, &digitalOceanChangeUpdate{
Domain: domain,
DomainRecord: record,
Options: makeDomainEditRequest(domain, ep.DNSName, ep.RecordType, target, ttl),
})
delete(matchingRecordsByTarget, target)
} else {
// Record did not previously exist, create new 'target'
log.WithFields(log.Fields{
"domain": domain,
"dnsName": ep.DNSName,
"recordType": ep.RecordType,
"target": target,
}).Warn("Creating new target")
changes.Creates = append(changes.Creates, &digitalOceanChangeCreate{
Domain: domain,
Options: makeDomainEditRequest(domain, ep.DNSName, ep.RecordType, target, ttl),
})
}
}
// Any remaining records have been removed, delete them
for _, record := range matchingRecordsByTarget {
log.WithFields(log.Fields{
"domain": domain,
"dnsName": ep.DNSName,
"recordType": ep.RecordType,
"target": record.Data,
}).Warn("Deleting target")
changes.Deletes = append(changes.Deletes, &digitalOceanChangeDelete{
Domain: domain,
RecordID: record.ID,
})
}
}
}
return nil
}
func processDeleteActions(
recordsByDomain map[string][]godo.DomainRecord,
deletesByDomain map[string][]*endpoint.Endpoint,
changes *digitalOceanChanges,
) error {
// Generate delete actions for each deleted endpoint.
for domain, deletes := range deletesByDomain {
if len(deletes) == 0 {
log.WithFields(log.Fields{
"domain": domain,
}).Debug("Skipping Zone, no deletes found.")
continue
}
records := recordsByDomain[domain]
for _, ep := range deletes {
matchingRecords := getMatchingDomainRecords(records, domain, ep)
if len(matchingRecords) == 0 {
log.WithFields(log.Fields{
"domain": domain,
"dnsName": ep.DNSName,
"recordType": ep.RecordType,
}).Warn("Records to delete not found.")
}
for _, record := range matchingRecords {
doDelete := false
for _, t := range ep.Targets {
v1 := t
v2 := record.Data
if ep.RecordType == endpoint.RecordTypeCNAME {
v1 = strings.TrimSuffix(t, ".")
v2 = strings.TrimSuffix(t, ".")
}
if v1 == v2 {
doDelete = true
}
}
if doDelete {
changes.Deletes = append(changes.Deletes, &digitalOceanChangeDelete{
Domain: domain,
RecordID: record.ID,
})
}
}
}
}
return nil
}
// SupportedRecordType returns true if the record type is supported by the provider
func (p *DigitalOceanProvider) SupportedRecordType(recordType string) bool {
switch recordType {
case "MX":
return true
default:
return provider.SupportedRecordType(recordType)
}
}
// ApplyChanges applies the given set of generic changes to the provider.
func (p *DigitalOceanProvider) ApplyChanges(ctx context.Context, planChanges *plan.Changes) error {
// TODO: This should only retrieve zones affected by the given `planChanges`.
recordsByDomain, zoneNameIDMapper, err := p.getRecordsByDomain(ctx)
if err != nil {
return err
}
createsByDomain := endpointsByZone(zoneNameIDMapper, planChanges.Create)
updatesByDomain := endpointsByZone(zoneNameIDMapper, planChanges.UpdateNew)
deletesByDomain := endpointsByZone(zoneNameIDMapper, planChanges.Delete)
var changes digitalOceanChanges
if err := processCreateActions(recordsByDomain, createsByDomain, &changes); err != nil {
return err
}
if err := processUpdateActions(recordsByDomain, updatesByDomain, &changes); err != nil {
return err
}
if err := processDeleteActions(recordsByDomain, deletesByDomain, &changes); err != nil {
return err
}
return p.submitChanges(ctx, &changes)
}