Skip to content
Merged
6 changes: 4 additions & 2 deletions controller/execute.go
Original file line number Diff line number Diff line change
Expand Up @@ -242,8 +242,10 @@ func buildProvider(
CertificateAuthority: cfg.CloudflareCustomHostnamesCertificateAuthority,
},
cloudflare.DNSRecordsConfig{
PerPage: cfg.CloudflareDNSRecordsPerPage,
Comment: cfg.CloudflareDNSRecordsComment,
PerPage: cfg.CloudflareDNSRecordsPerPage,
Comment: cfg.CloudflareDNSRecordsComment,
BatchChangeSize: cfg.BatchChangeSize,
BatchChangeInterval: cfg.BatchChangeInterval,
})
case "google":
p, err = google.NewGoogleProvider(ctx, cfg.GoogleProject, domainFilter, zoneIDFilter, cfg.GoogleBatchChangeSize, cfg.GoogleBatchChangeInterval, cfg.GoogleZoneVisibility, cfg.DryRun)
Expand Down
2 changes: 2 additions & 0 deletions docs/flags.md
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,8 @@
| `--azure-user-assigned-identity-client-id=""` | When using the Azure provider, override the client id of user assigned identity in config file (optional) |
| `--azure-zones-cache-duration=0s` | When using the Azure provider, set the zones list cache TTL (0s to disable). |
| `--azure-maxretries-count=3` | When using the Azure provider, set the number of retries for API calls (When less than 0, it disables retries). (optional) |
| `--batch-change-size=200` | Set the maximum number of DNS record changes that will be submitted to the provider in each batch (optional) |
| `--batch-change-interval=1s` | Set the interval between batch changes (optional, default: 1s) |
| `--[no-]cloudflare-proxied` | When using the Cloudflare provider, specify if the proxy mode must be enabled (default: disabled) |
| `--[no-]cloudflare-custom-hostnames` | When using the Cloudflare provider, specify if the Custom Hostnames feature will be used. Requires "Cloudflare for SaaS" enabled. (default: disabled) |
| `--cloudflare-custom-hostnames-min-tls-version=1.0` | When using the Cloudflare provider with the Custom Hostnames, specify which Minimum TLS Version will be used by default. (default: 1.0, options: 1.0, 1.1, 1.2, 1.3) |
Expand Down
15 changes: 15 additions & 0 deletions docs/tutorials/cloudflare.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,21 @@ If you would like to further restrict the API permissions to a specific zone (or
Cloudflare API has a [global rate limit of 1,200 requests per five minutes](https://developers.cloudflare.com/fundamentals/api/reference/limits/). Running several fast polling ExternalDNS instances in a given account can easily hit that limit.
The AWS Provider [docs](./aws.md#throttling) has some recommendations that can be followed here too, but in particular, consider passing `--cloudflare-dns-records-per-page` with a high value (maximum is 5,000).

## Batch API

The Cloudflare provider submits DNS record changes using Cloudflare's [Batch DNS Records API](https://developers.cloudflare.com/api/resources/dns/subresources/records/methods/batch/).
All creates, updates, and deletes for a zone are grouped into transactional chunks and sent in a single API call per chunk,
significantly reducing the total number of requests made.

The batch API is transactional — if a chunk fails, the entire chunk is rolled back by Cloudflare.
In that case, ExternalDNS automatically retries each record change in the chunk individually.
Record types that are not supported by the batch PUT operation (e.g. SRV, CAA) are always submitted individually rather than through the batch API.

| Flag | Default | Description |
| :--- | :------ | :---------- |
| `--batch-change-size` | `200` | Maximum number of DNS operations (creates + updates + deletes) per batch chunk. |
| `--batch-change-interval` | `1s` | Pause between consecutive batch chunks. |

## Deploy ExternalDNS

Connect your `kubectl` client to the cluster you want to test ExternalDNS with.
Expand Down
6 changes: 6 additions & 0 deletions pkg/apis/externaldns/types.go
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,8 @@ type Config struct {
AzureActiveDirectoryAuthorityHost string
AzureZonesCacheDuration time.Duration
AzureMaxRetriesCount int
BatchChangeSize int
BatchChangeInterval time.Duration
CloudflareProxied bool
CloudflareCustomHostnames bool
CloudflareDNSRecordsPerPage int
Expand Down Expand Up @@ -253,6 +255,8 @@ var defaultConfig = &Config{
AzureSubscriptionID: "",
AzureZonesCacheDuration: 0 * time.Second,
AzureMaxRetriesCount: 3,
BatchChangeSize: 200,
BatchChangeInterval: time.Second,
CloudflareCustomHostnamesCertificateAuthority: "none",
CloudflareCustomHostnames: false,
CloudflareCustomHostnamesMinTLSVersion: "1.0",
Expand Down Expand Up @@ -582,6 +586,8 @@ func bindFlags(b flags.FlagBinder, cfg *Config) {
b.DurationVar("azure-zones-cache-duration", "When using the Azure provider, set the zones list cache TTL (0s to disable).", defaultConfig.AzureZonesCacheDuration, &cfg.AzureZonesCacheDuration)
b.IntVar("azure-maxretries-count", "When using the Azure provider, set the number of retries for API calls (When less than 0, it disables retries). (optional)", defaultConfig.AzureMaxRetriesCount, &cfg.AzureMaxRetriesCount)

b.IntVar("batch-change-size", "Set the maximum number of DNS record changes that will be submitted to the provider in each batch (optional)", defaultConfig.BatchChangeSize, &cfg.BatchChangeSize)
b.DurationVar("batch-change-interval", "Set the interval between batch changes (optional, default: 1s)", defaultConfig.BatchChangeInterval, &cfg.BatchChangeInterval)
b.BoolVar("cloudflare-proxied", "When using the Cloudflare provider, specify if the proxy mode must be enabled (default: disabled)", false, &cfg.CloudflareProxied)
b.BoolVar("cloudflare-custom-hostnames", "When using the Cloudflare provider, specify if the Custom Hostnames feature will be used. Requires \"Cloudflare for SaaS\" enabled. (default: disabled)", false, &cfg.CloudflareCustomHostnames)
b.EnumVar("cloudflare-custom-hostnames-min-tls-version", "When using the Cloudflare provider with the Custom Hostnames, specify which Minimum TLS Version will be used by default. (default: 1.0, options: 1.0, 1.1, 1.2, 1.3)", "1.0", &cfg.CloudflareCustomHostnamesMinTLSVersion, "1.0", "1.1", "1.2", "1.3")
Expand Down
6 changes: 6 additions & 0 deletions pkg/apis/externaldns/types_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,8 @@ var (
AzureResourceGroup: "",
AzureSubscriptionID: "",
AzureMaxRetriesCount: 3,
BatchChangeSize: 200,
BatchChangeInterval: time.Second,
CloudflareProxied: false,
CloudflareCustomHostnames: false,
CloudflareCustomHostnamesMinTLSVersion: "1.0",
Expand Down Expand Up @@ -185,6 +187,8 @@ var (
AzureResourceGroup: "arg",
AzureSubscriptionID: "arg",
AzureMaxRetriesCount: 4,
BatchChangeSize: 200,
BatchChangeInterval: time.Second,
CloudflareProxied: true,
CloudflareCustomHostnames: true,
CloudflareCustomHostnamesMinTLSVersion: "1.3",
Expand Down Expand Up @@ -426,6 +430,7 @@ func TestParseFlags(t *testing.T) {
"--rfc2136-load-balancing-strategy=round-robin",
"--rfc2136-host=rfc2136-host1",
"--rfc2136-host=rfc2136-host2",
"--batch-change-size=200",
},
envVars: map[string]string{},
expected: func(cfg *Config) {
Expand Down Expand Up @@ -547,6 +552,7 @@ func TestParseFlags(t *testing.T) {
"EXTERNAL_DNS_RFC2136_BATCH_CHANGE_SIZE": "100",
"EXTERNAL_DNS_RFC2136_LOAD_BALANCING_STRATEGY": "round-robin",
"EXTERNAL_DNS_RFC2136_HOST": "rfc2136-host1\nrfc2136-host2",
"EXTERNAL_DNS_BATCH_CHANGE_SIZE": "200",
},
expected: func(cfg *Config) {
assert.Equal(t, overriddenConfig, cfg)
Expand Down
143 changes: 56 additions & 87 deletions provider/cloudflare/cloudflare.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,11 +20,13 @@ import (
"context"
"errors"
"fmt"
"io"
"net/http"
"os"
"sort"
"strconv"
"strings"
"time"

"github.com/cloudflare/cloudflare-go/v5"
"github.com/cloudflare/cloudflare-go/v5/addressing"
Expand Down Expand Up @@ -96,6 +98,7 @@ type cloudFlareDNS interface {
ListZones(ctx context.Context, params zones.ZoneListParams) autoPager[zones.Zone]
GetZone(ctx context.Context, zoneID string) (*zones.Zone, error)
ListDNSRecords(ctx context.Context, params dns.RecordListParams) autoPager[dns.RecordResponse]
BatchDNSRecords(ctx context.Context, params dns.RecordBatchParams) (*dns.RecordBatchResponse, error)
CreateDNSRecord(ctx context.Context, params dns.RecordNewParams) (*dns.RecordResponse, error)
DeleteDNSRecord(ctx context.Context, recordID string, params dns.RecordDeleteParams) error
UpdateDNSRecord(ctx context.Context, recordID string, params dns.RecordUpdateParams) (*dns.RecordResponse, error)
Expand Down Expand Up @@ -163,8 +166,10 @@ func listZonesV4Params() zones.ZoneListParams {
}

type DNSRecordsConfig struct {
PerPage int
Comment string
PerPage int
Comment string
BatchChangeSize int
BatchChangeInterval time.Duration
}

func (c *DNSRecordsConfig) trimAndValidateComment(dnsName, comment string, paidZone func(string) bool) string {
Expand Down Expand Up @@ -229,40 +234,6 @@ type cloudFlareChange struct {
CustomHostnamesPrev []string
}

// updateDNSRecordParam is a function that returns the appropriate Record Param based on the cloudFlareChange passed in
func getUpdateDNSRecordParam(zoneID string, cfc cloudFlareChange) dns.RecordUpdateParams {
return dns.RecordUpdateParams{
ZoneID: cloudflare.F(zoneID),
Body: dns.RecordUpdateParamsBody{
Name: cloudflare.F(cfc.ResourceRecord.Name),
TTL: cloudflare.F(cfc.ResourceRecord.TTL),
Proxied: cloudflare.F(cfc.ResourceRecord.Proxied),
Type: cloudflare.F(dns.RecordUpdateParamsBodyType(cfc.ResourceRecord.Type)),
Content: cloudflare.F(cfc.ResourceRecord.Content),
Priority: cloudflare.F(cfc.ResourceRecord.Priority),
Comment: cloudflare.F(cfc.ResourceRecord.Comment),
Tags: cloudflare.F(cfc.ResourceRecord.Tags),
},
}
}

// getCreateDNSRecordParam is a function that returns the appropriate Record Param based on the cloudFlareChange passed in
func getCreateDNSRecordParam(zoneID string, cfc *cloudFlareChange) dns.RecordNewParams {
return dns.RecordNewParams{
ZoneID: cloudflare.F(zoneID),
Body: dns.RecordNewParamsBody{
Name: cloudflare.F(cfc.ResourceRecord.Name),
TTL: cloudflare.F(cfc.ResourceRecord.TTL),
Proxied: cloudflare.F(cfc.ResourceRecord.Proxied),
Type: cloudflare.F(dns.RecordNewParamsBodyType(cfc.ResourceRecord.Type)),
Content: cloudflare.F(cfc.ResourceRecord.Content),
Priority: cloudflare.F(cfc.ResourceRecord.Priority),
Comment: cloudflare.F(cfc.ResourceRecord.Comment),
Tags: cloudflare.F(cfc.ResourceRecord.Tags),
},
}
}

func convertCloudflareError(err error) error {
// Handle CloudFlare v5 SDK errors according to the documentation:
// https://github.com/cloudflare/cloudflare-go?tab=readme-ov-file#errors
Expand All @@ -278,7 +249,14 @@ func convertCloudflareError(err error) error {
return err
}

// Also check for rate limit indicators in error message strings as a fallback.
// Transport-level errors that the SDK does not wrap as *cloudflare.Error.
// Both are transient and worth retrying at the external-dns level.
// ErrUnexpectedEOF – connection closed mid-response (during body read)
// EOF – connection closed before any response bytes arrived
if errors.Is(err, io.ErrUnexpectedEOF) || errors.Is(err, io.EOF) {
return provider.NewSoftError(err)
}

// The v5 SDK's retry logic and error wrapping can hide the structured error type,
// so we need string matching to catch rate limits in wrapped errors like:
// "exceeded available rate limit retries" from the SDK's auto-retry mechanism.
Expand Down Expand Up @@ -534,64 +512,55 @@ func (p *CloudFlareProvider) submitChanges(ctx context.Context, changes []*cloud
"action": change.Action.String(),
"zone": zoneID,
}

log.WithFields(logFields).Info("Changing record.")
}

if p.DryRun {
continue
}

records, err := p.getDNSRecordsMap(ctx, zoneID)
if err != nil {
return fmt.Errorf("could not fetch records from zone, %w", err)
}
chs, chErr := p.listCustomHostnamesWithPagination(ctx, zoneID)
if chErr != nil {
return fmt.Errorf("could not fetch custom hostnames from zone, %w", chErr)
}
switch change.Action {
case cloudFlareUpdate:
if !p.submitCustomHostnameChanges(ctx, zoneID, change, chs, logFields) {
failedChange = true
}
recordID := p.getRecordID(records, change.ResourceRecord)
if recordID == "" {
log.WithFields(logFields).Errorf("failed to find previous record: %v", change.ResourceRecord)
continue
}
recordParam := getUpdateDNSRecordParam(zoneID, *change)
_, err := p.Client.UpdateDNSRecord(ctx, recordID, recordParam)
if p.DryRun {
// In dry-run mode, skip all DNS record mutations but still process
// regional hostname changes (which have their own dry-run logging).
if p.RegionalServicesConfig.Enabled {
desiredRegionalHostnames, err := desiredRegionalHostnames(zoneChanges)
if err != nil {
failedChange = true
log.WithFields(logFields).Errorf("failed to update record: %v", err)
}
case cloudFlareDelete:
recordID := p.getRecordID(records, change.ResourceRecord)
if recordID == "" {
log.WithFields(logFields).Errorf("failed to find previous record: %v", change.ResourceRecord)
continue
}
err := p.Client.DeleteDNSRecord(ctx, recordID, dns.RecordDeleteParams{ZoneID: cloudflare.F(zoneID)})
if err != nil {
failedChange = true
log.WithFields(logFields).Errorf("failed to delete record: %v", err)
}
if !p.submitCustomHostnameChanges(ctx, zoneID, change, chs, logFields) {
failedChange = true
}
case cloudFlareCreate:
recordParam := getCreateDNSRecordParam(zoneID, change)
_, err := p.Client.CreateDNSRecord(ctx, recordParam)
if err != nil {
failedChange = true
log.WithFields(logFields).Errorf("failed to create record: %v", err)
return fmt.Errorf("failed to build desired regional hostnames: %w", err)
}
if !p.submitCustomHostnameChanges(ctx, zoneID, change, chs, logFields) {
failedChange = true
if len(desiredRegionalHostnames) > 0 {
regionalHostnames, err := p.listDataLocalisationRegionalHostnames(ctx, zoneID)
if err != nil {
return fmt.Errorf("could not fetch regional hostnames from zone, %w", err)
}
regionalHostnamesChanges := regionalHostnamesChanges(desiredRegionalHostnames, regionalHostnames)
if !p.submitRegionalHostnameChanges(ctx, zoneID, regionalHostnamesChanges) {
failedChange = true
}
}
}
if failedChange {
failedZones = append(failedZones, zoneID)
}
continue
}

// Fetch the zone's current DNS records and custom hostnames once, rather
// than once per change, to avoid O(n) API calls for n changes.
records, err := p.getDNSRecordsMap(ctx, zoneID)
if err != nil {
return fmt.Errorf("could not fetch records from zone, %w", err)
}
chs, chErr := p.listCustomHostnamesWithPagination(ctx, zoneID)
if chErr != nil {
return fmt.Errorf("could not fetch custom hostnames from zone, %w", chErr)
}

// Apply custom hostname side-effects (separate Cloudflare API), then
// classify DNS record changes into batch collections.
if p.processCustomHostnameChanges(ctx, zoneID, zoneChanges, chs) {
failedChange = true
}
bc := p.buildBatchCollections(zoneID, zoneChanges, records)

if p.submitDNSRecordChanges(ctx, zoneID, bc, records) {
failedChange = true
}
if p.RegionalServicesConfig.Enabled {
desiredRegionalHostnames, err := desiredRegionalHostnames(zoneChanges)
if err != nil {
Expand Down
Loading
Loading