mirror of
https://github.com/kubernetes-sigs/external-dns.git
synced 2026-04-02 06:32:05 +02:00
feat(cloudflare): support batch API for DNS record changes (#6208)
* feat(cloudflare): add --batch-change-size and --batch-change-interval flags Adds two new global CLI flags for controlling batch DNS change behaviour: - --batch-change-size (default 200): maximum number of DNS operations per batch - --batch-change-interval (default 1s): pause between consecutive batch chunks Wires the flags through Config into the Cloudflare provider's DNSRecordsConfig. * feat(cloudflare): implement batch DNS records API with automatic fallback Uses Cloudflare's Batch DNS Records API to submit all creates, updates, and deletes for a zone in a single transactional API call per chunk, significantly reducing the total number of requests made against the Cloudflare API. - Batch size and interval are controlled via --batch-change-size / --batch-change-interval - Record types unsupported by the batch PUT endpoint (e.g. SRV, CAA) are submitted individually via the standard API - If a batch chunk is rejected by Cloudflare, ExternalDNS automatically retries each record change in that chunk individually so no changes are silently lost - Adds cloudflare_batch.go with the core batching logic and full test coverage * feat(cloudflare): soft retry for 'unexpected EOF' (issue 3798) * feat(cloudflare): soft retry for 'unexpected EOF' (issue 3798) * feat(cloudflare): debug logs for intentional invididual-updates * feat(cloudflare): improved code coverage * feat(cloudflare): handle json.Encoder error in test helper
This commit is contained in:
parent
dc123262c6
commit
ca58d993af
@ -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)
|
||||
|
||||
@ -85,6 +85,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) |
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -112,6 +112,8 @@ type Config struct {
|
||||
AzureActiveDirectoryAuthorityHost string
|
||||
AzureZonesCacheDuration time.Duration
|
||||
AzureMaxRetriesCount int
|
||||
BatchChangeSize int
|
||||
BatchChangeInterval time.Duration
|
||||
CloudflareProxied bool
|
||||
CloudflareCustomHostnames bool
|
||||
CloudflareDNSRecordsPerPage int
|
||||
@ -256,6 +258,8 @@ var defaultConfig = &Config{
|
||||
AzureSubscriptionID: "",
|
||||
AzureZonesCacheDuration: 0 * time.Second,
|
||||
AzureMaxRetriesCount: 3,
|
||||
BatchChangeSize: 200,
|
||||
BatchChangeInterval: time.Second,
|
||||
CloudflareCustomHostnamesCertificateAuthority: "none",
|
||||
CloudflareCustomHostnames: false,
|
||||
CloudflareCustomHostnamesMinTLSVersion: "1.0",
|
||||
@ -587,6 +591,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")
|
||||
|
||||
@ -76,6 +76,8 @@ var (
|
||||
AzureResourceGroup: "",
|
||||
AzureSubscriptionID: "",
|
||||
AzureMaxRetriesCount: 3,
|
||||
BatchChangeSize: 200,
|
||||
BatchChangeInterval: time.Second,
|
||||
CloudflareProxied: false,
|
||||
CloudflareCustomHostnames: false,
|
||||
CloudflareCustomHostnamesMinTLSVersion: "1.0",
|
||||
@ -185,6 +187,8 @@ var (
|
||||
AzureResourceGroup: "arg",
|
||||
AzureSubscriptionID: "arg",
|
||||
AzureMaxRetriesCount: 4,
|
||||
BatchChangeSize: 200,
|
||||
BatchChangeInterval: time.Second,
|
||||
CloudflareProxied: true,
|
||||
CloudflareCustomHostnames: true,
|
||||
CloudflareCustomHostnamesMinTLSVersion: "1.3",
|
||||
@ -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) {
|
||||
@ -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)
|
||||
|
||||
@ -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"
|
||||
@ -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)
|
||||
@ -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 {
|
||||
@ -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
|
||||
@ -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.
|
||||
@ -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 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)
|
||||
}
|
||||
if !p.submitCustomHostnameChanges(ctx, zoneID, change, chs, logFields) {
|
||||
failedChange = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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 {
|
||||
return fmt.Errorf("failed to build desired regional hostnames: %w", err)
|
||||
}
|
||||
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 {
|
||||
|
||||
447
provider/cloudflare/cloudflare_batch.go
Normal file
447
provider/cloudflare/cloudflare_batch.go
Normal file
@ -0,0 +1,447 @@
|
||||
/*
|
||||
Copyright 2026 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 cloudflare
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"github.com/cloudflare/cloudflare-go/v5"
|
||||
"github.com/cloudflare/cloudflare-go/v5/dns"
|
||||
log "github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
const (
|
||||
// defaultBatchChangeSize is the default maximum number of DNS record
|
||||
// operations included in each Cloudflare batch request.
|
||||
defaultBatchChangeSize = 200
|
||||
)
|
||||
|
||||
// batchCollections groups the parallel slices that are assembled while
|
||||
// classifying per-zone changes. It is passed as a unit to
|
||||
// submitDNSRecordChanges and chunkBatchChanges, replacing the previous
|
||||
// eight-parameter signatures and making it clear which slices travel
|
||||
// together.
|
||||
type batchCollections struct {
|
||||
// Batch API parameters in server-execution order: deletes → puts → posts.
|
||||
batchDeletes []dns.RecordBatchParamsDelete
|
||||
batchPosts []dns.RecordBatchParamsPostUnion
|
||||
batchPuts []dns.BatchPutUnionParam
|
||||
|
||||
// Parallel change slices — one entry per batch param, in the same order,
|
||||
// so that a failed batch chunk can be replayed with per-record fallback.
|
||||
deleteChanges []*cloudFlareChange
|
||||
createChanges []*cloudFlareChange
|
||||
updateChanges []*cloudFlareChange
|
||||
|
||||
// fallbackUpdates holds changes for record types whose batch-put param
|
||||
// requires structured Data fields (e.g. SRV, CAA). These are submitted
|
||||
// via individual UpdateDNSRecord calls instead of the batch API.
|
||||
fallbackUpdates []*cloudFlareChange
|
||||
}
|
||||
|
||||
// batchChunk holds a DNS record batch request alongside the source changes
|
||||
// that produced it, enabling per-record fallback when a batch fails.
|
||||
type batchChunk struct {
|
||||
params dns.RecordBatchParams
|
||||
deleteChanges []*cloudFlareChange
|
||||
createChanges []*cloudFlareChange
|
||||
updateChanges []*cloudFlareChange
|
||||
}
|
||||
|
||||
// BatchDNSRecords submits a batch of DNS record changes to the Cloudflare API.
|
||||
func (z zoneService) BatchDNSRecords(ctx context.Context, params dns.RecordBatchParams) (*dns.RecordBatchResponse, error) {
|
||||
return z.service.DNS.Records.Batch(ctx, params)
|
||||
}
|
||||
|
||||
// getUpdateDNSRecordParam returns the RecordUpdateParams for an individual update.
|
||||
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 returns the RecordNewParams for an individual create.
|
||||
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),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// chunkBatchChanges splits DNS record batch operations into batchChunks,
|
||||
// each containing at most <limit> total operations. Operations are distributed
|
||||
// in server-execution order: deletes first, then puts, then posts.
|
||||
// The parallel change slices track which cloudFlareChange produced each batch
|
||||
// param so that individual fallback is possible when a chunk fails.
|
||||
func chunkBatchChanges(zoneID string, bc batchCollections, limit int) []batchChunk {
|
||||
deletes, deleteChanges := bc.batchDeletes, bc.deleteChanges
|
||||
posts, createChanges := bc.batchPosts, bc.createChanges
|
||||
puts, updateChanges := bc.batchPuts, bc.updateChanges
|
||||
|
||||
var chunks []batchChunk
|
||||
di, pi, ui := 0, 0, 0
|
||||
for di < len(deletes) || pi < len(posts) || ui < len(puts) {
|
||||
remaining := limit
|
||||
chunk := batchChunk{
|
||||
params: dns.RecordBatchParams{ZoneID: cloudflare.F(zoneID)},
|
||||
}
|
||||
|
||||
if di < len(deletes) && remaining > 0 {
|
||||
end := min(di+remaining, len(deletes))
|
||||
chunk.params.Deletes = cloudflare.F(deletes[di:end])
|
||||
chunk.deleteChanges = deleteChanges[di:end]
|
||||
remaining -= end - di
|
||||
di = end
|
||||
}
|
||||
|
||||
if ui < len(puts) && remaining > 0 {
|
||||
end := min(ui+remaining, len(puts))
|
||||
chunk.params.Puts = cloudflare.F(puts[ui:end])
|
||||
chunk.updateChanges = updateChanges[ui:end]
|
||||
remaining -= end - ui
|
||||
ui = end
|
||||
}
|
||||
|
||||
if pi < len(posts) && remaining > 0 {
|
||||
end := min(pi+remaining, len(posts))
|
||||
chunk.params.Posts = cloudflare.F(posts[pi:end])
|
||||
chunk.createChanges = createChanges[pi:end]
|
||||
pi = end
|
||||
}
|
||||
|
||||
chunks = append(chunks, chunk)
|
||||
}
|
||||
return chunks
|
||||
}
|
||||
|
||||
// tagsFromResponse converts a RecordResponse Tags field (any) to the typed tag slice.
|
||||
func tagsFromResponse(tags any) []dns.RecordTagsParam {
|
||||
if ts, ok := tags.([]string); ok {
|
||||
return ts
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// buildBatchPostParam constructs a RecordBatchParamsPost for creating a DNS record in a batch.
|
||||
func buildBatchPostParam(r dns.RecordResponse) dns.RecordBatchParamsPost {
|
||||
return dns.RecordBatchParamsPost{
|
||||
Name: cloudflare.F(r.Name),
|
||||
TTL: cloudflare.F(r.TTL),
|
||||
Type: cloudflare.F(dns.RecordBatchParamsPostsType(r.Type)),
|
||||
Content: cloudflare.F(r.Content),
|
||||
Proxied: cloudflare.F(r.Proxied),
|
||||
Priority: cloudflare.F(r.Priority),
|
||||
Comment: cloudflare.F(r.Comment),
|
||||
Tags: cloudflare.F[any](tagsFromResponse(r.Tags)),
|
||||
}
|
||||
}
|
||||
|
||||
// buildBatchPutParam constructs a BatchPutUnionParam for updating a DNS record in a batch.
|
||||
// Returns (nil, false) for record types that use structured Data fields (e.g. SRV, CAA),
|
||||
// which fall back to individual UpdateDNSRecord calls.
|
||||
func buildBatchPutParam(id string, r dns.RecordResponse) (dns.BatchPutUnionParam, bool) {
|
||||
tags := tagsFromResponse(r.Tags)
|
||||
comment := r.Comment
|
||||
switch r.Type {
|
||||
case dns.RecordResponseTypeA:
|
||||
return dns.BatchPutARecordParam{
|
||||
ID: cloudflare.F(id),
|
||||
ARecordParam: dns.ARecordParam{
|
||||
Name: cloudflare.F(r.Name),
|
||||
TTL: cloudflare.F(r.TTL),
|
||||
Type: cloudflare.F(dns.ARecordTypeA),
|
||||
Content: cloudflare.F(r.Content),
|
||||
Proxied: cloudflare.F(r.Proxied),
|
||||
Comment: cloudflare.F(comment),
|
||||
Tags: cloudflare.F(tags),
|
||||
},
|
||||
}, true
|
||||
case dns.RecordResponseTypeAAAA:
|
||||
return dns.BatchPutAAAARecordParam{
|
||||
ID: cloudflare.F(id),
|
||||
AAAARecordParam: dns.AAAARecordParam{
|
||||
Name: cloudflare.F(r.Name),
|
||||
TTL: cloudflare.F(r.TTL),
|
||||
Type: cloudflare.F(dns.AAAARecordTypeAAAA),
|
||||
Content: cloudflare.F(r.Content),
|
||||
Proxied: cloudflare.F(r.Proxied),
|
||||
Comment: cloudflare.F(comment),
|
||||
Tags: cloudflare.F(tags),
|
||||
},
|
||||
}, true
|
||||
case dns.RecordResponseTypeCNAME:
|
||||
return dns.BatchPutCNAMERecordParam{
|
||||
ID: cloudflare.F(id),
|
||||
CNAMERecordParam: dns.CNAMERecordParam{
|
||||
Name: cloudflare.F(r.Name),
|
||||
TTL: cloudflare.F(r.TTL),
|
||||
Type: cloudflare.F(dns.CNAMERecordTypeCNAME),
|
||||
Content: cloudflare.F(r.Content),
|
||||
Proxied: cloudflare.F(r.Proxied),
|
||||
Comment: cloudflare.F(comment),
|
||||
Tags: cloudflare.F(tags),
|
||||
},
|
||||
}, true
|
||||
case dns.RecordResponseTypeTXT:
|
||||
return dns.BatchPutTXTRecordParam{
|
||||
ID: cloudflare.F(id),
|
||||
TXTRecordParam: dns.TXTRecordParam{
|
||||
Name: cloudflare.F(r.Name),
|
||||
TTL: cloudflare.F(r.TTL),
|
||||
Type: cloudflare.F(dns.TXTRecordTypeTXT),
|
||||
Content: cloudflare.F(r.Content),
|
||||
Proxied: cloudflare.F(r.Proxied),
|
||||
Comment: cloudflare.F(comment),
|
||||
Tags: cloudflare.F(tags),
|
||||
},
|
||||
}, true
|
||||
case dns.RecordResponseTypeMX:
|
||||
return dns.BatchPutMXRecordParam{
|
||||
ID: cloudflare.F(id),
|
||||
MXRecordParam: dns.MXRecordParam{
|
||||
Name: cloudflare.F(r.Name),
|
||||
TTL: cloudflare.F(r.TTL),
|
||||
Type: cloudflare.F(dns.MXRecordTypeMX),
|
||||
Content: cloudflare.F(r.Content),
|
||||
Proxied: cloudflare.F(r.Proxied),
|
||||
Comment: cloudflare.F(comment),
|
||||
Tags: cloudflare.F(tags),
|
||||
Priority: cloudflare.F(r.Priority),
|
||||
},
|
||||
}, true
|
||||
case dns.RecordResponseTypeNS:
|
||||
return dns.BatchPutNSRecordParam{
|
||||
ID: cloudflare.F(id),
|
||||
NSRecordParam: dns.NSRecordParam{
|
||||
Name: cloudflare.F(r.Name),
|
||||
TTL: cloudflare.F(r.TTL),
|
||||
Type: cloudflare.F(dns.NSRecordTypeNS),
|
||||
Content: cloudflare.F(r.Content),
|
||||
Proxied: cloudflare.F(r.Proxied),
|
||||
Comment: cloudflare.F(comment),
|
||||
Tags: cloudflare.F(tags),
|
||||
},
|
||||
}, true
|
||||
default:
|
||||
// Record types that use structured Data fields (SRV, CAA, etc.) are not
|
||||
// supported in the generic batch put and fall back to individual updates.
|
||||
return nil, false
|
||||
}
|
||||
}
|
||||
|
||||
// buildBatchCollections classifies per-zone changes into batch collections.
|
||||
// Custom hostname side-effects are handled separately by
|
||||
// processCustomHostnameChanges before this is called.
|
||||
func (p *CloudFlareProvider) buildBatchCollections(
|
||||
zoneID string,
|
||||
changes []*cloudFlareChange,
|
||||
records DNSRecordsMap,
|
||||
) batchCollections {
|
||||
var bc batchCollections
|
||||
|
||||
for _, change := range changes {
|
||||
logFields := log.Fields{
|
||||
"record": change.ResourceRecord.Name,
|
||||
"type": change.ResourceRecord.Type,
|
||||
"ttl": change.ResourceRecord.TTL,
|
||||
"action": change.Action.String(),
|
||||
"zone": zoneID,
|
||||
}
|
||||
|
||||
switch change.Action {
|
||||
case cloudFlareCreate:
|
||||
bc.batchPosts = append(bc.batchPosts, buildBatchPostParam(change.ResourceRecord))
|
||||
bc.createChanges = append(bc.createChanges, change)
|
||||
|
||||
case cloudFlareDelete:
|
||||
recordID := p.getRecordID(records, change.ResourceRecord)
|
||||
if recordID == "" {
|
||||
log.WithFields(logFields).Errorf("failed to find previous record: %v", change.ResourceRecord)
|
||||
continue
|
||||
}
|
||||
bc.batchDeletes = append(bc.batchDeletes, dns.RecordBatchParamsDelete{ID: cloudflare.F(recordID)})
|
||||
bc.deleteChanges = append(bc.deleteChanges, change)
|
||||
|
||||
case cloudFlareUpdate:
|
||||
recordID := p.getRecordID(records, change.ResourceRecord)
|
||||
if recordID == "" {
|
||||
log.WithFields(logFields).Errorf("failed to find previous record: %v", change.ResourceRecord)
|
||||
continue
|
||||
}
|
||||
if putParam, ok := buildBatchPutParam(recordID, change.ResourceRecord); ok {
|
||||
bc.batchPuts = append(bc.batchPuts, putParam)
|
||||
bc.updateChanges = append(bc.updateChanges, change)
|
||||
} else {
|
||||
log.WithFields(logFields).Debugf("batch PUT not supported for type %s, using individual update", change.ResourceRecord.Type)
|
||||
bc.fallbackUpdates = append(bc.fallbackUpdates, change)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return bc
|
||||
}
|
||||
|
||||
// submitDNSRecordChanges submits the pre-built batch collections and any
|
||||
// fallback individual updates for a single zone. When a batch chunk fails,
|
||||
// the provider falls back to individual API calls for that chunk's changes
|
||||
// (since the batch is transactional — failure means full rollback).
|
||||
// Returns true if any operation fails.
|
||||
func (p *CloudFlareProvider) submitDNSRecordChanges(
|
||||
ctx context.Context,
|
||||
zoneID string,
|
||||
bc batchCollections,
|
||||
records DNSRecordsMap,
|
||||
) bool {
|
||||
failed := false
|
||||
if len(bc.batchDeletes) > 0 || len(bc.batchPosts) > 0 || len(bc.batchPuts) > 0 {
|
||||
limit := max(p.DNSRecordsConfig.BatchChangeSize, defaultBatchChangeSize)
|
||||
chunks := chunkBatchChanges(zoneID, bc, limit)
|
||||
for i, chunk := range chunks {
|
||||
log.Debugf("Submitting batch DNS records for zone %s (chunk %d/%d): %d deletes, %d creates, %d updates",
|
||||
zoneID, i+1, len(chunks),
|
||||
len(chunk.params.Deletes.Value),
|
||||
len(chunk.params.Posts.Value),
|
||||
len(chunk.params.Puts.Value),
|
||||
)
|
||||
if _, err := p.Client.BatchDNSRecords(ctx, chunk.params); err != nil {
|
||||
log.Warnf("Batch DNS operation failed for zone %s (chunk %d/%d): %v — falling back to individual operations",
|
||||
zoneID, i+1, len(chunks), convertCloudflareError(err))
|
||||
if p.fallbackIndividualChanges(ctx, zoneID, chunk, records) {
|
||||
failed = true
|
||||
}
|
||||
} else {
|
||||
log.Debugf("Successfully submitted batch DNS records for zone %s (chunk %d/%d)", zoneID, i+1, len(chunks))
|
||||
}
|
||||
if i < len(chunks)-1 && p.DNSRecordsConfig.BatchChangeInterval > 0 {
|
||||
time.Sleep(p.DNSRecordsConfig.BatchChangeInterval)
|
||||
}
|
||||
}
|
||||
}
|
||||
for _, change := range bc.fallbackUpdates {
|
||||
logFields := log.Fields{
|
||||
"record": change.ResourceRecord.Name,
|
||||
"type": change.ResourceRecord.Type,
|
||||
"ttl": change.ResourceRecord.TTL,
|
||||
"action": change.Action.String(),
|
||||
"zone": zoneID,
|
||||
}
|
||||
recordID := p.getRecordID(records, change.ResourceRecord)
|
||||
recordParam := getUpdateDNSRecordParam(zoneID, *change)
|
||||
if _, err := p.Client.UpdateDNSRecord(ctx, recordID, recordParam); err != nil {
|
||||
failed = true
|
||||
log.WithFields(logFields).Errorf("failed to update record: %v", err)
|
||||
} else {
|
||||
log.WithFields(logFields).Debugf("individual update succeeded")
|
||||
}
|
||||
}
|
||||
return failed
|
||||
}
|
||||
|
||||
// fallbackIndividualChanges replays a failed (rolled-back) batch chunk as
|
||||
// individual API calls. Because the batch API is transactional, a failure means
|
||||
// zero state was changed in that chunk, so these individual calls are the first
|
||||
// real mutations. Individual calls return Cloudflare's own per-record error
|
||||
// details.
|
||||
//
|
||||
// Execution order matches the batch contract: deletes → updates → creates.
|
||||
// Returns true if any operation failed.
|
||||
func (p *CloudFlareProvider) fallbackIndividualChanges(
|
||||
ctx context.Context,
|
||||
zoneID string,
|
||||
chunk batchChunk,
|
||||
records DNSRecordsMap,
|
||||
) bool {
|
||||
failed := false
|
||||
|
||||
// Process in batch execution order: deletes → updates → creates.
|
||||
groups := []struct {
|
||||
changes []*cloudFlareChange
|
||||
}{
|
||||
{chunk.deleteChanges},
|
||||
{chunk.updateChanges},
|
||||
{chunk.createChanges},
|
||||
}
|
||||
|
||||
for _, group := range groups {
|
||||
for _, change := range group.changes {
|
||||
logFields := log.Fields{
|
||||
"record": change.ResourceRecord.Name,
|
||||
"type": change.ResourceRecord.Type,
|
||||
"content": change.ResourceRecord.Content,
|
||||
"action": change.Action.String(),
|
||||
"zone": zoneID,
|
||||
}
|
||||
|
||||
var err error
|
||||
switch change.Action {
|
||||
case cloudFlareCreate:
|
||||
params := getCreateDNSRecordParam(zoneID, change)
|
||||
_, err = p.Client.CreateDNSRecord(ctx, params)
|
||||
|
||||
case cloudFlareDelete:
|
||||
recordID := p.getRecordID(records, change.ResourceRecord)
|
||||
if recordID == "" {
|
||||
// Record is already absent — the desired state is achieved.
|
||||
log.WithFields(logFields).Info("fallback: record already gone, treating delete as success")
|
||||
continue
|
||||
}
|
||||
err = p.Client.DeleteDNSRecord(ctx, recordID, dns.RecordDeleteParams{
|
||||
ZoneID: cloudflare.F(zoneID),
|
||||
})
|
||||
|
||||
case cloudFlareUpdate:
|
||||
recordID := p.getRecordID(records, change.ResourceRecord)
|
||||
if recordID == "" {
|
||||
// Record is gone; let the next sync cycle issue a fresh CREATE.
|
||||
log.WithFields(logFields).Info("fallback: record unexpectedly not found for update, will re-evaluate on next sync")
|
||||
continue
|
||||
}
|
||||
params := getUpdateDNSRecordParam(zoneID, *change)
|
||||
_, err = p.Client.UpdateDNSRecord(ctx, recordID, params)
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
failed = true
|
||||
log.WithFields(logFields).Errorf("fallback: individual %s failed: %v", change.Action, convertCloudflareError(err))
|
||||
} else {
|
||||
log.WithFields(logFields).Debugf("fallback: individual %s succeeded", change.Action)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return failed
|
||||
}
|
||||
709
provider/cloudflare/cloudflare_batch_test.go
Normal file
709
provider/cloudflare/cloudflare_batch_test.go
Normal file
@ -0,0 +1,709 @@
|
||||
/*
|
||||
Copyright 2026 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 cloudflare
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"maps"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/cloudflare/cloudflare-go/v5"
|
||||
"github.com/cloudflare/cloudflare-go/v5/dns"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"sigs.k8s.io/external-dns/endpoint"
|
||||
"sigs.k8s.io/external-dns/plan"
|
||||
)
|
||||
|
||||
func (m *mockCloudFlareClient) BatchDNSRecords(_ context.Context, params dns.RecordBatchParams) (*dns.RecordBatchResponse, error) {
|
||||
m.BatchDNSRecordsCalls++
|
||||
zoneID := params.ZoneID.Value
|
||||
|
||||
// Snapshot zone state for transactional rollback on error.
|
||||
// The real Cloudflare batch API is fully transactional — if any
|
||||
// operation fails, the entire batch is rolled back.
|
||||
var snapshot map[string]dns.RecordResponse
|
||||
if zone, ok := m.Records[zoneID]; ok {
|
||||
snapshot = make(map[string]dns.RecordResponse, len(zone))
|
||||
maps.Copy(snapshot, zone)
|
||||
}
|
||||
actionsStart := len(m.Actions)
|
||||
|
||||
var firstErr error
|
||||
|
||||
// Process Deletes first to mirror the real API's ordering.
|
||||
for _, del := range params.Deletes.Value {
|
||||
recordID := del.ID.Value
|
||||
m.Actions = append(m.Actions, MockAction{
|
||||
Name: "Delete",
|
||||
ZoneId: zoneID,
|
||||
RecordId: recordID,
|
||||
})
|
||||
if zone, ok := m.Records[zoneID]; ok {
|
||||
if rec, exists := zone[recordID]; exists {
|
||||
name := rec.Name
|
||||
delete(zone, recordID)
|
||||
if strings.HasPrefix(name, "newerror-delete-") && firstErr == nil {
|
||||
firstErr = errors.New("failed to delete erroring DNS record")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Process Puts (updates) before Posts (creates) to mirror the real API's
|
||||
// server-side execution order: Deletes → Patches → Puts → Posts.
|
||||
for _, putUnion := range params.Puts.Value {
|
||||
id, record := extractBatchPutData(putUnion)
|
||||
m.Actions = append(m.Actions, MockAction{
|
||||
Name: "Update",
|
||||
ZoneId: zoneID,
|
||||
RecordId: id,
|
||||
RecordData: record,
|
||||
})
|
||||
if zone, ok := m.Records[zoneID]; ok {
|
||||
if _, exists := zone[id]; exists {
|
||||
if strings.HasPrefix(record.Name, "newerror-update-") {
|
||||
if firstErr == nil {
|
||||
firstErr = errors.New("failed to update erroring DNS record")
|
||||
}
|
||||
} else {
|
||||
zone[id] = record
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Process Posts (creates).
|
||||
for _, postUnion := range params.Posts.Value {
|
||||
post, ok := postUnion.(dns.RecordBatchParamsPost)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
typeStr := string(post.Type.Value)
|
||||
record := dns.RecordResponse{
|
||||
ID: generateDNSRecordID(typeStr, post.Name.Value, post.Content.Value),
|
||||
Name: post.Name.Value,
|
||||
TTL: dns.TTL(post.TTL.Value),
|
||||
Proxied: post.Proxied.Value,
|
||||
Type: dns.RecordResponseType(typeStr),
|
||||
Content: post.Content.Value,
|
||||
Priority: post.Priority.Value,
|
||||
}
|
||||
m.Actions = append(m.Actions, MockAction{
|
||||
Name: "Create",
|
||||
ZoneId: zoneID,
|
||||
RecordId: record.ID,
|
||||
RecordData: record,
|
||||
})
|
||||
if zone, ok := m.Records[zoneID]; ok {
|
||||
zone[record.ID] = record
|
||||
}
|
||||
if record.Name == "newerror.bar.com" && firstErr == nil {
|
||||
firstErr = fmt.Errorf("failed to create record")
|
||||
}
|
||||
}
|
||||
|
||||
// Transactional: on error, rollback all state and action changes.
|
||||
if firstErr != nil {
|
||||
if snapshot != nil {
|
||||
m.Records[zoneID] = snapshot
|
||||
}
|
||||
m.Actions = m.Actions[:actionsStart]
|
||||
return nil, firstErr
|
||||
}
|
||||
|
||||
return &dns.RecordBatchResponse{}, nil
|
||||
}
|
||||
|
||||
// extractBatchPutData unpacks a BatchPutUnionParam into a record ID and a RecordResponse
|
||||
// suitable for recording in the mock's Actions list.
|
||||
func extractBatchPutData(put dns.BatchPutUnionParam) (string, dns.RecordResponse) {
|
||||
switch p := put.(type) {
|
||||
case dns.BatchPutARecordParam:
|
||||
return p.ID.Value, dns.RecordResponse{
|
||||
ID: p.ID.Value,
|
||||
Name: p.Name.Value,
|
||||
TTL: p.TTL.Value,
|
||||
Proxied: p.Proxied.Value,
|
||||
Type: dns.RecordResponseTypeA,
|
||||
Content: p.Content.Value,
|
||||
}
|
||||
case dns.BatchPutAAAARecordParam:
|
||||
return p.ID.Value, dns.RecordResponse{
|
||||
ID: p.ID.Value,
|
||||
Name: p.Name.Value,
|
||||
TTL: p.TTL.Value,
|
||||
Proxied: p.Proxied.Value,
|
||||
Type: dns.RecordResponseTypeAAAA,
|
||||
Content: p.Content.Value,
|
||||
}
|
||||
case dns.BatchPutCNAMERecordParam:
|
||||
return p.ID.Value, dns.RecordResponse{
|
||||
ID: p.ID.Value,
|
||||
Name: p.Name.Value,
|
||||
TTL: p.TTL.Value,
|
||||
Proxied: p.Proxied.Value,
|
||||
Type: dns.RecordResponseTypeCNAME,
|
||||
Content: p.Content.Value,
|
||||
}
|
||||
case dns.BatchPutTXTRecordParam:
|
||||
return p.ID.Value, dns.RecordResponse{
|
||||
ID: p.ID.Value,
|
||||
Name: p.Name.Value,
|
||||
TTL: p.TTL.Value,
|
||||
Proxied: p.Proxied.Value,
|
||||
Type: dns.RecordResponseTypeTXT,
|
||||
Content: p.Content.Value,
|
||||
}
|
||||
case dns.BatchPutMXRecordParam:
|
||||
return p.ID.Value, dns.RecordResponse{
|
||||
ID: p.ID.Value,
|
||||
Name: p.Name.Value,
|
||||
TTL: p.TTL.Value,
|
||||
Proxied: p.Proxied.Value,
|
||||
Type: dns.RecordResponseTypeMX,
|
||||
Content: p.Content.Value,
|
||||
Priority: p.Priority.Value,
|
||||
}
|
||||
case dns.BatchPutNSRecordParam:
|
||||
return p.ID.Value, dns.RecordResponse{
|
||||
ID: p.ID.Value,
|
||||
Name: p.Name.Value,
|
||||
TTL: p.TTL.Value,
|
||||
Proxied: p.Proxied.Value,
|
||||
Type: dns.RecordResponseTypeNS,
|
||||
Content: p.Content.Value,
|
||||
}
|
||||
default:
|
||||
panic(fmt.Sprintf("extractBatchPutData: unexpected BatchPutUnionParam type %T", put))
|
||||
}
|
||||
}
|
||||
|
||||
// generateDNSRecordID builds the deterministic record ID used by the mock client.
|
||||
func generateDNSRecordID(rrtype string, name string, content string) string {
|
||||
return fmt.Sprintf("%s-%s-%s", name, rrtype, content)
|
||||
}
|
||||
|
||||
func TestBatchFallbackIndividual(t *testing.T) {
|
||||
t.Run("batch failure falls back to individual operations", func(t *testing.T) {
|
||||
// Create a provider with pre-existing records.
|
||||
client := NewMockCloudFlareClientWithRecords(map[string][]dns.RecordResponse{
|
||||
"001": {
|
||||
{ID: "existing-1", Name: "ok.bar.com", Type: "A", Content: "1.2.3.4", TTL: 120},
|
||||
},
|
||||
})
|
||||
p := &CloudFlareProvider{
|
||||
Client: client,
|
||||
}
|
||||
|
||||
// Apply changes that include a good create and a bad create.
|
||||
// "newerror.bar.com" triggers a batch failure in the mock BatchDNSRecords,
|
||||
// then an individual fallback failure in CreateDNSRecord.
|
||||
changes := &plan.Changes{
|
||||
Create: []*endpoint.Endpoint{
|
||||
{DNSName: "good.bar.com", Targets: endpoint.Targets{"5.6.7.8"}, RecordType: "A"},
|
||||
{DNSName: "newerror.bar.com", Targets: endpoint.Targets{"9.10.11.12"}, RecordType: "A"},
|
||||
},
|
||||
}
|
||||
|
||||
err := p.ApplyChanges(t.Context(), changes)
|
||||
require.Error(t, err, "should return error when individual fallback has failures")
|
||||
assert.Equal(t, 1, client.BatchDNSRecordsCalls, "batch path should be attempted before fallback")
|
||||
|
||||
// The batch should have failed (because of newerror.bar.com), then
|
||||
// fallback should have applied "good.bar.com" individually (success)
|
||||
// and "newerror.bar.com" individually (failure).
|
||||
|
||||
// Verify the good record was created via individual fallback.
|
||||
zone001 := client.Records["001"]
|
||||
goodID := generateDNSRecordID("A", "good.bar.com", "5.6.7.8")
|
||||
assert.Contains(t, zone001, goodID, "good record should exist after individual fallback")
|
||||
})
|
||||
|
||||
t.Run("failed individual delete is reported", func(t *testing.T) {
|
||||
// When a batch containing two deletes fails, the fallback replays them
|
||||
// individually. The one that ultimately fails should be reported;
|
||||
// the one that succeeds should not block the overall zone from converging.
|
||||
client := NewMockCloudFlareClientWithRecords(map[string][]dns.RecordResponse{
|
||||
"001": {
|
||||
{ID: "del-ok", Name: "deleteme.bar.com", Type: "A", Content: "1.2.3.4", TTL: 120},
|
||||
{ID: "del-err", Name: "newerror-delete-1.bar.com", Type: "A", Content: "5.6.7.8", TTL: 120},
|
||||
},
|
||||
})
|
||||
p := &CloudFlareProvider{
|
||||
Client: client,
|
||||
}
|
||||
|
||||
changes := &plan.Changes{
|
||||
Delete: []*endpoint.Endpoint{
|
||||
{DNSName: "deleteme.bar.com", Targets: endpoint.Targets{"1.2.3.4"}, RecordType: "A"},
|
||||
{DNSName: "newerror-delete-1.bar.com", Targets: endpoint.Targets{"5.6.7.8"}, RecordType: "A"},
|
||||
},
|
||||
}
|
||||
err := p.ApplyChanges(t.Context(), changes)
|
||||
require.Error(t, err, "should return error for the failing delete")
|
||||
|
||||
// The good delete should have succeeded via individual fallback.
|
||||
assert.NotContains(t, client.Records["001"], "del-ok", "successfully deleted record should be gone")
|
||||
})
|
||||
|
||||
t.Run("fallback update failure is reported", func(t *testing.T) {
|
||||
client := NewMockCloudFlareClientWithRecords(map[string][]dns.RecordResponse{
|
||||
"001": {
|
||||
{ID: "upd-err", Name: "newerror-update-1.bar.com", Type: "A", Content: "1.2.3.4", TTL: 120},
|
||||
},
|
||||
})
|
||||
p := &CloudFlareProvider{
|
||||
Client: client,
|
||||
}
|
||||
|
||||
changes := &plan.Changes{
|
||||
UpdateNew: []*endpoint.Endpoint{
|
||||
{DNSName: "newerror-update-1.bar.com", Targets: endpoint.Targets{"1.2.3.4"}, RecordType: "A", RecordTTL: 300},
|
||||
},
|
||||
UpdateOld: []*endpoint.Endpoint{
|
||||
{DNSName: "newerror-update-1.bar.com", Targets: endpoint.Targets{"1.2.3.4"}, RecordType: "A", RecordTTL: 120},
|
||||
},
|
||||
}
|
||||
err := p.ApplyChanges(t.Context(), changes)
|
||||
require.Error(t, err, "should return error for the failing update")
|
||||
})
|
||||
}
|
||||
|
||||
func TestChunkBatchChanges(t *testing.T) {
|
||||
// Build sample changes and batch params.
|
||||
mkDelete := func(id string) dns.RecordBatchParamsDelete {
|
||||
return dns.RecordBatchParamsDelete{ID: cloudflare.F(id)}
|
||||
}
|
||||
mkPost := func(name, content string) dns.RecordBatchParamsPostUnion {
|
||||
return dns.RecordBatchParamsPost{
|
||||
Name: cloudflare.F(name),
|
||||
Type: cloudflare.F(dns.RecordBatchParamsPostsTypeA),
|
||||
Content: cloudflare.F(content),
|
||||
}
|
||||
}
|
||||
mkPut := func(id, name, content string) dns.BatchPutUnionParam {
|
||||
return dns.BatchPutARecordParam{
|
||||
ID: cloudflare.F(id),
|
||||
ARecordParam: dns.ARecordParam{
|
||||
Name: cloudflare.F(name),
|
||||
Type: cloudflare.F(dns.ARecordTypeA),
|
||||
Content: cloudflare.F(content),
|
||||
},
|
||||
}
|
||||
}
|
||||
mkChange := func(action changeAction, name, content string) *cloudFlareChange {
|
||||
return &cloudFlareChange{
|
||||
Action: action,
|
||||
ResourceRecord: dns.RecordResponse{Name: name, Type: "A", Content: content},
|
||||
}
|
||||
}
|
||||
|
||||
deletes := []dns.RecordBatchParamsDelete{mkDelete("d1"), mkDelete("d2")}
|
||||
deleteChanges := []*cloudFlareChange{
|
||||
mkChange(cloudFlareDelete, "del1.bar.com", "1.1.1.1"),
|
||||
mkChange(cloudFlareDelete, "del2.bar.com", "2.2.2.2"),
|
||||
}
|
||||
posts := []dns.RecordBatchParamsPostUnion{mkPost("create1.bar.com", "3.3.3.3")}
|
||||
createChanges := []*cloudFlareChange{
|
||||
mkChange(cloudFlareCreate, "create1.bar.com", "3.3.3.3"),
|
||||
}
|
||||
puts := []dns.BatchPutUnionParam{mkPut("u1", "update1.bar.com", "4.4.4.4")}
|
||||
updateChanges := []*cloudFlareChange{
|
||||
mkChange(cloudFlareUpdate, "update1.bar.com", "4.4.4.4"),
|
||||
}
|
||||
|
||||
t.Run("single chunk when under limit", func(t *testing.T) {
|
||||
bc := batchCollections{
|
||||
batchDeletes: deletes,
|
||||
deleteChanges: deleteChanges,
|
||||
batchPosts: posts,
|
||||
createChanges: createChanges,
|
||||
batchPuts: puts,
|
||||
updateChanges: updateChanges,
|
||||
}
|
||||
chunks := chunkBatchChanges("zone1", bc, 10)
|
||||
require.Len(t, chunks, 1)
|
||||
assert.Len(t, chunks[0].deleteChanges, 2)
|
||||
assert.Len(t, chunks[0].createChanges, 1)
|
||||
assert.Len(t, chunks[0].updateChanges, 1)
|
||||
})
|
||||
|
||||
t.Run("splits into multiple chunks at limit", func(t *testing.T) {
|
||||
bc := batchCollections{
|
||||
batchDeletes: deletes,
|
||||
deleteChanges: deleteChanges,
|
||||
batchPosts: posts,
|
||||
createChanges: createChanges,
|
||||
batchPuts: puts,
|
||||
updateChanges: updateChanges,
|
||||
}
|
||||
chunks := chunkBatchChanges("zone1", bc, 2)
|
||||
require.Len(t, chunks, 2)
|
||||
// First chunk: 2 deletes (fills limit)
|
||||
assert.Len(t, chunks[0].deleteChanges, 2)
|
||||
assert.Empty(t, chunks[0].updateChanges)
|
||||
assert.Empty(t, chunks[0].createChanges)
|
||||
// Second chunk: 1 put then 1 post
|
||||
assert.Empty(t, chunks[1].deleteChanges)
|
||||
assert.Len(t, chunks[1].updateChanges, 1)
|
||||
assert.Len(t, chunks[1].createChanges, 1)
|
||||
})
|
||||
|
||||
t.Run("preserves operation order across chunk boundaries", func(t *testing.T) {
|
||||
bc := batchCollections{
|
||||
batchDeletes: []dns.RecordBatchParamsDelete{mkDelete("d1")},
|
||||
deleteChanges: []*cloudFlareChange{
|
||||
mkChange(cloudFlareDelete, "del1.bar.com", "1.1.1.1"),
|
||||
},
|
||||
batchPuts: []dns.BatchPutUnionParam{
|
||||
mkPut("u1", "update1.bar.com", "2.2.2.2"),
|
||||
mkPut("u2", "update2.bar.com", "3.3.3.3"),
|
||||
},
|
||||
updateChanges: []*cloudFlareChange{
|
||||
mkChange(cloudFlareUpdate, "update1.bar.com", "2.2.2.2"),
|
||||
mkChange(cloudFlareUpdate, "update2.bar.com", "3.3.3.3"),
|
||||
},
|
||||
batchPosts: []dns.RecordBatchParamsPostUnion{
|
||||
mkPost("create1.bar.com", "4.4.4.4"),
|
||||
mkPost("create2.bar.com", "5.5.5.5"),
|
||||
},
|
||||
createChanges: []*cloudFlareChange{
|
||||
mkChange(cloudFlareCreate, "create1.bar.com", "4.4.4.4"),
|
||||
mkChange(cloudFlareCreate, "create2.bar.com", "5.5.5.5"),
|
||||
},
|
||||
}
|
||||
|
||||
chunks := chunkBatchChanges("zone1", bc, 2)
|
||||
require.Len(t, chunks, 3)
|
||||
|
||||
assert.Len(t, chunks[0].deleteChanges, 1)
|
||||
assert.Len(t, chunks[0].updateChanges, 1)
|
||||
assert.Empty(t, chunks[0].createChanges)
|
||||
|
||||
assert.Empty(t, chunks[1].deleteChanges)
|
||||
assert.Len(t, chunks[1].updateChanges, 1)
|
||||
assert.Len(t, chunks[1].createChanges, 1)
|
||||
|
||||
assert.Empty(t, chunks[2].deleteChanges)
|
||||
assert.Empty(t, chunks[2].updateChanges)
|
||||
assert.Len(t, chunks[2].createChanges, 1)
|
||||
})
|
||||
}
|
||||
|
||||
func TestTagsFromResponse(t *testing.T) {
|
||||
t.Run("nil input returns nil", func(t *testing.T) {
|
||||
assert.Nil(t, tagsFromResponse(nil))
|
||||
})
|
||||
t.Run("non-string-slice returns nil", func(t *testing.T) {
|
||||
assert.Nil(t, tagsFromResponse(42))
|
||||
})
|
||||
t.Run("string slice is returned unchanged", func(t *testing.T) {
|
||||
tags := []string{"tag1", "tag2"}
|
||||
assert.Equal(t, tags, tagsFromResponse(tags))
|
||||
})
|
||||
}
|
||||
|
||||
func TestBuildBatchPutParam(t *testing.T) {
|
||||
base := dns.RecordResponse{
|
||||
Name: "example.bar.com",
|
||||
TTL: 120,
|
||||
Proxied: false,
|
||||
Comment: "test-comment",
|
||||
}
|
||||
|
||||
t.Run("AAAA record", func(t *testing.T) {
|
||||
r := base
|
||||
r.Type = dns.RecordResponseTypeAAAA
|
||||
r.Content = "2001:db8::1"
|
||||
param, ok := buildBatchPutParam("id-aaaa", r)
|
||||
require.True(t, ok)
|
||||
p, cast := param.(dns.BatchPutAAAARecordParam)
|
||||
require.True(t, cast)
|
||||
assert.Equal(t, "id-aaaa", p.ID.Value)
|
||||
assert.Equal(t, "2001:db8::1", p.Content.Value)
|
||||
assert.Equal(t, dns.AAAARecordTypeAAAA, p.Type.Value)
|
||||
})
|
||||
|
||||
t.Run("CNAME record", func(t *testing.T) {
|
||||
r := base
|
||||
r.Type = dns.RecordResponseTypeCNAME
|
||||
r.Content = "target.bar.com"
|
||||
param, ok := buildBatchPutParam("id-cname", r)
|
||||
require.True(t, ok)
|
||||
p, cast := param.(dns.BatchPutCNAMERecordParam)
|
||||
require.True(t, cast)
|
||||
assert.Equal(t, "id-cname", p.ID.Value)
|
||||
assert.Equal(t, "target.bar.com", p.Content.Value)
|
||||
assert.Equal(t, dns.CNAMERecordTypeCNAME, p.Type.Value)
|
||||
})
|
||||
|
||||
t.Run("TXT record", func(t *testing.T) {
|
||||
r := base
|
||||
r.Type = dns.RecordResponseTypeTXT
|
||||
r.Content = "v=spf1 include:example.com ~all"
|
||||
param, ok := buildBatchPutParam("id-txt", r)
|
||||
require.True(t, ok)
|
||||
p, cast := param.(dns.BatchPutTXTRecordParam)
|
||||
require.True(t, cast)
|
||||
assert.Equal(t, "id-txt", p.ID.Value)
|
||||
assert.Equal(t, dns.TXTRecordTypeTXT, p.Type.Value)
|
||||
})
|
||||
|
||||
t.Run("MX record with priority", func(t *testing.T) {
|
||||
r := base
|
||||
r.Type = dns.RecordResponseTypeMX
|
||||
r.Content = "mail.example.com"
|
||||
r.Priority = 10
|
||||
param, ok := buildBatchPutParam("id-mx", r)
|
||||
require.True(t, ok)
|
||||
p, cast := param.(dns.BatchPutMXRecordParam)
|
||||
require.True(t, cast)
|
||||
assert.Equal(t, "id-mx", p.ID.Value)
|
||||
assert.InDelta(t, float64(10), float64(p.Priority.Value), 0)
|
||||
assert.Equal(t, dns.MXRecordTypeMX, p.Type.Value)
|
||||
})
|
||||
|
||||
t.Run("NS record", func(t *testing.T) {
|
||||
r := base
|
||||
r.Type = dns.RecordResponseTypeNS
|
||||
r.Content = "ns1.example.com"
|
||||
param, ok := buildBatchPutParam("id-ns", r)
|
||||
require.True(t, ok)
|
||||
p, cast := param.(dns.BatchPutNSRecordParam)
|
||||
require.True(t, cast)
|
||||
assert.Equal(t, "id-ns", p.ID.Value)
|
||||
assert.Equal(t, dns.NSRecordTypeNS, p.Type.Value)
|
||||
})
|
||||
|
||||
t.Run("SRV record falls back (returns nil, false)", func(t *testing.T) {
|
||||
r := base
|
||||
r.Type = dns.RecordResponseTypeSRV
|
||||
r.Content = "10 20 443 target.bar.com"
|
||||
param, ok := buildBatchPutParam("id-srv", r)
|
||||
assert.False(t, ok)
|
||||
assert.Nil(t, param)
|
||||
})
|
||||
|
||||
t.Run("CAA record falls back (returns nil, false)", func(t *testing.T) {
|
||||
r := base
|
||||
r.Type = dns.RecordResponseTypeCAA
|
||||
r.Content = "0 issue letsencrypt.org"
|
||||
param, ok := buildBatchPutParam("id-caa", r)
|
||||
assert.False(t, ok)
|
||||
assert.Nil(t, param)
|
||||
})
|
||||
}
|
||||
|
||||
func TestBuildBatchCollections_EdgeCases(t *testing.T) {
|
||||
p := &CloudFlareProvider{}
|
||||
|
||||
t.Run("update with missing record ID is skipped", func(t *testing.T) {
|
||||
changes := []*cloudFlareChange{
|
||||
{
|
||||
Action: cloudFlareUpdate,
|
||||
ResourceRecord: dns.RecordResponse{
|
||||
Name: "missing.bar.com",
|
||||
Type: dns.RecordResponseTypeA,
|
||||
Content: "1.2.3.4",
|
||||
},
|
||||
},
|
||||
}
|
||||
// Empty records map — getRecordID will return ""
|
||||
bc := p.buildBatchCollections("zone1", changes, make(DNSRecordsMap))
|
||||
assert.Empty(t, bc.batchPuts, "missing record should not be added to batch puts")
|
||||
assert.Empty(t, bc.updateChanges)
|
||||
assert.Empty(t, bc.fallbackUpdates)
|
||||
})
|
||||
|
||||
t.Run("SRV update goes to fallbackUpdates", func(t *testing.T) {
|
||||
srvRecord := dns.RecordResponse{
|
||||
ID: "srv-1",
|
||||
Name: "srv.bar.com",
|
||||
Type: dns.RecordResponseTypeSRV,
|
||||
Content: "10 20 443 target.bar.com",
|
||||
}
|
||||
records := DNSRecordsMap{
|
||||
newDNSRecordIndex(srvRecord): srvRecord,
|
||||
}
|
||||
changes := []*cloudFlareChange{
|
||||
{
|
||||
Action: cloudFlareUpdate,
|
||||
ResourceRecord: srvRecord,
|
||||
},
|
||||
}
|
||||
bc := p.buildBatchCollections("zone1", changes, records)
|
||||
assert.Empty(t, bc.batchPuts, "SRV should not be in batch puts")
|
||||
assert.Empty(t, bc.updateChanges)
|
||||
require.Len(t, bc.fallbackUpdates, 1)
|
||||
assert.Equal(t, "srv.bar.com", bc.fallbackUpdates[0].ResourceRecord.Name)
|
||||
})
|
||||
|
||||
t.Run("delete with missing record ID is skipped", func(t *testing.T) {
|
||||
changes := []*cloudFlareChange{
|
||||
{
|
||||
Action: cloudFlareDelete,
|
||||
ResourceRecord: dns.RecordResponse{
|
||||
Name: "gone.bar.com",
|
||||
Type: dns.RecordResponseTypeA,
|
||||
Content: "1.2.3.4",
|
||||
},
|
||||
},
|
||||
}
|
||||
bc := p.buildBatchCollections("zone1", changes, make(DNSRecordsMap))
|
||||
assert.Empty(t, bc.batchDeletes, "missing record should not be added to batch deletes")
|
||||
assert.Empty(t, bc.deleteChanges)
|
||||
})
|
||||
}
|
||||
|
||||
func TestSubmitDNSRecordChanges_BatchInterval(t *testing.T) {
|
||||
// Build 201 creates so they span 2 chunks (defaultBatchChangeSize=200),
|
||||
// triggering the time.Sleep(BatchChangeInterval) code path between chunks.
|
||||
client := NewMockCloudFlareClientWithRecords(map[string][]dns.RecordResponse{
|
||||
"001": {},
|
||||
})
|
||||
p := &CloudFlareProvider{
|
||||
Client: client,
|
||||
DNSRecordsConfig: DNSRecordsConfig{
|
||||
BatchChangeInterval: 1, // 1 nanosecond — non-zero triggers sleep
|
||||
},
|
||||
}
|
||||
|
||||
const nRecords = defaultBatchChangeSize + 1
|
||||
var posts []dns.RecordBatchParamsPostUnion
|
||||
var createChanges []*cloudFlareChange
|
||||
for i := range nRecords {
|
||||
name := fmt.Sprintf("record%d.bar.com", i)
|
||||
posts = append(posts, dns.RecordBatchParamsPost{
|
||||
Name: cloudflare.F(name),
|
||||
Type: cloudflare.F(dns.RecordBatchParamsPostsTypeA),
|
||||
Content: cloudflare.F("1.2.3.4"),
|
||||
})
|
||||
createChanges = append(createChanges, &cloudFlareChange{
|
||||
Action: cloudFlareCreate,
|
||||
ResourceRecord: dns.RecordResponse{Name: name, Type: "A", Content: "1.2.3.4"},
|
||||
})
|
||||
}
|
||||
|
||||
bc := batchCollections{
|
||||
batchPosts: posts,
|
||||
createChanges: createChanges,
|
||||
}
|
||||
|
||||
failed := p.submitDNSRecordChanges(t.Context(), "001", bc, make(DNSRecordsMap))
|
||||
assert.False(t, failed, "should not fail")
|
||||
assert.Equal(t, 2, client.BatchDNSRecordsCalls, "two chunks should require two batch API calls")
|
||||
}
|
||||
|
||||
func TestSubmitDNSRecordChanges_FallbackUpdates(t *testing.T) {
|
||||
t.Run("successful SRV fallback update", func(t *testing.T) {
|
||||
srvRecord := dns.RecordResponse{
|
||||
ID: "srv-1",
|
||||
Name: "srv.bar.com",
|
||||
Type: dns.RecordResponseTypeSRV,
|
||||
Content: "10 20 443 target.bar.com",
|
||||
}
|
||||
client := NewMockCloudFlareClientWithRecords(map[string][]dns.RecordResponse{
|
||||
"001": {srvRecord},
|
||||
})
|
||||
p := &CloudFlareProvider{Client: client}
|
||||
|
||||
records := DNSRecordsMap{
|
||||
newDNSRecordIndex(srvRecord): srvRecord,
|
||||
}
|
||||
bc := batchCollections{
|
||||
fallbackUpdates: []*cloudFlareChange{
|
||||
{Action: cloudFlareUpdate, ResourceRecord: srvRecord},
|
||||
},
|
||||
}
|
||||
|
||||
failed := p.submitDNSRecordChanges(t.Context(), "001", bc, records)
|
||||
assert.False(t, failed, "successful SRV fallback update should not report failure")
|
||||
assert.Equal(t, 0, client.BatchDNSRecordsCalls, "batch API not called for fallback-only changes")
|
||||
})
|
||||
|
||||
t.Run("failed SRV fallback update is reported", func(t *testing.T) {
|
||||
srvRecord := dns.RecordResponse{
|
||||
ID: "newerror-upd-srv",
|
||||
Name: "newerror-update-srv.bar.com",
|
||||
Type: dns.RecordResponseTypeSRV,
|
||||
Content: "10 20 443 target.bar.com",
|
||||
}
|
||||
client := NewMockCloudFlareClientWithRecords(map[string][]dns.RecordResponse{
|
||||
"001": {srvRecord},
|
||||
})
|
||||
p := &CloudFlareProvider{Client: client}
|
||||
|
||||
records := DNSRecordsMap{
|
||||
newDNSRecordIndex(srvRecord): srvRecord,
|
||||
}
|
||||
bc := batchCollections{
|
||||
fallbackUpdates: []*cloudFlareChange{
|
||||
{Action: cloudFlareUpdate, ResourceRecord: srvRecord},
|
||||
},
|
||||
}
|
||||
|
||||
failed := p.submitDNSRecordChanges(t.Context(), "001", bc, records)
|
||||
assert.True(t, failed, "failed SRV fallback update should be reported")
|
||||
})
|
||||
}
|
||||
|
||||
func TestFallbackIndividualChanges_MissingRecord(t *testing.T) {
|
||||
client := NewMockCloudFlareClientWithRecords(map[string][]dns.RecordResponse{
|
||||
"001": {},
|
||||
})
|
||||
p := &CloudFlareProvider{Client: client}
|
||||
emptyRecords := make(DNSRecordsMap)
|
||||
|
||||
t.Run("delete where record is already gone succeeds silently", func(t *testing.T) {
|
||||
chunk := batchChunk{
|
||||
deleteChanges: []*cloudFlareChange{
|
||||
{
|
||||
Action: cloudFlareDelete,
|
||||
ResourceRecord: dns.RecordResponse{
|
||||
Name: "gone.bar.com",
|
||||
Type: dns.RecordResponseTypeA,
|
||||
Content: "1.2.3.4",
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
failed := p.fallbackIndividualChanges(t.Context(), "001", chunk, emptyRecords)
|
||||
assert.False(t, failed, "delete of already-absent record should not report failure")
|
||||
})
|
||||
|
||||
t.Run("update where record is not found skips gracefully", func(t *testing.T) {
|
||||
chunk := batchChunk{
|
||||
updateChanges: []*cloudFlareChange{
|
||||
{
|
||||
Action: cloudFlareUpdate,
|
||||
ResourceRecord: dns.RecordResponse{
|
||||
Name: "missing.bar.com",
|
||||
Type: dns.RecordResponseTypeA,
|
||||
Content: "1.2.3.4",
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
failed := p.fallbackIndividualChanges(t.Context(), "001", chunk, emptyRecords)
|
||||
assert.False(t, failed, "update of missing record should not report failure")
|
||||
})
|
||||
}
|
||||
@ -277,6 +277,30 @@ func (p *CloudFlareProvider) listCustomHostnamesWithPagination(ctx context.Conte
|
||||
return chs, nil
|
||||
}
|
||||
|
||||
// processCustomHostnameChanges applies custom hostname side-effects for each
|
||||
// change in the set and returns true if any operation failed.
|
||||
func (p *CloudFlareProvider) processCustomHostnameChanges(
|
||||
ctx context.Context,
|
||||
zoneID string,
|
||||
changes []*cloudFlareChange,
|
||||
chs customHostnamesMap,
|
||||
) bool {
|
||||
failed := false
|
||||
for _, change := range changes {
|
||||
logFields := log.Fields{
|
||||
"record": change.ResourceRecord.Name,
|
||||
"type": change.ResourceRecord.Type,
|
||||
"ttl": change.ResourceRecord.TTL,
|
||||
"action": change.Action.String(),
|
||||
"zone": zoneID,
|
||||
}
|
||||
if !p.submitCustomHostnameChanges(ctx, zoneID, change, chs, logFields) {
|
||||
failed = true
|
||||
}
|
||||
}
|
||||
return failed
|
||||
}
|
||||
|
||||
// listAllCustomHostnames extracts all custom hostnames from the iterator
|
||||
func listAllCustomHostnames(iter autoPager[custom_hostnames.CustomHostnameListResponse]) ([]customHostname, error) {
|
||||
var customHostnames []customHostname
|
||||
|
||||
@ -28,6 +28,7 @@ import (
|
||||
log "github.com/sirupsen/logrus"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"sigs.k8s.io/external-dns/endpoint"
|
||||
"sigs.k8s.io/external-dns/internal/testutils"
|
||||
@ -1294,3 +1295,89 @@ func TestCloudflareAdjustEndpointsRegionalServices(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestSubmitChanges_DryRun_RegionalErrors covers error paths in the dry-run branch of
|
||||
// submitChanges. Two statements in that branch are not covered by any test:
|
||||
//
|
||||
// - the `failedChange = true` body inside `if !p.submitRegionalHostnameChanges(...)`
|
||||
// - the subsequent `failedZones = append(...)` when failedChange is true
|
||||
//
|
||||
// Both are unreachable because submitRegionalHostnameChange unconditionally returns true
|
||||
// when DryRun=true (it logs and returns before making any API call), so the failure
|
||||
// branch can never be entered without changing the production code.
|
||||
func TestSubmitChanges_DryRun_RegionalErrors(t *testing.T) {
|
||||
t.Run("desiredRegionalHostnames conflict returns error", func(t *testing.T) {
|
||||
// Two changes for the same hostname with different region keys →
|
||||
// desiredRegionalHostnames returns a conflict error.
|
||||
client := NewMockCloudFlareClient()
|
||||
p := &CloudFlareProvider{
|
||||
Client: client,
|
||||
DryRun: true,
|
||||
RegionalServicesConfig: RegionalServicesConfig{
|
||||
Enabled: true,
|
||||
},
|
||||
}
|
||||
|
||||
// Build conflicting cloudFlareChanges directly and call submitChanges,
|
||||
// which is in the same package.
|
||||
changes := []*cloudFlareChange{
|
||||
{
|
||||
Action: cloudFlareCreate,
|
||||
ResourceRecord: dns.RecordResponse{Name: "foo.bar.com", Type: "A", Content: "1.2.3.4"},
|
||||
RegionalHostname: regionalHostname{
|
||||
hostname: "foo.bar.com",
|
||||
regionKey: "us",
|
||||
},
|
||||
},
|
||||
{
|
||||
Action: cloudFlareUpdate,
|
||||
ResourceRecord: dns.RecordResponse{Name: "foo.bar.com", Type: "A", Content: "1.2.3.4"},
|
||||
RegionalHostname: regionalHostname{
|
||||
hostname: "foo.bar.com",
|
||||
regionKey: "eu", // different from "us" → conflict
|
||||
},
|
||||
},
|
||||
}
|
||||
err := p.submitChanges(t.Context(), changes)
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "failed to build desired regional hostnames")
|
||||
})
|
||||
|
||||
t.Run("listDataLocalisationRegionalHostnames error in dry-run returns error", func(t *testing.T) {
|
||||
// Zone ID containing "rherror" causes the mock to return an error from
|
||||
// ListDataLocalizationRegionalHostnames.
|
||||
client := &mockCloudFlareClient{
|
||||
Zones: map[string]string{
|
||||
"rherror-zone1": "rherror.bar.com",
|
||||
},
|
||||
Records: map[string]map[string]dns.RecordResponse{
|
||||
"rherror-zone1": {},
|
||||
},
|
||||
customHostnames: map[string][]customHostname{},
|
||||
regionalHostnames: map[string][]regionalHostname{},
|
||||
}
|
||||
p := &CloudFlareProvider{
|
||||
Client: client,
|
||||
DryRun: true,
|
||||
RegionalServicesConfig: RegionalServicesConfig{
|
||||
Enabled: true,
|
||||
RegionKey: "us",
|
||||
},
|
||||
domainFilter: endpoint.NewDomainFilter([]string{"rherror.bar.com"}),
|
||||
}
|
||||
|
||||
changes := []*cloudFlareChange{
|
||||
{
|
||||
Action: cloudFlareCreate,
|
||||
ResourceRecord: dns.RecordResponse{Name: "foo.rherror.bar.com", Type: "A", Content: "1.2.3.4"},
|
||||
RegionalHostname: regionalHostname{
|
||||
hostname: "foo.rherror.bar.com",
|
||||
regionKey: "us",
|
||||
},
|
||||
},
|
||||
}
|
||||
err := p.submitChanges(t.Context(), changes)
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "could not fetch regional hostnames from zone")
|
||||
})
|
||||
}
|
||||
|
||||
@ -18,8 +18,10 @@ package cloudflare
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
@ -29,6 +31,7 @@ import (
|
||||
|
||||
"github.com/cloudflare/cloudflare-go/v5"
|
||||
"github.com/cloudflare/cloudflare-go/v5/dns"
|
||||
"github.com/cloudflare/cloudflare-go/v5/option"
|
||||
"github.com/cloudflare/cloudflare-go/v5/zones"
|
||||
"github.com/maxatome/go-testdeep/td"
|
||||
log "github.com/sirupsen/logrus"
|
||||
@ -100,6 +103,7 @@ type mockCloudFlareClient struct {
|
||||
Zones map[string]string
|
||||
Records map[string]map[string]dns.RecordResponse
|
||||
Actions []MockAction
|
||||
BatchDNSRecordsCalls int
|
||||
listZonesError error // For v4 ListZones
|
||||
getZoneError error // For v4 GetZone
|
||||
dnsRecordsError error
|
||||
@ -1680,19 +1684,6 @@ func TestCloudflareComplexUpdate(t *testing.T) {
|
||||
ZoneId: "001",
|
||||
RecordId: "2345678901",
|
||||
},
|
||||
{
|
||||
Name: "Create",
|
||||
ZoneId: "001",
|
||||
RecordId: generateDNSRecordID("A", "foobar.bar.com", "2.3.4.5"),
|
||||
RecordData: dns.RecordResponse{
|
||||
ID: generateDNSRecordID("A", "foobar.bar.com", "2.3.4.5"),
|
||||
Name: "foobar.bar.com",
|
||||
Type: "A",
|
||||
Content: "2.3.4.5",
|
||||
TTL: 1,
|
||||
Proxied: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "Update",
|
||||
ZoneId: "001",
|
||||
@ -1706,6 +1697,19 @@ func TestCloudflareComplexUpdate(t *testing.T) {
|
||||
Proxied: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "Create",
|
||||
ZoneId: "001",
|
||||
RecordId: generateDNSRecordID("A", "foobar.bar.com", "2.3.4.5"),
|
||||
RecordData: dns.RecordResponse{
|
||||
ID: generateDNSRecordID("A", "foobar.bar.com", "2.3.4.5"),
|
||||
Name: "foobar.bar.com",
|
||||
Type: "A",
|
||||
Content: "2.3.4.5",
|
||||
TTL: 1,
|
||||
Proxied: true,
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
@ -2627,6 +2631,24 @@ func TestConvertCloudflareError(t *testing.T) {
|
||||
expectSoftError: true,
|
||||
description: "Server error (503) should be converted to soft error",
|
||||
},
|
||||
{
|
||||
name: "io.ErrUnexpectedEOF is soft",
|
||||
inputError: io.ErrUnexpectedEOF,
|
||||
expectSoftError: true,
|
||||
description: "Unexpected EOF (connection closed mid-response) should be converted to soft error",
|
||||
},
|
||||
{
|
||||
name: "io.EOF is soft",
|
||||
inputError: io.EOF,
|
||||
expectSoftError: true,
|
||||
description: "EOF (connection closed before response) should be converted to soft error",
|
||||
},
|
||||
{
|
||||
name: "wrapped io.ErrUnexpectedEOF is soft",
|
||||
inputError: fmt.Errorf("transport error: %w", io.ErrUnexpectedEOF),
|
||||
expectSoftError: true,
|
||||
description: "Wrapped unexpected EOF should be converted to soft error",
|
||||
},
|
||||
{
|
||||
name: "Rate limit string error",
|
||||
inputError: errors.New("exceeded available rate limit retries"),
|
||||
@ -2980,8 +3002,226 @@ func TestZoneService(t *testing.T) {
|
||||
err := client.CreateCustomHostname(ctx, zoneID, customHostname{})
|
||||
assert.ErrorIs(t, err, context.Canceled)
|
||||
})
|
||||
|
||||
t.Run("BatchDNSRecords", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
_, err := client.BatchDNSRecords(ctx, dns.RecordBatchParams{ZoneID: cloudflare.F(zoneID)})
|
||||
assert.ErrorIs(t, err, context.Canceled)
|
||||
})
|
||||
}
|
||||
|
||||
func generateDNSRecordID(rrtype string, name string, content string) string {
|
||||
return fmt.Sprintf("%s-%s-%s", name, rrtype, content)
|
||||
func TestSubmitChanges_ErrorPaths(t *testing.T) {
|
||||
t.Run("getDNSRecordsMap error returns error from submitChanges", func(t *testing.T) {
|
||||
client := NewMockCloudFlareClient()
|
||||
client.dnsRecordsError = errors.New("dns list failed")
|
||||
p := &CloudFlareProvider{Client: client}
|
||||
|
||||
changes := &plan.Changes{
|
||||
Create: []*endpoint.Endpoint{
|
||||
{DNSName: "test.bar.com", Targets: endpoint.Targets{"1.2.3.4"}, RecordType: "A"},
|
||||
},
|
||||
}
|
||||
err := p.ApplyChanges(t.Context(), changes)
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "could not fetch records from zone")
|
||||
})
|
||||
|
||||
t.Run("listCustomHostnamesWithPagination error returns error from submitChanges", func(t *testing.T) {
|
||||
// The mock returns an error for CustomHostnames() when zoneID starts with "newerror-".
|
||||
// CustomHostnamesConfig.Enabled must be true to reach that code path.
|
||||
client := &mockCloudFlareClient{
|
||||
Zones: map[string]string{
|
||||
"newerror-zone1": "errorcf.com",
|
||||
},
|
||||
Records: map[string]map[string]dns.RecordResponse{
|
||||
"newerror-zone1": {},
|
||||
},
|
||||
customHostnames: map[string][]customHostname{},
|
||||
regionalHostnames: map[string][]regionalHostname{},
|
||||
}
|
||||
p := &CloudFlareProvider{
|
||||
Client: client,
|
||||
domainFilter: endpoint.NewDomainFilter([]string{"errorcf.com"}),
|
||||
CustomHostnamesConfig: CustomHostnamesConfig{Enabled: true},
|
||||
}
|
||||
|
||||
changes := &plan.Changes{
|
||||
Create: []*endpoint.Endpoint{
|
||||
{DNSName: "sub.errorcf.com", Targets: endpoint.Targets{"1.2.3.4"}, RecordType: "A"},
|
||||
},
|
||||
}
|
||||
err := p.ApplyChanges(t.Context(), changes)
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "could not fetch custom hostnames from zone")
|
||||
})
|
||||
|
||||
t.Run("processCustomHostnameChanges failure sets failedChange", func(t *testing.T) {
|
||||
// The mock's CreateCustomHostname fails for "newerror-create.foo.fancybar.com".
|
||||
// With CustomHostnames enabled, the failing create causes processCustomHostnameChanges
|
||||
// to return true, which sets failedChange=true for the zone.
|
||||
client := NewMockCloudFlareClient()
|
||||
p := &CloudFlareProvider{
|
||||
Client: client,
|
||||
CustomHostnamesConfig: CustomHostnamesConfig{Enabled: true},
|
||||
}
|
||||
|
||||
changes := &plan.Changes{
|
||||
Create: []*endpoint.Endpoint{
|
||||
{
|
||||
DNSName: "a.bar.com",
|
||||
Targets: endpoint.Targets{"1.2.3.4"},
|
||||
RecordType: "A",
|
||||
ProviderSpecific: endpoint.ProviderSpecific{
|
||||
{
|
||||
Name: "external-dns.alpha.kubernetes.io/cloudflare-custom-hostname",
|
||||
Value: "newerror-create.foo.fancybar.com",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
err := p.ApplyChanges(t.Context(), changes)
|
||||
require.Error(t, err, "failing custom hostname create should cause an error")
|
||||
})
|
||||
|
||||
t.Run("Zones error propagates from submitChanges", func(t *testing.T) {
|
||||
// Setting listZonesError causes p.Zones() to fail inside submitChanges,
|
||||
// exercising the `if err != nil { return err }` block at the top of the loop.
|
||||
client := NewMockCloudFlareClient()
|
||||
client.listZonesError = errors.New("zones fetch failed")
|
||||
p := &CloudFlareProvider{Client: client}
|
||||
|
||||
changes := &plan.Changes{
|
||||
Create: []*endpoint.Endpoint{
|
||||
{DNSName: "test.bar.com", Targets: endpoint.Targets{"1.2.3.4"}, RecordType: "A"},
|
||||
},
|
||||
}
|
||||
err := p.ApplyChanges(t.Context(), changes)
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "zones fetch failed")
|
||||
})
|
||||
}
|
||||
|
||||
func TestParseTagsAnnotation(t *testing.T) {
|
||||
t.Run("parses comma-separated tags", func(t *testing.T) {
|
||||
tags := parseTagsAnnotation("tag1,tag2,tag3")
|
||||
assert.Equal(t, []string{"tag1", "tag2", "tag3"}, tags)
|
||||
})
|
||||
t.Run("trims whitespace from each tag", func(t *testing.T) {
|
||||
tags := parseTagsAnnotation(" z-tag , a-tag ")
|
||||
assert.Equal(t, []string{"a-tag", "z-tag"}, tags)
|
||||
})
|
||||
t.Run("sorts tags canonically", func(t *testing.T) {
|
||||
tags := parseTagsAnnotation("c,a,b")
|
||||
assert.Equal(t, []string{"a", "b", "c"}, tags)
|
||||
})
|
||||
t.Run("skips empty tokens", func(t *testing.T) {
|
||||
tags := parseTagsAnnotation("tag1,,,, tag2")
|
||||
assert.Equal(t, []string{"tag1", "tag2"}, tags)
|
||||
})
|
||||
}
|
||||
|
||||
func TestAdjustEndpoints_TagsAnnotation(t *testing.T) {
|
||||
// parseTagsAnnotation is only invoked when the CloudflareTagsKey annotation
|
||||
// is present on the endpoint. This test exercises that branch via AdjustEndpoints.
|
||||
p := &CloudFlareProvider{}
|
||||
ep := &endpoint.Endpoint{
|
||||
RecordType: "A",
|
||||
DNSName: "test.bar.com",
|
||||
Targets: endpoint.Targets{"1.2.3.4"},
|
||||
ProviderSpecific: endpoint.ProviderSpecific{
|
||||
{
|
||||
Name: annotations.CloudflareTagsKey,
|
||||
Value: "beta, alpha, gamma",
|
||||
},
|
||||
},
|
||||
}
|
||||
adjusted, err := p.AdjustEndpoints([]*endpoint.Endpoint{ep})
|
||||
require.NoError(t, err)
|
||||
require.Len(t, adjusted, 1)
|
||||
|
||||
val, ok := adjusted[0].GetProviderSpecificProperty(annotations.CloudflareTagsKey)
|
||||
require.True(t, ok, "tags annotation should still be present after AdjustEndpoints")
|
||||
// Tags should be sorted and whitespace-trimmed
|
||||
assert.Equal(t, "alpha,beta,gamma", val)
|
||||
}
|
||||
|
||||
func TestZoneServiceZoneIDByName(t *testing.T) {
|
||||
// Build a minimal cloudflare API response page for /zones.
|
||||
writeZonesPage := func(w http.ResponseWriter, zones []map[string]any) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
if err := json.NewEncoder(w).Encode(map[string]any{
|
||||
"result": zones,
|
||||
"result_info": map[string]any{
|
||||
"count": len(zones),
|
||||
"total_count": len(zones),
|
||||
"page": 1,
|
||||
"per_page": 20,
|
||||
},
|
||||
"success": true,
|
||||
"errors": []any{},
|
||||
"messages": []any{},
|
||||
}); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
|
||||
t.Run("zone found returns its ID", func(t *testing.T) {
|
||||
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
writeZonesPage(w, []map[string]any{
|
||||
{"id": "zone-abc", "name": "example.com", "plan": map[string]any{"is_subscribed": false}},
|
||||
})
|
||||
}))
|
||||
defer ts.Close()
|
||||
|
||||
svc := &zoneService{service: cloudflare.NewClient(
|
||||
option.WithBaseURL(ts.URL+"/"),
|
||||
option.WithAPIToken("test-token"),
|
||||
option.WithMaxRetries(0),
|
||||
)}
|
||||
id, err := svc.ZoneIDByName("example.com")
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "zone-abc", id)
|
||||
})
|
||||
|
||||
t.Run("zone not found returns descriptive error", func(t *testing.T) {
|
||||
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
writeZonesPage(w, []map[string]any{})
|
||||
}))
|
||||
defer ts.Close()
|
||||
|
||||
svc := &zoneService{service: cloudflare.NewClient(
|
||||
option.WithBaseURL(ts.URL+"/"),
|
||||
option.WithAPIToken("test-token"),
|
||||
option.WithMaxRetries(0),
|
||||
)}
|
||||
id, err := svc.ZoneIDByName("missing.com")
|
||||
assert.Empty(t, id)
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "not found in CloudFlare account")
|
||||
})
|
||||
|
||||
t.Run("server error causes wrapped iterator error", func(t *testing.T) {
|
||||
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||
"result": nil,
|
||||
"success": false,
|
||||
"errors": []map[string]any{{"code": 500, "message": "internal server error"}},
|
||||
"messages": []any{},
|
||||
})
|
||||
}))
|
||||
defer ts.Close()
|
||||
|
||||
svc := &zoneService{service: cloudflare.NewClient(
|
||||
option.WithBaseURL(ts.URL+"/"),
|
||||
option.WithAPIToken("test-token"),
|
||||
option.WithMaxRetries(0),
|
||||
)}
|
||||
id, err := svc.ZoneIDByName("any.com")
|
||||
assert.Empty(t, id)
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "failed to list zones from CloudFlare API")
|
||||
})
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user