Skip to content

Commit c3f0cd6

Browse files
committed
fix cloudflare regional hostnames
Implements create & delete of regional hostnames for A, AAAA & CNAME records. Implements "external-dns.alpha.kubernetes.io/cloudflare-region-key" annotation.
1 parent 0d97521 commit c3f0cd6

File tree

4 files changed

+457
-47
lines changed

4 files changed

+457
-47
lines changed

provider/cloudflare/cloudflare.go

Lines changed: 189 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,6 @@ import (
2727
"sort"
2828
"strconv"
2929
"strings"
30-
"time"
3130

3231
cloudflare "github.com/cloudflare/cloudflare-go"
3332
log "github.com/sirupsen/logrus"
@@ -74,6 +73,11 @@ type CustomHostnameIndex struct {
7473

7574
type CustomHostnamesMap map[CustomHostnameIndex]cloudflare.CustomHostname
7675

76+
type DataLocalizationRegionalHostnameChange struct {
77+
Action string
78+
cloudflare.RegionalHostname
79+
}
80+
7781
var recordTypeProxyNotSupported = map[string]bool{
7882
"LOC": true,
7983
"MX": true,
@@ -94,6 +98,12 @@ var recordTypeCustomHostnameSupported = map[string]bool{
9498
"CNAME": true,
9599
}
96100

101+
var recordTypeRegionalHostnameSupported = map[string]bool{
102+
"A": true,
103+
"AAAA": true,
104+
"CNAME": true,
105+
}
106+
97107
// cloudFlareDNS is the subset of the CloudFlare API that we actually use. Add methods as required. Signatures must match exactly.
98108
type cloudFlareDNS interface {
99109
UserDetails(ctx context.Context) (cloudflare.User, error)
@@ -105,7 +115,9 @@ type cloudFlareDNS interface {
105115
CreateDNSRecord(ctx context.Context, rc *cloudflare.ResourceContainer, rp cloudflare.CreateDNSRecordParams) (cloudflare.DNSRecord, error)
106116
DeleteDNSRecord(ctx context.Context, rc *cloudflare.ResourceContainer, recordID string) error
107117
UpdateDNSRecord(ctx context.Context, rc *cloudflare.ResourceContainer, rp cloudflare.UpdateDNSRecordParams) error
118+
CreateDataLocalizationRegionalHostname(ctx context.Context, rc *cloudflare.ResourceContainer, rp cloudflare.CreateDataLocalizationRegionalHostnameParams) error
108119
UpdateDataLocalizationRegionalHostname(ctx context.Context, rc *cloudflare.ResourceContainer, rp cloudflare.UpdateDataLocalizationRegionalHostnameParams) error
120+
DeleteDataLocalizationRegionalHostname(ctx context.Context, rc *cloudflare.ResourceContainer, hostname string) error
109121
CustomHostnames(ctx context.Context, zoneID string, page int, filter cloudflare.CustomHostname) ([]cloudflare.CustomHostname, cloudflare.ResultInfo, error)
110122
DeleteCustomHostname(ctx context.Context, zoneID string, customHostnameID string) error
111123
CreateCustomHostname(ctx context.Context, zoneID string, ch cloudflare.CustomHostname) (*cloudflare.CustomHostnameResponse, error)
@@ -140,11 +152,20 @@ func (z zoneService) UpdateDNSRecord(ctx context.Context, rc *cloudflare.Resourc
140152
return err
141153
}
142154

155+
func (z zoneService) CreateDataLocalizationRegionalHostname(ctx context.Context, rc *cloudflare.ResourceContainer, rp cloudflare.CreateDataLocalizationRegionalHostnameParams) error {
156+
_, err := z.service.CreateDataLocalizationRegionalHostname(ctx, rc, rp)
157+
return err
158+
}
159+
143160
func (z zoneService) UpdateDataLocalizationRegionalHostname(ctx context.Context, rc *cloudflare.ResourceContainer, rp cloudflare.UpdateDataLocalizationRegionalHostnameParams) error {
144161
_, err := z.service.UpdateDataLocalizationRegionalHostname(ctx, rc, rp)
145162
return err
146163
}
147164

165+
func (z zoneService) DeleteDataLocalizationRegionalHostname(ctx context.Context, rc *cloudflare.ResourceContainer, hostname string) error {
166+
return z.service.DeleteDataLocalizationRegionalHostname(ctx, rc, hostname)
167+
}
168+
148169
func (z zoneService) DeleteDNSRecord(ctx context.Context, rc *cloudflare.ResourceContainer, recordID string) error {
149170
return z.service.DeleteDNSRecord(ctx, rc, recordID)
150171
}
@@ -208,11 +229,19 @@ func updateDNSRecordParam(cfc cloudFlareChange) cloudflare.UpdateDNSRecordParams
208229
}
209230
}
210231

232+
// createDataLocalizationRegionalHostnameParams is a function that returns the appropriate RegionalHostname Param based on the cloudFlareChange passed in
233+
func createDataLocalizationRegionalHostnameParams(rhc DataLocalizationRegionalHostnameChange) cloudflare.CreateDataLocalizationRegionalHostnameParams {
234+
return cloudflare.CreateDataLocalizationRegionalHostnameParams{
235+
Hostname: rhc.Hostname,
236+
RegionKey: rhc.RegionKey,
237+
}
238+
}
239+
211240
// updateDataLocalizationRegionalHostnameParams is a function that returns the appropriate RegionalHostname Param based on the cloudFlareChange passed in
212-
func updateDataLocalizationRegionalHostnameParams(cfc cloudFlareChange) cloudflare.UpdateDataLocalizationRegionalHostnameParams {
241+
func updateDataLocalizationRegionalHostnameParams(rhc DataLocalizationRegionalHostnameChange) cloudflare.UpdateDataLocalizationRegionalHostnameParams {
213242
return cloudflare.UpdateDataLocalizationRegionalHostnameParams{
214-
Hostname: cfc.RegionalHostname.Hostname,
215-
RegionKey: cfc.RegionalHostname.RegionKey,
243+
Hostname: rhc.Hostname,
244+
RegionKey: rhc.RegionKey,
216245
}
217246
}
218247

@@ -466,6 +495,126 @@ func (p *CloudFlareProvider) submitCustomHostnameChanges(ctx context.Context, zo
466495
return !failedChange
467496
}
468497

498+
// submitDataLocalizationRegionalHostnameChanges applies a set of data localization regional hostname changes, returns false if it fails
499+
func (p *CloudFlareProvider) submitDataLocalizationRegionalHostnameChanges(ctx context.Context, changes []DataLocalizationRegionalHostnameChange, resourceContainer *cloudflare.ResourceContainer) bool {
500+
failedChange := false
501+
502+
for _, change := range changes {
503+
logFields := log.Fields{
504+
"hostname": change.Hostname,
505+
"region_key": change.RegionKey,
506+
"action": change.Action,
507+
"zone": resourceContainer.Identifier,
508+
}
509+
log.WithFields(logFields).Info("Changing regional hostname")
510+
switch change.Action {
511+
case cloudFlareCreate:
512+
log.WithFields(logFields).Debug("Creating regional hostname")
513+
if p.DryRun {
514+
continue
515+
}
516+
regionalHostnameParam := createDataLocalizationRegionalHostnameParams(change)
517+
err := p.Client.CreateDataLocalizationRegionalHostname(ctx, resourceContainer, regionalHostnameParam)
518+
if err != nil {
519+
var apiErr *cloudflare.Error
520+
if errors.As(err, &apiErr) && apiErr.StatusCode == http.StatusConflict {
521+
log.WithFields(logFields).Debug("Regional hostname already exists, updating instead")
522+
params := updateDataLocalizationRegionalHostnameParams(change)
523+
err := p.Client.UpdateDataLocalizationRegionalHostname(ctx, resourceContainer, params)
524+
if err != nil {
525+
failedChange = true
526+
log.WithFields(logFields).Errorf("failed to update regional hostname: %v", err)
527+
}
528+
continue
529+
}
530+
failedChange = true
531+
log.WithFields(logFields).Errorf("failed to create regional hostname: %v", err)
532+
}
533+
case cloudFlareUpdate:
534+
log.WithFields(logFields).Debug("Updating regional hostname")
535+
if p.DryRun {
536+
continue
537+
}
538+
regionalHostnameParam := updateDataLocalizationRegionalHostnameParams(change)
539+
err := p.Client.UpdateDataLocalizationRegionalHostname(ctx, resourceContainer, regionalHostnameParam)
540+
if err != nil {
541+
var apiErr *cloudflare.Error
542+
if errors.As(err, &apiErr) && apiErr.StatusCode == http.StatusNotFound {
543+
log.WithFields(logFields).Debug("Regional hostname not does not exists, creating instead")
544+
params := createDataLocalizationRegionalHostnameParams(change)
545+
err := p.Client.CreateDataLocalizationRegionalHostname(ctx, resourceContainer, params)
546+
if err != nil {
547+
failedChange = true
548+
log.WithFields(logFields).Errorf("failed to create regional hostname: %v", err)
549+
}
550+
continue
551+
}
552+
failedChange = true
553+
log.WithFields(logFields).Errorf("failed to update regional hostname: %v", err)
554+
}
555+
case cloudFlareDelete:
556+
log.WithFields(logFields).Debug("Deleting regional hostname")
557+
if p.DryRun {
558+
continue
559+
}
560+
err := p.Client.DeleteDataLocalizationRegionalHostname(ctx, resourceContainer, change.Hostname)
561+
if err != nil {
562+
var apiErr *cloudflare.Error
563+
if errors.As(err, &apiErr) && apiErr.StatusCode == http.StatusNotFound {
564+
log.WithFields(logFields).Debug("Regional hostname does not exists, nothing to do")
565+
continue
566+
}
567+
failedChange = true
568+
log.WithFields(logFields).Errorf("failed to delete regional hostname: %v", err)
569+
}
570+
}
571+
}
572+
573+
return !failedChange
574+
}
575+
576+
// dataLocalizationRegionalHostnamesChanges processes a slice of cloudFlare changes and consolidates them
577+
// into a list of data localization regional hostname changes.
578+
func dataLocalizationRegionalHostnamesChanges(changes []*cloudFlareChange) ([]DataLocalizationRegionalHostnameChange, error) {
579+
regionalHostnameChanges := make(map[string]DataLocalizationRegionalHostnameChange)
580+
for _, change := range changes {
581+
if change.RegionalHostname.Hostname == "" {
582+
continue
583+
}
584+
if change.RegionalHostname.RegionKey == "" {
585+
return nil, fmt.Errorf("region key is empty for regional hostname %q", change.RegionalHostname.Hostname)
586+
}
587+
regionalHostname, ok := regionalHostnameChanges[change.RegionalHostname.Hostname]
588+
switch change.Action {
589+
case cloudFlareCreate, cloudFlareUpdate:
590+
if !ok {
591+
regionalHostnameChanges[change.RegionalHostname.Hostname] = DataLocalizationRegionalHostnameChange{
592+
Action: change.Action,
593+
RegionalHostname: change.RegionalHostname,
594+
}
595+
continue
596+
}
597+
if regionalHostname.RegionKey != change.RegionalHostname.RegionKey {
598+
return nil, fmt.Errorf("conflicting region keys for regional hostname %q: %q and %q", change.RegionalHostname.Hostname, regionalHostname.RegionKey, change.RegionalHostname.RegionKey)
599+
}
600+
if change.Action == cloudFlareUpdate {
601+
regionalHostname.Action = cloudFlareUpdate
602+
} else if regionalHostname.Action == cloudFlareDelete {
603+
regionalHostname.Action = cloudFlareUpdate
604+
}
605+
case cloudFlareDelete:
606+
if !ok {
607+
regionalHostnameChanges[change.RegionalHostname.Hostname] = DataLocalizationRegionalHostnameChange{
608+
Action: cloudFlareDelete,
609+
RegionalHostname: change.RegionalHostname,
610+
}
611+
continue
612+
}
613+
}
614+
}
615+
return slices.Collect(maps.Values(regionalHostnameChanges)), nil
616+
}
617+
469618
// submitChanges takes a zone and a collection of Changes and sends them as a single transaction.
470619
func (p *CloudFlareProvider) submitChanges(ctx context.Context, changes []*cloudFlareChange) error {
471620
// return early if there is nothing to change
@@ -484,6 +633,8 @@ func (p *CloudFlareProvider) submitChanges(ctx context.Context, changes []*cloud
484633
var failedZones []string
485634
for zoneID, zoneChanges := range changesByZone {
486635
var failedChange bool
636+
resourceContainer := cloudflare.ZoneIdentifier(zoneID)
637+
487638
for _, change := range zoneChanges {
488639
logFields := log.Fields{
489640
"record": change.ResourceRecord.Name,
@@ -499,7 +650,6 @@ func (p *CloudFlareProvider) submitChanges(ctx context.Context, changes []*cloud
499650
continue
500651
}
501652

502-
resourceContainer := cloudflare.ZoneIdentifier(zoneID)
503653
records, err := p.listDNSRecordsWithAutoPagination(ctx, zoneID)
504654
if err != nil {
505655
return fmt.Errorf("could not fetch records from zone, %w", err)
@@ -524,13 +674,6 @@ func (p *CloudFlareProvider) submitChanges(ctx context.Context, changes []*cloud
524674
failedChange = true
525675
log.WithFields(logFields).Errorf("failed to update record: %v", err)
526676
}
527-
if regionalHostnameParam := updateDataLocalizationRegionalHostnameParams(*change); regionalHostnameParam.RegionKey != "" {
528-
regionalHostnameErr := p.Client.UpdateDataLocalizationRegionalHostname(ctx, resourceContainer, regionalHostnameParam)
529-
if regionalHostnameErr != nil {
530-
failedChange = true
531-
log.WithFields(logFields).Errorf("failed to update record when editing region: %v", regionalHostnameErr)
532-
}
533-
}
534677
} else if change.Action == cloudFlareDelete {
535678
recordID := p.getRecordID(records, change.ResourceRecord)
536679
if recordID == "" {
@@ -557,6 +700,19 @@ func (p *CloudFlareProvider) submitChanges(ctx context.Context, changes []*cloud
557700
}
558701
}
559702
}
703+
704+
if regionalHostnamesChanges, err := dataLocalizationRegionalHostnamesChanges(zoneChanges); err == nil {
705+
if !p.submitDataLocalizationRegionalHostnameChanges(ctx, regionalHostnamesChanges, resourceContainer) {
706+
failedChange = true
707+
}
708+
} else {
709+
logFields := log.Fields{
710+
"zone": zoneID,
711+
}
712+
log.WithFields(logFields).Errorf("failed to build data localization regional hostname changes: %v", err)
713+
failedChange = true
714+
}
715+
560716
if failedChange {
561717
failedZones = append(failedZones, zoneID)
562718
}
@@ -649,7 +805,6 @@ func (p *CloudFlareProvider) newCloudFlareChange(action string, ep *endpoint.End
649805
if ep.RecordTTL.IsConfigured() {
650806
ttl = int(ep.RecordTTL)
651807
}
652-
dt := time.Now()
653808

654809
prevCustomHostnames := []string{}
655810
newCustomHostnames := map[string]cloudflare.CustomHostname{}
@@ -661,6 +816,13 @@ func (p *CloudFlareProvider) newCloudFlareChange(action string, ep *endpoint.End
661816
newCustomHostnames[v] = p.newCustomHostname(v, ep.DNSName)
662817
}
663818
}
819+
regionalHostname := cloudflare.RegionalHostname{}
820+
if regionKey := getRegionKey(ep, p.RegionKey); regionKey != "" {
821+
regionalHostname = cloudflare.RegionalHostname{
822+
Hostname: ep.DNSName,
823+
RegionKey: regionKey,
824+
}
825+
}
664826
return &cloudFlareChange{
665827
Action: action,
666828
ResourceRecord: cloudflare.DNSRecord{
@@ -671,15 +833,8 @@ func (p *CloudFlareProvider) newCloudFlareChange(action string, ep *endpoint.End
671833
Proxied: &proxied,
672834
Type: ep.RecordType,
673835
Content: target,
674-
Meta: map[string]interface{}{
675-
"region": p.RegionKey,
676-
},
677-
},
678-
RegionalHostname: cloudflare.RegionalHostname{
679-
Hostname: ep.DNSName,
680-
RegionKey: p.RegionKey,
681-
CreatedOn: &dt,
682836
},
837+
RegionalHostname: regionalHostname,
683838
CustomHostnamesPrev: prevCustomHostnames,
684839
CustomHostnames: newCustomHostnames,
685840
}
@@ -787,6 +942,19 @@ func shouldBeProxied(ep *endpoint.Endpoint, proxiedByDefault bool) bool {
787942
return proxied
788943
}
789944

945+
func getRegionKey(endpoint *endpoint.Endpoint, defaultRegionKey string) string {
946+
if !recordTypeRegionalHostnameSupported[endpoint.RecordType] {
947+
return ""
948+
}
949+
950+
for _, v := range endpoint.ProviderSpecific {
951+
if v.Name == source.CloudflareRegionKey {
952+
return v.Value
953+
}
954+
}
955+
return defaultRegionKey
956+
}
957+
790958
func getEndpointCustomHostnames(ep *endpoint.Endpoint) []string {
791959
for _, v := range ep.ProviderSpecific {
792960
if v.Name == source.CloudflareCustomHostnameKey {

0 commit comments

Comments
 (0)