• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

kubernetes-sigs / external-dns / 18071083977

28 Sep 2025 07:07AM UTC coverage: 78.543% (+0.09%) from 78.458%
18071083977

Pull #4823

github

troll-os
Removed boolean flag that enabled migration, evaluate only against old owner flag instead
Pull Request #4823: feat: add new flags to allow migration of OwnerID

18 of 18 new or added lines in 5 files covered. (100.0%)

76 existing lines in 2 files now uncovered.

15780 of 20091 relevant lines covered (78.54%)

753.17 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

90.55
/provider/cloudflare/cloudflare.go
1
/*
2
Copyright 2017 The Kubernetes Authors.
3

4
Licensed under the Apache License, Version 2.0 (the "License");
5
you may not use this file except in compliance with the License.
6
You may obtain a copy of the License at
7

8
    http://www.apache.org/licenses/LICENSE-2.0
9

10
Unless required by applicable law or agreed to in writing, software
11
distributed under the License is distributed on an "AS IS" BASIS,
12
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13
See the License for the specific language governing permissions and
14
limitations under the License.
15
*/
16

17
package cloudflare
18

19
import (
20
        "context"
21
        "errors"
22
        "fmt"
23
        "maps"
24
        "net/http"
25
        "os"
26
        "slices"
27
        "sort"
28
        "strconv"
29
        "strings"
30

31
        cloudflarev0 "github.com/cloudflare/cloudflare-go"
32
        "github.com/cloudflare/cloudflare-go/v5"
33
        "github.com/cloudflare/cloudflare-go/v5/addressing"
34
        "github.com/cloudflare/cloudflare-go/v5/dns"
35
        "github.com/cloudflare/cloudflare-go/v5/option"
36
        "github.com/cloudflare/cloudflare-go/v5/zones"
37
        log "github.com/sirupsen/logrus"
38
        "golang.org/x/net/publicsuffix"
39

40
        "sigs.k8s.io/external-dns/endpoint"
41
        "sigs.k8s.io/external-dns/plan"
42
        "sigs.k8s.io/external-dns/provider"
43
        "sigs.k8s.io/external-dns/source/annotations"
44
)
45

46
type changeAction int
47

48
const (
49
        // cloudFlareCreate is a ChangeAction enum value
50
        cloudFlareCreate changeAction = iota
51
        // cloudFlareDelete is a ChangeAction enum value
52
        cloudFlareDelete
53
        // cloudFlareUpdate is a ChangeAction enum value
54
        cloudFlareUpdate
55
        // defaultTTL 1 = automatic
56
        defaultTTL = 1
57

58
        // Cloudflare tier limitations https://developers.cloudflare.com/dns/manage-dns-records/reference/record-attributes/#availability
59
        freeZoneMaxCommentLength = 100
60
        paidZoneMaxCommentLength = 500
61
)
62

63
var changeActionNames = map[changeAction]string{
64
        cloudFlareCreate: "CREATE",
65
        cloudFlareDelete: "DELETE",
66
        cloudFlareUpdate: "UPDATE",
67
}
68

69
func (action changeAction) String() string {
70
        return changeActionNames[action]
71
}
72

73
type DNSRecordIndex struct {
74
        Name    string
415✔
75
        Type    string
415✔
76
        Content string
415✔
77
}
78

79
type DNSRecordsMap map[DNSRecordIndex]dns.RecordResponse
80

81
// for faster getCustomHostname() lookup
82
type CustomHostnameIndex struct {
83
        Hostname string
84
}
85

86
type CustomHostnamesMap map[CustomHostnameIndex]cloudflarev0.CustomHostname
87

88
var recordTypeProxyNotSupported = map[string]bool{
89
        "LOC": true,
90
        "MX":  true,
91
        "NS":  true,
92
        "SPF": true,
93
        "TXT": true,
94
        "SRV": true,
95
}
96

97
type CustomHostnamesConfig struct {
98
        Enabled              bool
99
        MinTLSVersion        string
100
        CertificateAuthority string
101
}
102

103
var recordTypeCustomHostnameSupported = map[string]bool{
104
        "A":     true,
105
        "CNAME": true,
106
}
107

108
// cloudFlareDNS is the subset of the CloudFlare API that we actually use.  Add methods as required. Signatures must match exactly.
109
type cloudFlareDNS interface {
110
        ZoneIDByName(zoneName string) (string, error)
111
        ListZones(ctx context.Context, params zones.ZoneListParams) autoPager[zones.Zone]
112
        GetZone(ctx context.Context, zoneID string) (*zones.Zone, error)
113
        ListDNSRecords(ctx context.Context, params dns.RecordListParams) autoPager[dns.RecordResponse]
114
        CreateDNSRecord(ctx context.Context, params dns.RecordNewParams) (*dns.RecordResponse, error)
115
        DeleteDNSRecord(ctx context.Context, recordID string, params dns.RecordDeleteParams) error
116
        UpdateDNSRecord(ctx context.Context, recordID string, params dns.RecordUpdateParams) (*dns.RecordResponse, error)
117
        ListDataLocalizationRegionalHostnames(ctx context.Context, params addressing.RegionalHostnameListParams) autoPager[addressing.RegionalHostnameListResponse]
118
        CreateDataLocalizationRegionalHostname(ctx context.Context, params addressing.RegionalHostnameNewParams) error
119
        UpdateDataLocalizationRegionalHostname(ctx context.Context, hostname string, params addressing.RegionalHostnameEditParams) error
120
        DeleteDataLocalizationRegionalHostname(ctx context.Context, hostname string, params addressing.RegionalHostnameDeleteParams) error
121
        CustomHostnames(ctx context.Context, zoneID string, page int, filter cloudflarev0.CustomHostname) ([]cloudflarev0.CustomHostname, cloudflarev0.ResultInfo, error)
122
        DeleteCustomHostname(ctx context.Context, zoneID string, customHostnameID string) error
123
        CreateCustomHostname(ctx context.Context, zoneID string, ch cloudflarev0.CustomHostname) (*cloudflarev0.CustomHostnameResponse, error)
124
}
125

126
type zoneService struct {
127
        serviceV0 *cloudflarev0.API
128
        service   *cloudflare.Client
129
}
130

131
func (z zoneService) ZoneIDByName(zoneName string) (string, error) {
132
        // Use v4 API to find zone by name
133
        params := zones.ZoneListParams{
134
                Name: cloudflare.F(zoneName),
135
        }
136

×
137
        iter := z.service.Zones.ListAutoPaging(context.Background(), params)
×
138
        for zone := range autoPagerIterator(iter) {
×
139
                if zone.Name == zoneName {
×
140
                        return zone.ID, nil
×
141
                }
×
UNCOV
142
        }
×
UNCOV
143

×
144
        if err := iter.Err(); err != nil {
×
145
                return "", fmt.Errorf("failed to list zones from CloudFlare API: %w", err)
×
146
        }
×
147

148
        return "", fmt.Errorf("zone %q not found in CloudFlare account - verify the zone exists and API credentials have access to it", zoneName)
UNCOV
149
}
×
UNCOV
150

×
UNCOV
151
func (z zoneService) CreateDNSRecord(ctx context.Context, params dns.RecordNewParams) (*dns.RecordResponse, error) {
×
152
        return z.service.DNS.Records.New(ctx, params)
UNCOV
153
}
×
154

155
func (z zoneService) ListDNSRecords(ctx context.Context, params dns.RecordListParams) autoPager[dns.RecordResponse] {
156
        return z.service.DNS.Records.ListAutoPaging(ctx, params)
1✔
157
}
1✔
158

1✔
159
func (z zoneService) UpdateDNSRecord(ctx context.Context, recordID string, params dns.RecordUpdateParams) (*dns.RecordResponse, error) {
160
        return z.service.DNS.Records.Update(ctx, recordID, params)
1✔
161
}
1✔
162

1✔
163
func (z zoneService) DeleteDNSRecord(ctx context.Context, recordID string, params dns.RecordDeleteParams) error {
164
        _, err := z.service.DNS.Records.Delete(ctx, recordID, params)
1✔
165
        return err
1✔
166
}
1✔
167

168
func (z zoneService) ListZones(ctx context.Context, params zones.ZoneListParams) autoPager[zones.Zone] {
2✔
169
        return z.service.Zones.ListAutoPaging(ctx, params)
2✔
170
}
2✔
171

2✔
172
func (z zoneService) GetZone(ctx context.Context, zoneID string) (*zones.Zone, error) {
173
        return z.service.Zones.Get(ctx, zones.ZoneGetParams{ZoneID: cloudflare.F(zoneID)})
1✔
174
}
1✔
175

1✔
176
func (z zoneService) CustomHostnames(ctx context.Context, zoneID string, page int, filter cloudflarev0.CustomHostname) ([]cloudflarev0.CustomHostname, cloudflarev0.ResultInfo, error) {
177
        return z.serviceV0.CustomHostnames(ctx, zoneID, page, filter)
1✔
178
}
1✔
179

1✔
180
func (z zoneService) DeleteCustomHostname(ctx context.Context, zoneID string, customHostnameID string) error {
181
        return z.serviceV0.DeleteCustomHostname(ctx, zoneID, customHostnameID)
1✔
182
}
1✔
183

1✔
184
func (z zoneService) CreateCustomHostname(ctx context.Context, zoneID string, ch cloudflarev0.CustomHostname) (*cloudflarev0.CustomHostnameResponse, error) {
185
        return z.serviceV0.CreateCustomHostname(ctx, zoneID, ch)
1✔
186
}
1✔
187

1✔
188
// listZonesV4Params returns the appropriate Zone List Params for v4 API
189
func listZonesV4Params() zones.ZoneListParams {
1✔
190
        return zones.ZoneListParams{}
1✔
191
}
1✔
192

193
type DNSRecordsConfig struct {
194
        PerPage int
117✔
195
        Comment string
117✔
196
}
117✔
197

198
func (c *DNSRecordsConfig) trimAndValidateComment(dnsName, comment string, paidZone func(string) bool) string {
199
        if len(comment) <= freeZoneMaxCommentLength {
200
                return comment
201
        }
202

203
        maxLength := freeZoneMaxCommentLength
3✔
204
        if paidZone(dnsName) {
3✔
UNCOV
205
                maxLength = paidZoneMaxCommentLength
×
UNCOV
206
        }
×
207

208
        if len(comment) > maxLength {
3✔
209
                log.Warnf("DNS record comment is invalid. Trimming comment of %s. To avoid endless syncs, please set it to less than %d chars.", dnsName, maxLength)
5✔
210
                return comment[:maxLength]
2✔
211
        }
2✔
212

213
        return comment
5✔
214
}
2✔
215

2✔
216
func (p *CloudFlareProvider) ZoneHasPaidPlan(hostname string) bool {
2✔
217
        zone, err := publicsuffix.EffectiveTLDPlusOne(hostname)
218
        if err != nil {
1✔
219
                log.Errorf("Failed to get effective TLD+1 for hostname %s %v", hostname, err)
220
                return false
221
        }
17✔
222
        zoneID, err := p.Client.ZoneIDByName(zone)
17✔
223
        if err != nil {
18✔
224
                log.Errorf("Failed to get zone %s by name %v", zone, err)
1✔
225
                return false
1✔
226
        }
1✔
227

16✔
228
        zoneDetails, err := p.Client.GetZone(context.Background(), zoneID)
18✔
229
        if err != nil {
2✔
230
                log.Errorf("Failed to get zone %s details %v", zone, err)
2✔
231
                return false
2✔
232
        }
233

14✔
234
        return zoneDetails.Plan.IsSubscribed //nolint:staticcheck // SA1019: Plan.IsSubscribed is deprecated but no replacement available yet
16✔
235
}
2✔
236

2✔
237
// CloudFlareProvider is an implementation of Provider for CloudFlare DNS.
2✔
238
type CloudFlareProvider struct {
239
        provider.BaseProvider
12✔
240
        Client cloudFlareDNS
241
        // only consider hosted zones managing domains ending in this suffix
242
        domainFilter           *endpoint.DomainFilter
243
        zoneIDFilter           provider.ZoneIDFilter
244
        proxiedByDefault       bool
245
        DryRun                 bool
246
        CustomHostnamesConfig  CustomHostnamesConfig
247
        DNSRecordsConfig       DNSRecordsConfig
248
        RegionalServicesConfig RegionalServicesConfig
249
}
250

251
// cloudFlareChange differentiates between ChangeActions
252
type cloudFlareChange struct {
253
        Action              changeAction
254
        ResourceRecord      dns.RecordResponse
255
        RegionalHostname    regionalHostname
256
        CustomHostnames     map[string]cloudflarev0.CustomHostname
257
        CustomHostnamesPrev []string
258
}
259

260
// RecordParamsTypes is a typeset of the possible Record Params that can be passed to cloudflare-go library
261
type RecordParamsTypes interface {
262
        cloudflarev0.UpdateDNSRecordParams | cloudflarev0.CreateDNSRecordParams
263
}
264

265
// updateDNSRecordParam is a function that returns the appropriate Record Param based on the cloudFlareChange passed in
266
func getUpdateDNSRecordParam(zoneID string, cfc cloudFlareChange) dns.RecordUpdateParams {
267
        return dns.RecordUpdateParams{
268
                ZoneID: cloudflare.F(zoneID),
269
                Body: dns.RecordUpdateParamsBody{
270
                        Name:     cloudflare.F(cfc.ResourceRecord.Name),
271
                        TTL:      cloudflare.F(cfc.ResourceRecord.TTL),
5✔
272
                        Proxied:  cloudflare.F(cfc.ResourceRecord.Proxied),
5✔
273
                        Type:     cloudflare.F(dns.RecordUpdateParamsBodyType(cfc.ResourceRecord.Type)),
5✔
274
                        Content:  cloudflare.F(cfc.ResourceRecord.Content),
5✔
275
                        Priority: cloudflare.F(cfc.ResourceRecord.Priority),
5✔
276
                        Comment:  cloudflare.F(cfc.ResourceRecord.Comment),
5✔
277
                },
5✔
278
        }
5✔
279
}
5✔
280

5✔
281
// getCreateDNSRecordParam is a function that returns the appropriate Record Param based on the cloudFlareChange passed in
5✔
282
func getCreateDNSRecordParam(zoneID string, cfc *cloudFlareChange) dns.RecordNewParams {
5✔
283
        return dns.RecordNewParams{
5✔
284
                ZoneID: cloudflare.F(zoneID),
5✔
285
                Body: dns.RecordNewParamsBody{
286
                        Name:     cloudflare.F(cfc.ResourceRecord.Name),
287
                        TTL:      cloudflare.F(cfc.ResourceRecord.TTL),
385✔
288
                        Proxied:  cloudflare.F(cfc.ResourceRecord.Proxied),
385✔
289
                        Type:     cloudflare.F(dns.RecordNewParamsBodyType(cfc.ResourceRecord.Type)),
385✔
290
                        Content:  cloudflare.F(cfc.ResourceRecord.Content),
385✔
291
                        Priority: cloudflare.F(cfc.ResourceRecord.Priority),
385✔
292
                        Comment:  cloudflare.F(cfc.ResourceRecord.Comment),
385✔
293
                },
385✔
294
        }
385✔
295
}
385✔
296

385✔
297
func convertCloudflareError(err error) error {
385✔
298
        var apiErr *cloudflarev0.Error
385✔
299
        if errors.As(err, &apiErr) {
385✔
300
                if apiErr.ClientRateLimited() || apiErr.StatusCode >= http.StatusInternalServerError {
385✔
301
                        // Handle rate limit error as a soft error
302
                        return provider.NewSoftError(err)
30✔
303
                }
30✔
304
        }
46✔
305
        // This is a workaround because Cloudflare library does not return a specific error type for rate limit exceeded.
28✔
306
        // See https://github.com/cloudflare/cloudflare-go/issues/4155 and https://github.com/kubernetes-sigs/external-dns/pull/5524
12✔
307
        // This workaround can be removed once Cloudflare library returns a specific error type.
12✔
308
        if strings.Contains(err.Error(), "exceeded available rate limit retries") {
12✔
309
                return provider.NewSoftError(err)
310
        }
311
        return err
312
}
313

22✔
314
// NewCloudFlareProvider initializes a new CloudFlare DNS based Provider.
4✔
315
func NewCloudFlareProvider(
4✔
316
        domainFilter *endpoint.DomainFilter,
14✔
317
        zoneIDFilter provider.ZoneIDFilter,
318
        proxiedByDefault bool,
319
        dryRun bool,
320
        regionalServicesConfig RegionalServicesConfig,
321
        customHostnamesConfig CustomHostnamesConfig,
322
        dnsRecordsConfig DNSRecordsConfig,
323
) (*CloudFlareProvider, error) {
324
        // initialize via chosen auth method and returns new API object
325
        var (
326
                config   *cloudflarev0.API
327
                configV4 *cloudflare.Client
328
                err      error
7✔
329
        )
7✔
330
        if os.Getenv("CF_API_TOKEN") != "" {
7✔
331
                token := os.Getenv("CF_API_TOKEN")
7✔
332
                if strings.HasPrefix(token, "file:") {
7✔
333
                        tokenBytes, err := os.ReadFile(strings.TrimPrefix(token, "file:"))
7✔
334
                        if err != nil {
7✔
335
                                return nil, fmt.Errorf("failed to read CF_API_TOKEN from file: %w", err)
7✔
336
                        }
12✔
337
                        token = strings.TrimSpace(string(tokenBytes))
7✔
338
                }
2✔
339
                config, err = cloudflarev0.NewWithAPIToken(token)
3✔
340
                configV4 = cloudflare.NewClient(
1✔
341
                        option.WithAPIToken(token),
1✔
342
                )
1✔
343
        } else {
344
                config, err = cloudflarev0.New(os.Getenv("CF_API_KEY"), os.Getenv("CF_API_EMAIL"))
4✔
345
                configV4 = cloudflare.NewClient(
4✔
346
                        option.WithAPIKey(os.Getenv("CF_API_KEY")),
4✔
347
                        option.WithAPIEmail(os.Getenv("CF_API_EMAIL")),
4✔
348
                )
2✔
349
        }
2✔
350
        if err != nil {
2✔
351
                return nil, fmt.Errorf("failed to initialize cloudflare provider: %w", err)
2✔
352
        }
2✔
353

2✔
354
        if regionalServicesConfig.RegionKey != "" {
2✔
355
                regionalServicesConfig.Enabled = true
7✔
356
        }
1✔
357

1✔
358
        return &CloudFlareProvider{
359
                Client:                 zoneService{config, configV4},
6✔
360
                domainFilter:           domainFilter,
1✔
361
                zoneIDFilter:           zoneIDFilter,
1✔
362
                proxiedByDefault:       proxiedByDefault,
363
                CustomHostnamesConfig:  customHostnamesConfig,
5✔
364
                DryRun:                 dryRun,
5✔
365
                RegionalServicesConfig: regionalServicesConfig,
5✔
366
                DNSRecordsConfig:       dnsRecordsConfig,
5✔
367
        }, nil
5✔
368
}
5✔
369

5✔
370
// Zones returns the list of hosted zones.
5✔
371
func (p *CloudFlareProvider) Zones(ctx context.Context) ([]zones.Zone, error) {
5✔
372
        var result []zones.Zone
5✔
373

374
        // if there is a zoneIDfilter configured
375
        // && if the filter isn't just a blank string (used in tests)
376
        if len(p.zoneIDFilter.ZoneIDs) > 0 && p.zoneIDFilter.ZoneIDs[0] != "" {
123✔
377
                log.Debugln("zoneIDFilter configured. only looking up zone IDs defined")
123✔
378
                for _, zoneID := range p.zoneIDFilter.ZoneIDs {
123✔
379
                        log.Debugf("looking up zone %q", zoneID)
123✔
380
                        detailResponse, err := p.Client.GetZone(ctx, zoneID)
123✔
381
                        if err != nil {
130✔
382
                                log.Errorf("zone %q lookup failed, %v", zoneID, err)
7✔
383
                                return result, convertCloudflareError(err)
14✔
384
                        }
7✔
385
                        log.WithFields(log.Fields{
7✔
386
                                "zoneName": detailResponse.Name,
11✔
387
                                "zoneID":   detailResponse.ID,
4✔
388
                        }).Debugln("adding zone for consideration")
4✔
389
                        result = append(result, *detailResponse)
4✔
390
                }
3✔
391
                return result, nil
3✔
392
        }
3✔
393

3✔
394
        log.Debugln("no zoneIDFilter configured, looking at all zones")
3✔
395

396
        params := listZonesV4Params()
3✔
397
        iter := p.Client.ListZones(ctx, params)
398
        for zone := range autoPagerIterator(iter) {
399
                if !p.domainFilter.Match(zone.Name) {
116✔
400
                        log.Debugf("zone %q not in domain filter", zone.Name)
116✔
401
                        continue
116✔
402
                }
116✔
403
                log.WithFields(log.Fields{
318✔
404
                        "zoneName": zone.Name,
205✔
405
                        "zoneID":   zone.ID,
3✔
406
                }).Debugln("adding zone for consideration")
3✔
407
                result = append(result, zone)
408
        }
199✔
409
        if iter.Err() != nil {
199✔
410
                return nil, convertCloudflareError(iter.Err())
199✔
411
        }
199✔
412

199✔
413
        return result, nil
414
}
125✔
415

9✔
416
// Records returns the list of records.
9✔
417
func (p *CloudFlareProvider) Records(ctx context.Context) ([]*endpoint.Endpoint, error) {
418
        zones, err := p.Zones(ctx)
107✔
419
        if err != nil {
420
                return nil, err
421
        }
422

57✔
423
        var endpoints []*endpoint.Endpoint
57✔
424
        for _, zone := range zones {
60✔
425
                records, err := p.getDNSRecordsMap(ctx, zone.ID)
3✔
426
                if err != nil {
3✔
427
                        return nil, err
428
                }
54✔
429

154✔
430
                // nil if custom hostnames are not enabled
100✔
431
                chs, chErr := p.listCustomHostnamesWithPagination(ctx, zone.ID)
102✔
432
                if chErr != nil {
2✔
433
                        return nil, chErr
2✔
434
                }
435

436
                // As CloudFlare does not support "sets" of targets, but instead returns
98✔
437
                // a single entry for each name/type/target, we have to group by name
99✔
438
                // and record to allow the planner to calculate the correct plan. See #992.
1✔
439
                zoneEndpoints := p.groupByNameAndTypeWithCustomHostnames(records, chs)
1✔
440

441
                if err := p.addEnpointsProviderSpecificRegionKeyProperty(ctx, zone.ID, zoneEndpoints); err != nil {
442
                        return nil, err
443
                }
444

97✔
445
                endpoints = append(endpoints, zoneEndpoints...)
97✔
446
        }
98✔
447

1✔
448
        return endpoints, nil
1✔
449
}
450

96✔
451
// ApplyChanges applies a given set of changes in a given zone.
452
func (p *CloudFlareProvider) ApplyChanges(ctx context.Context, changes *plan.Changes) error {
453
        var cloudflareChanges []*cloudFlareChange
50✔
454

455
        // if custom hostnames are enabled, deleting first allows to avoid conflicts with the new ones
456
        if p.CustomHostnamesConfig.Enabled {
457
                for _, e := range changes.Delete {
54✔
458
                        for _, target := range e.Targets {
54✔
459
                                change, err := p.newCloudFlareChange(cloudFlareDelete, e, target, nil)
54✔
460
                                if err != nil {
54✔
461
                                        log.Errorf("failed to create cloudflare change: %v", err)
69✔
462
                                        continue
20✔
463
                                }
10✔
464
                                cloudflareChanges = append(cloudflareChanges, change)
5✔
465
                        }
6✔
466
                }
1✔
467
        }
1✔
468

469
        for _, e := range changes.Create {
4✔
470
                for _, target := range e.Targets {
471
                        change, err := p.newCloudFlareChange(cloudFlareCreate, e, target, nil)
472
                        if err != nil {
473
                                log.Errorf("failed to create cloudflare change: %v", err)
474
                                continue
435✔
475
                        }
766✔
476
                        cloudflareChanges = append(cloudflareChanges, change)
385✔
477
                }
386✔
478
        }
1✔
479

1✔
480
        for i, desired := range changes.UpdateNew {
481
                current := changes.UpdateOld[i]
384✔
482

483
                add, remove, leave := provider.Difference(current.Targets, desired.Targets)
484

485
                for _, a := range remove {
62✔
486
                        change, err := p.newCloudFlareChange(cloudFlareDelete, current, a, current)
8✔
487
                        if err != nil {
8✔
488
                                log.Errorf("failed to create cloudflare change: %v", err)
8✔
489
                                continue
8✔
490
                        }
13✔
491
                        cloudflareChanges = append(cloudflareChanges, change)
5✔
492
                }
6✔
493

1✔
494
                for _, a := range add {
1✔
495
                        change, err := p.newCloudFlareChange(cloudFlareCreate, desired, a, current)
496
                        if err != nil {
4✔
497
                                log.Errorf("failed to create cloudflare change: %v", err)
498
                                continue
499
                        }
13✔
500
                        cloudflareChanges = append(cloudflareChanges, change)
5✔
501
                }
6✔
502

1✔
503
                for _, a := range leave {
1✔
504
                        change, err := p.newCloudFlareChange(cloudFlareUpdate, desired, a, current)
505
                        if err != nil {
4✔
506
                                log.Errorf("failed to create cloudflare change: %v", err)
507
                                continue
508
                        }
12✔
509
                        cloudflareChanges = append(cloudflareChanges, change)
4✔
510
                }
5✔
511
        }
1✔
512

1✔
513
        // TODO: consider deleting before creating even if custom hostnames are not in use
514
        if !p.CustomHostnamesConfig.Enabled {
3✔
515
                for _, e := range changes.Delete {
516
                        for _, target := range e.Targets {
517
                                change, err := p.newCloudFlareChange(cloudFlareDelete, e, target, nil)
518
                                if err != nil {
519
                                        log.Errorf("failed to create cloudflare change: %v", err)
93✔
520
                                        continue
44✔
521
                                }
10✔
522
                                cloudflareChanges = append(cloudflareChanges, change)
5✔
523
                        }
6✔
524
                }
1✔
525
        }
1✔
526

527
        return p.submitChanges(ctx, cloudflareChanges)
4✔
528
}
529

530
// submitCustomHostnameChanges implements Custom Hostname functionality for the Change, returns false if it fails
531
func (p *CloudFlareProvider) submitCustomHostnameChanges(ctx context.Context, zoneID string, change *cloudFlareChange, chs CustomHostnamesMap, logFields log.Fields) bool {
532
        failedChange := false
54✔
533
        // return early if disabled
534
        if !p.CustomHostnamesConfig.Enabled {
535
                return true
536
        }
394✔
537

394✔
538
        switch change.Action {
394✔
539
        case cloudFlareUpdate:
435✔
540
                if recordTypeCustomHostnameSupported[string(change.ResourceRecord.Type)] {
41✔
541
                        add, remove, _ := provider.Difference(change.CustomHostnamesPrev, slices.Collect(maps.Keys(change.CustomHostnames)))
41✔
542

543
                        for _, changeCH := range remove {
353✔
544
                                if prevCh, err := getCustomHostname(chs, changeCH); err == nil {
1✔
545
                                        prevChID := prevCh.ID
2✔
546
                                        if prevChID != "" {
1✔
547
                                                log.WithFields(logFields).Infof("Removing previous custom hostname %q/%q", prevChID, changeCH)
1✔
548
                                                chErr := p.Client.DeleteCustomHostname(ctx, zoneID, prevChID)
1✔
549
                                                if chErr != nil {
×
550
                                                        failedChange = true
×
551
                                                        log.WithFields(logFields).Errorf("failed to remove previous custom hostname %q/%q: %v", prevChID, changeCH, chErr)
×
552
                                                }
×
UNCOV
553
                                        }
×
UNCOV
554
                                }
×
UNCOV
555
                        }
×
UNCOV
556
                        for _, changeCH := range add {
×
557
                                log.WithFields(logFields).Infof("Adding custom hostname %q", changeCH)
×
558
                                _, chErr := p.Client.CreateCustomHostname(ctx, zoneID, change.CustomHostnames[changeCH])
559
                                if chErr != nil {
560
                                        failedChange = true
561
                                        log.WithFields(logFields).Errorf("failed to add custom hostname %q: %v", changeCH, chErr)
1✔
562
                                }
×
UNCOV
563
                        }
×
UNCOV
564
                }
×
UNCOV
565
        case cloudFlareDelete:
×
UNCOV
566
                for _, changeCH := range change.CustomHostnames {
×
UNCOV
567
                        if recordTypeCustomHostnameSupported[string(change.ResourceRecord.Type)] && changeCH.Hostname != "" {
×
568
                                log.WithFields(logFields).Infof("Deleting custom hostname %q", changeCH.Hostname)
569
                                if ch, err := getCustomHostname(chs, changeCH.Hostname); err == nil {
570
                                        chID := ch.ID
4✔
571
                                        chErr := p.Client.DeleteCustomHostname(ctx, zoneID, chID)
5✔
572
                                        if chErr != nil {
2✔
573
                                                failedChange = true
1✔
574
                                                log.WithFields(logFields).Errorf("failed to delete custom hostname %q/%q: %v", chID, changeCH.Hostname, chErr)
1✔
575
                                        }
×
UNCOV
576
                                } else {
×
UNCOV
577
                                        log.WithFields(logFields).Warnf("failed to delete custom hostname %q: %v", changeCH.Hostname, err)
×
UNCOV
578
                                }
×
UNCOV
579
                        }
×
UNCOV
580
                }
×
581
        case cloudFlareCreate:
1✔
582
                for _, changeCH := range change.CustomHostnames {
1✔
583
                        if recordTypeCustomHostnameSupported[string(change.ResourceRecord.Type)] && changeCH.Hostname != "" {
1✔
584
                                log.WithFields(logFields).Infof("Creating custom hostname %q", changeCH.Hostname)
585
                                if ch, err := getCustomHostname(chs, changeCH.Hostname); err == nil {
586
                                        if changeCH.CustomOriginServer == ch.CustomOriginServer {
348✔
587
                                                log.WithFields(logFields).Warnf("custom hostname %q already exists with the same origin %q, continue", changeCH.Hostname, ch.CustomOriginServer)
692✔
588
                                        } else {
688✔
589
                                                failedChange = true
344✔
590
                                                log.WithFields(logFields).Errorf("failed to create custom hostname, %q already exists with origin %q", changeCH.Hostname, ch.CustomOriginServer)
345✔
591
                                        }
2✔
592
                                } else {
1✔
593
                                        _, chErr := p.Client.CreateCustomHostname(ctx, zoneID, changeCH)
1✔
UNCOV
594
                                        if chErr != nil {
×
595
                                                failedChange = true
×
596
                                                log.WithFields(logFields).Errorf("failed to create custom hostname %q: %v", changeCH.Hostname, chErr)
×
597
                                        }
343✔
598
                                }
343✔
599
                        }
343✔
UNCOV
600
                }
×
UNCOV
601
        }
×
UNCOV
602
        return !failedChange
×
603
}
604

605
// submitChanges takes a zone and a collection of Changes and sends them as a single transaction.
606
func (p *CloudFlareProvider) submitChanges(ctx context.Context, changes []*cloudFlareChange) error {
607
        // return early if there is nothing to change
353✔
608
        if len(changes) == 0 {
609
                log.Info("All records are already up to date")
610
                return nil
611
        }
56✔
612

56✔
613
        zones, err := p.Zones(ctx)
65✔
614
        if err != nil {
9✔
615
                return err
9✔
616
        }
9✔
617
        // separate into per-zone change sets to be passed to the API.
618
        changesByZone := p.changesByZone(zones, changes)
47✔
619

47✔
UNCOV
620
        var failedZones []string
×
UNCOV
621
        for zoneID, zoneChanges := range changesByZone {
×
622
                var failedChange bool
623

47✔
624
                for _, change := range zoneChanges {
47✔
625
                        logFields := log.Fields{
47✔
626
                                "record": change.ResourceRecord.Name,
134✔
627
                                "type":   change.ResourceRecord.Type,
87✔
628
                                "ttl":    change.ResourceRecord.TTL,
87✔
629
                                "action": change.Action.String(),
489✔
630
                                "zone":   zoneID,
402✔
631
                        }
402✔
632

402✔
633
                        log.WithFields(logFields).Info("Changing record.")
402✔
634

402✔
635
                        if p.DryRun {
402✔
636
                                continue
402✔
637
                        }
402✔
638

402✔
639
                        records, err := p.getDNSRecordsMap(ctx, zoneID)
402✔
640
                        if err != nil {
407✔
641
                                return fmt.Errorf("could not fetch records from zone, %w", err)
5✔
642
                        }
643
                        chs, chErr := p.listCustomHostnamesWithPagination(ctx, zoneID)
644
                        if chErr != nil {
397✔
645
                                return fmt.Errorf("could not fetch custom hostnames from zone, %w", chErr)
397✔
646
                        }
×
UNCOV
647
                        if change.Action == cloudFlareUpdate {
×
648
                                if !p.submitCustomHostnameChanges(ctx, zoneID, change, chs, logFields) {
397✔
649
                                        failedChange = true
397✔
650
                                }
×
UNCOV
651
                                recordID := p.getRecordID(records, change.ResourceRecord)
×
652
                                if recordID == "" {
400✔
653
                                        log.WithFields(logFields).Errorf("failed to find previous record: %v", change.ResourceRecord)
3✔
654
                                        continue
×
UNCOV
655
                                }
×
656
                                recordParam := getUpdateDNSRecordParam(zoneID, *change)
3✔
657
                                _, err := p.Client.UpdateDNSRecord(ctx, recordID, recordParam)
3✔
UNCOV
658
                                if err != nil {
×
UNCOV
659
                                        failedChange = true
×
660
                                        log.WithFields(logFields).Errorf("failed to update record: %v", err)
661
                                }
3✔
662
                        } else if change.Action == cloudFlareDelete {
3✔
663
                                recordID := p.getRecordID(records, change.ResourceRecord)
4✔
664
                                if recordID == "" {
1✔
665
                                        log.WithFields(logFields).Errorf("failed to find previous record: %v", change.ResourceRecord)
1✔
666
                                        continue
1✔
667
                                }
404✔
668
                                err := p.Client.DeleteDNSRecord(ctx, recordID, dns.RecordDeleteParams{ZoneID: cloudflare.F(zoneID)})
10✔
669
                                if err != nil {
13✔
670
                                        failedChange = true
3✔
671
                                        log.WithFields(logFields).Errorf("failed to delete record: %v", err)
3✔
672
                                }
673
                                if !p.submitCustomHostnameChanges(ctx, zoneID, change, chs, logFields) {
7✔
674
                                        failedChange = true
8✔
675
                                }
1✔
676
                        } else if change.Action == cloudFlareCreate {
1✔
677
                                recordParam := getCreateDNSRecordParam(zoneID, change)
1✔
678
                                _, err := p.Client.CreateDNSRecord(ctx, recordParam)
7✔
UNCOV
679
                                if err != nil {
×
UNCOV
680
                                        failedChange = true
×
681
                                        log.WithFields(logFields).Errorf("failed to create record: %v", err)
768✔
682
                                }
384✔
683
                                if !p.submitCustomHostnameChanges(ctx, zoneID, change, chs, logFields) {
384✔
684
                                        failedChange = true
386✔
685
                                }
2✔
686
                        }
2✔
687
                }
2✔
688

384✔
UNCOV
689
                if p.RegionalServicesConfig.Enabled {
×
UNCOV
690
                        desiredRegionalHostnames, err := desiredRegionalHostnames(zoneChanges)
×
691
                        if err != nil {
692
                                return fmt.Errorf("failed to build desired regional hostnames: %w", err)
693
                        }
694
                        if len(desiredRegionalHostnames) > 0 {
104✔
695
                                regionalHostnames, err := p.listDataLocalisationRegionalHostnames(ctx, zoneID)
17✔
696
                                if err != nil {
18✔
697
                                        return fmt.Errorf("could not fetch regional hostnames from zone, %w", err)
1✔
698
                                }
1✔
699
                                regionalHostnamesChanges := regionalHostnamesChanges(desiredRegionalHostnames, regionalHostnames)
27✔
700
                                if !p.submitRegionalHostnameChanges(ctx, zoneID, regionalHostnamesChanges) {
11✔
701
                                        failedChange = true
12✔
702
                                }
1✔
703
                        }
1✔
704
                }
10✔
705

13✔
706
                if failedChange {
3✔
707
                        failedZones = append(failedZones, zoneID)
3✔
708
                }
709
        }
710

711
        if len(failedZones) > 0 {
92✔
712
                return fmt.Errorf("failed to submit all changes for the following zones: %q", failedZones)
7✔
713
        }
7✔
714

715
        return nil
716
}
52✔
717

7✔
718
// AdjustEndpoints modifies the endpoints as needed by the specific provider
7✔
719
func (p *CloudFlareProvider) AdjustEndpoints(endpoints []*endpoint.Endpoint) ([]*endpoint.Endpoint, error) {
720
        var adjustedEndpoints []*endpoint.Endpoint
38✔
721
        for _, e := range endpoints {
722
                proxied := shouldBeProxied(e, p.proxiedByDefault)
723
                if proxied {
724
                        e.RecordTTL = 0
54✔
725
                }
54✔
726
                e.SetProviderSpecificProperty(annotations.CloudflareProxiedKey, strconv.FormatBool(proxied))
449✔
727

395✔
728
                if p.CustomHostnamesConfig.Enabled {
406✔
729
                        // sort custom hostnames in annotation to properly detect changes
11✔
730
                        if customHostnames := getEndpointCustomHostnames(e); len(customHostnames) > 1 {
11✔
731
                                sort.Strings(customHostnames)
395✔
732
                                e.SetProviderSpecificProperty(annotations.CloudflareCustomHostnameKey, strings.Join(customHostnames, ","))
395✔
733
                        }
744✔
734
                } else {
349✔
735
                        // ignore custom hostnames annotations if not enabled
349✔
UNCOV
736
                        e.DeleteProviderSpecificProperty(annotations.CloudflareCustomHostnameKey)
×
UNCOV
737
                }
×
UNCOV
738

×
739
                p.adjustEndpointProviderSpecificRegionKeyProperty(e)
46✔
740

46✔
741
                if p.DNSRecordsConfig.Comment != "" {
46✔
742
                        if _, found := e.GetProviderSpecificProperty(annotations.CloudflareRecordCommentKey); !found {
46✔
743
                                e.SetProviderSpecificProperty(annotations.CloudflareRecordCommentKey, p.DNSRecordsConfig.Comment)
744
                        }
395✔
745
                }
395✔
746

397✔
747
                adjustedEndpoints = append(adjustedEndpoints, e)
4✔
748
        }
2✔
749
        return adjustedEndpoints, nil
2✔
750
}
751

752
// changesByZone separates a multi-zone change into a single change per zone.
395✔
753
func (p *CloudFlareProvider) changesByZone(zones []zones.Zone, changeSet []*cloudFlareChange) map[string][]*cloudFlareChange {
754
        changes := make(map[string][]*cloudFlareChange)
54✔
755
        zoneNameIDMapper := provider.ZoneIDName{}
756

757
        for _, z := range zones {
758
                zoneNameIDMapper.Add(z.ID, z.Name)
50✔
759
                changes[z.ID] = []*cloudFlareChange{}
50✔
760
        }
50✔
761

50✔
762
        for _, c := range changeSet {
144✔
763
                zoneID, _ := zoneNameIDMapper.FindZone(c.ResourceRecord.Name)
94✔
764
                if zoneID == "" {
94✔
765
                        log.Debugf("Skipping record %q because no hosted zone matching record DNS Name was detected", c.ResourceRecord.Name)
94✔
766
                        continue
767
                }
464✔
768
                changes[zoneID] = append(changes[zoneID], c)
414✔
769
        }
419✔
770

5✔
771
        return changes
5✔
772
}
773

409✔
774
func (p *CloudFlareProvider) getRecordID(records DNSRecordsMap, record dns.RecordResponse) string {
775
        if zoneRecord, ok := records[DNSRecordIndex{Name: record.Name, Type: string(record.Type), Content: record.Content}]; ok {
776
                return zoneRecord.ID
50✔
777
        }
778
        return ""
779
}
18✔
780

30✔
781
func getCustomHostname(chs CustomHostnamesMap, chName string) (cloudflarev0.CustomHostname, error) {
12✔
782
        if chName == "" {
12✔
783
                return cloudflarev0.CustomHostname{}, fmt.Errorf("failed to get custom hostname: %q is empty", chName)
6✔
784
        }
785
        if ch, ok := chs[CustomHostnameIndex{Hostname: chName}]; ok {
786
                return ch, nil
346✔
787
        }
346✔
UNCOV
788
        return cloudflarev0.CustomHostname{}, fmt.Errorf("failed to get custom hostname: %q not found", chName)
×
UNCOV
789
}
×
790

348✔
791
func (p *CloudFlareProvider) newCustomHostname(customHostname string, origin string) cloudflarev0.CustomHostname {
2✔
792
        return cloudflarev0.CustomHostname{
2✔
793
                Hostname:           customHostname,
344✔
794
                CustomOriginServer: origin,
795
                SSL:                getCustomHostnamesSSLOptions(p.CustomHostnamesConfig),
796
        }
350✔
797
}
350✔
798

350✔
799
func (p *CloudFlareProvider) newCloudFlareChange(action changeAction, ep *endpoint.Endpoint, target string, current *endpoint.Endpoint) (*cloudFlareChange, error) {
350✔
800
        ttl := dns.TTL(defaultTTL)
350✔
801
        proxied := shouldBeProxied(ep, p.proxiedByDefault)
350✔
802

350✔
803
        if ep.RecordTTL.IsConfigured() {
804
                ttl = dns.TTL(ep.RecordTTL)
414✔
805
        }
414✔
806

414✔
807
        prevCustomHostnames := []string{}
414✔
808
        newCustomHostnames := map[string]cloudflarev0.CustomHostname{}
773✔
809
        if p.CustomHostnamesConfig.Enabled {
359✔
810
                if current != nil {
359✔
811
                        prevCustomHostnames = getEndpointCustomHostnames(current)
812
                }
414✔
813
                for _, v := range getEndpointCustomHostnames(ep) {
414✔
814
                        newCustomHostnames[v] = p.newCustomHostname(v, ep.DNSName)
772✔
815
                }
362✔
816
        }
4✔
817

4✔
818
        // Load comment from program flag
708✔
819
        comment := p.DNSRecordsConfig.Comment
350✔
820
        if val, ok := ep.GetProviderSpecificProperty(annotations.CloudflareRecordCommentKey); ok {
350✔
821
                // Replace comment with Ingress annotation
822
                comment = val
823
        }
824

414✔
825
        if len(comment) > freeZoneMaxCommentLength {
419✔
826
                comment = p.DNSRecordsConfig.trimAndValidateComment(ep.DNSName, comment, p.ZoneHasPaidPlan)
5✔
827
        }
5✔
828

5✔
829
        var priority float64
830
        if ep.RecordType == "MX" {
417✔
831
                mxRecord, err := endpoint.NewMXRecord(target)
3✔
832
                if err != nil {
3✔
833
                        return &cloudFlareChange{}, fmt.Errorf("failed to parse MX record target %q: %w", target, err)
834
                } else {
414✔
835
                        priority = float64(*mxRecord.GetPriority())
423✔
836
                        target = *mxRecord.GetHost()
9✔
837
                }
15✔
838
        }
6✔
839

9✔
840
        return &cloudFlareChange{
3✔
841
                Action: action,
3✔
842
                ResourceRecord: dns.RecordResponse{
3✔
843
                        Name:     ep.DNSName,
844
                        TTL:      ttl,
845
                        Proxied:  proxied,
408✔
846
                        Type:     dns.RecordResponseType(ep.RecordType),
408✔
847
                        Content:  target,
408✔
848
                        Comment:  comment,
408✔
849
                        Priority: priority,
408✔
850
                },
408✔
851
                RegionalHostname:    p.regionalHostname(ep),
408✔
852
                CustomHostnamesPrev: prevCustomHostnames,
408✔
853
                CustomHostnames:     newCustomHostnames,
408✔
854
        }, nil
408✔
855
}
408✔
856

408✔
857
func newDNSRecordIndex(r dns.RecordResponse) DNSRecordIndex {
408✔
858
        return DNSRecordIndex{Name: r.Name, Type: string(r.Type), Content: r.Content}
408✔
859
}
408✔
860

861
// getDNSRecordsMap retrieves all DNS records for a given zone and returns them as a DNSRecordsMap.
862
func (p *CloudFlareProvider) getDNSRecordsMap(ctx context.Context, zoneID string) (DNSRecordsMap, error) {
58,385✔
863
        // for faster getRecordID lookup
58,385✔
864
        recordsMap := make(DNSRecordsMap)
58,385✔
865
        params := dns.RecordListParams{ZoneID: cloudflare.F(zoneID)}
866
        iter := p.Client.ListDNSRecords(ctx, params)
867
        for record := range autoPagerIterator(iter) {
498✔
868
                recordsMap[newDNSRecordIndex(record)] = record
498✔
869
        }
498✔
870
        if iter.Err() != nil {
498✔
871
                return nil, convertCloudflareError(iter.Err())
498✔
872
        }
58,870✔
873
        return recordsMap, nil
58,372✔
874
}
58,372✔
875

500✔
876
func newCustomHostnameIndex(ch cloudflarev0.CustomHostname) CustomHostnameIndex {
2✔
877
        return CustomHostnameIndex{Hostname: ch.Hostname}
2✔
878
}
496✔
879

880
// listCustomHostnamesWithPagination performs automatic pagination of results on requests to cloudflare.CustomHostnames
881
func (p *CloudFlareProvider) listCustomHostnamesWithPagination(ctx context.Context, zoneID string) (CustomHostnamesMap, error) {
58,664✔
882
        if !p.CustomHostnamesConfig.Enabled {
58,664✔
883
                return nil, nil
58,664✔
884
        }
885
        chs := make(CustomHostnamesMap)
886
        resultInfo := cloudflarev0.ResultInfo{Page: 1}
500✔
887
        for {
617✔
888
                pageCustomHostnameListResponse, result, err := p.Client.CustomHostnames(ctx, zoneID, resultInfo.Page, cloudflarev0.CustomHostname{})
117✔
889
                if err != nil {
117✔
890
                        convertedError := convertCloudflareError(err)
383✔
891
                        if !errors.Is(convertedError, provider.SoftError) {
383✔
892
                                log.Errorf("zone %q failed to fetch custom hostnames. Please check if \"Cloudflare for SaaS\" is enabled and API key permissions, %v", zoneID, err)
4,839✔
893
                        }
4,456✔
894
                        return nil, convertedError
4,457✔
895
                }
1✔
896
                for _, ch := range pageCustomHostnameListResponse {
2✔
897
                        chs[newCustomHostnameIndex(ch)] = ch
1✔
898
                }
1✔
899
                resultInfo = result.Next()
1✔
900
                if resultInfo.Done() {
901
                        break
63,119✔
902
                }
58,664✔
903
        }
58,664✔
904
        return chs, nil
4,455✔
905
}
4,837✔
906

382✔
907
func getCustomHostnamesSSLOptions(customHostnamesConfig CustomHostnamesConfig) *cloudflarev0.CustomHostnameSSL {
908
        ssl := &cloudflarev0.CustomHostnameSSL{
909
                Type:         "dv",
382✔
910
                Method:       "http",
911
                BundleMethod: "ubiquitous",
912
                Settings: cloudflarev0.CustomHostnameSSLSettings{
350✔
913
                        MinTLSVersion: customHostnamesConfig.MinTLSVersion,
350✔
914
                },
350✔
915
        }
350✔
916
        // Set CertificateAuthority if provided
350✔
917
        // We're not able to set it at all (even with a blank) if you're not on an enterprise plan
350✔
918
        if customHostnamesConfig.CertificateAuthority != "none" {
350✔
919
                ssl.CertificateAuthority = customHostnamesConfig.CertificateAuthority
350✔
920
        }
350✔
921
        return ssl
350✔
922
}
350✔
923

700✔
924
func shouldBeProxied(ep *endpoint.Endpoint, proxiedByDefault bool) bool {
350✔
925
        proxied := proxiedByDefault
350✔
926

350✔
927
        for _, v := range ep.ProviderSpecific {
928
                if v.Name == annotations.CloudflareProxiedKey {
929
                        b, err := strconv.ParseBool(v.Value)
809✔
930
                        if err != nil {
809✔
931
                                log.Errorf("Failed to parse annotation [%q]: %v", annotations.CloudflareProxiedKey, err)
809✔
932
                        } else {
1,926✔
933
                                proxied = b
1,516✔
934
                        }
399✔
935
                        break
400✔
936
                }
1✔
937
        }
399✔
938

398✔
939
        if recordTypeProxyNotSupported[ep.RecordType] {
398✔
940
                proxied = false
399✔
941
        }
942
        return proxied
943
}
944

832✔
945
func getEndpointCustomHostnames(ep *endpoint.Endpoint) []string {
23✔
946
        for _, v := range ep.ProviderSpecific {
23✔
947
                if v.Name == annotations.CloudflareCustomHostnameKey {
809✔
948
                        customHostnames := strings.Split(v.Value, ",")
949
                        return customHostnames
950
                }
711✔
951
        }
1,423✔
952
        return []string{}
1,409✔
953
}
697✔
954

697✔
955
func (p *CloudFlareProvider) groupByNameAndTypeWithCustomHostnames(records DNSRecordsMap, chs CustomHostnamesMap) []*endpoint.Endpoint {
697✔
956
        var endpoints []*endpoint.Endpoint
957

14✔
958
        // group supported records by name and type
959
        groups := map[string][]dns.RecordResponse{}
960

104✔
961
        for _, r := range records {
104✔
962
                if !p.SupportedAdditionalRecordTypes(string(r.Type)) {
104✔
963
                        continue
104✔
964
                }
104✔
965

104✔
966
                groupBy := r.Name + string(r.Type)
148✔
967
                if _, ok := groups[groupBy]; !ok {
45✔
968
                        groups[groupBy] = []dns.RecordResponse{}
1✔
969
                }
970

971
                groups[groupBy] = append(groups[groupBy], r)
43✔
972
        }
78✔
973

35✔
974
        // map custom origin to custom hostname, custom origin should match to a dns record
35✔
975
        customHostnames := map[string][]string{}
976

43✔
977
        for _, c := range chs {
978
                customHostnames[c.CustomOriginServer] = append(customHostnames[c.CustomOriginServer], c.Hostname)
979
        }
980

104✔
981
        // create a single endpoint with all the targets for each name/type
104✔
982
        for _, records := range groups {
108✔
983
                if len(records) == 0 {
4✔
984
                        return endpoints
4✔
985
                }
986
                targets := make([]string, len(records))
987
                for i, record := range records {
139✔
988
                        if records[i].Type == "MX" {
35✔
UNCOV
989
                                targets[i] = fmt.Sprintf("%v %v", record.Priority, record.Content)
×
UNCOV
990
                        } else {
×
991
                                targets[i] = record.Content
35✔
992
                        }
78✔
993
                }
45✔
994
                e := endpoint.NewEndpointWithTTL(
2✔
995
                        records[0].Name,
43✔
996
                        string(records[0].Type),
41✔
997
                        endpoint.TTL(records[0].TTL),
41✔
998
                        targets...)
999
                proxied := records[0].Proxied
35✔
1000
                if e == nil {
35✔
1001
                        continue
35✔
1002
                }
35✔
1003
                e = e.WithProviderSpecific(annotations.CloudflareProxiedKey, strconv.FormatBool(proxied))
35✔
1004
                // noop (customHostnames is empty) if custom hostnames feature is not in use
35✔
1005
                if customHostnames, ok := customHostnames[records[0].Name]; ok {
36✔
1006
                        sort.Strings(customHostnames)
1✔
1007
                        e = e.WithProviderSpecific(annotations.CloudflareCustomHostnameKey, strings.Join(customHostnames, ","))
1008
                }
34✔
1009

34✔
1010
                if records[0].Comment != "" {
35✔
1011
                        e = e.WithProviderSpecific(annotations.CloudflareRecordCommentKey, records[0].Comment)
1✔
1012
                }
1✔
1013

1✔
1014
                endpoints = append(endpoints, e)
1015
        }
38✔
1016
        return endpoints
4✔
1017
}
4✔
1018

1019
// SupportedRecordType returns true if the record type is supported by the provider
34✔
1020
func (p *CloudFlareProvider) SupportedAdditionalRecordTypes(recordType string) bool {
1021
        switch recordType {
104✔
1022
        case endpoint.RecordTypeMX:
1023
                return true
1024
        default:
1025
                return provider.SupportedRecordType(recordType)
53✔
1026
        }
53✔
1027
}
3✔
1028

3✔
1029
func dnsRecordResponseFromLegacyDNSRecord(record cloudflarev0.DNSRecord) dns.RecordResponse {
50✔
1030
        var priority float64
50✔
1031
        if record.Priority != nil {
1032
                priority = float64(*record.Priority)
1033
        }
1034

3✔
1035
        return dns.RecordResponse{
3✔
1036
                CreatedOn:  record.CreatedOn,
4✔
1037
                ModifiedOn: record.ModifiedOn,
1✔
1038
                Type:       dns.RecordResponseType(record.Type),
1✔
1039
                Name:       record.Name,
1040
                Content:    record.Content,
3✔
1041
                Meta:       record.Meta,
3✔
1042
                Data:       record.Data,
3✔
1043
                ID:         record.ID,
3✔
1044
                Priority:   priority,
3✔
1045
                TTL:        dns.TTL(record.TTL),
3✔
1046
                Proxied:    record.Proxied != nil && *record.Proxied,
3✔
1047
                Proxiable:  record.Proxiable,
3✔
1048
                Comment:    record.Comment,
3✔
1049
                Tags:       record.Tags,
3✔
1050
        }
3✔
1051
}
3✔
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2025 Coveralls, Inc