From e2b56049f7d1b826b44c449aa3a4bf2364907d19 Mon Sep 17 00:00:00 2001 From: vflaux <38909103+vflaux@users.noreply.github.com> Date: Fri, 11 Jul 2025 18:47:36 +0200 Subject: [PATCH] chore(cloudflare): use lib v4 for regional services (#5609) * chore(cloudflare): add cloudflare v4 client * chrome(cloudflare): cli v4 for regional hostanmes --- go.mod | 5 + go.sum | 12 +++ provider/cloudflare/cloudflare.go | 32 +++++-- provider/cloudflare/cloudflare_regional.go | 89 +++++++++++------- .../cloudflare/cloudflare_regional_test.go | 42 +++++---- provider/cloudflare/pagination.go | 34 +++++++ provider/cloudflare/pagination_test.go | 94 +++++++++++++++++++ 7 files changed, 246 insertions(+), 62 deletions(-) create mode 100644 provider/cloudflare/pagination.go create mode 100644 provider/cloudflare/pagination_test.go diff --git a/go.mod b/go.mod index 77e12a811..a56ae2817 100644 --- a/go.mod +++ b/go.mod @@ -25,6 +25,7 @@ require ( github.com/cenkalti/backoff/v5 v5.0.2 github.com/civo/civogo v0.6.1 github.com/cloudflare/cloudflare-go v0.115.0 + github.com/cloudflare/cloudflare-go/v4 v4.5.1 github.com/cloudfoundry-community/go-cfclient v0.0.0-20190201205600-f136f9222381 github.com/datawire/ambassador v1.12.4 github.com/denverdino/aliyungo v0.0.0-20230411124812-ab98a9173ace @@ -162,6 +163,10 @@ require ( github.com/sosodev/duration v1.3.1 // indirect github.com/spf13/pflag v1.0.6 // indirect github.com/stretchr/objx v0.5.2 // indirect + github.com/tidwall/gjson v1.14.4 // indirect + github.com/tidwall/match v1.1.1 // indirect + github.com/tidwall/pretty v1.2.1 // indirect + github.com/tidwall/sjson v1.2.5 // indirect github.com/vektah/gqlparser/v2 v2.5.26 // indirect github.com/x448/float16 v0.8.4 // indirect github.com/xhit/go-str2duration/v2 v2.1.0 // indirect diff --git a/go.sum b/go.sum index 9d8b33e67..0dcef98ed 100644 --- a/go.sum +++ b/go.sum @@ -186,6 +186,8 @@ github.com/clbanning/x2j v0.0.0-20191024224557-825249438eec/go.mod h1:jMjuTZXRI4 github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw= github.com/cloudflare/cloudflare-go v0.115.0 h1:84/dxeeXweCc0PN5Cto44iTA8AkG1fyT11yPO5ZB7sM= github.com/cloudflare/cloudflare-go v0.115.0/go.mod h1:Ds6urDwn/TF2uIU24mu7H91xkKP8gSAHxQ44DSZgVmU= +github.com/cloudflare/cloudflare-go/v4 v4.5.1 h1:ZQgQ7QO+M9rK0KYx1CmppuG15ZTYGHn8F9/Fh7mCuQQ= +github.com/cloudflare/cloudflare-go/v4 v4.5.1/go.mod h1:XcYpLe7Mf6FN87kXzEWVnJ6z+vskW/k6eUqgqfhFE9k= github.com/cloudfoundry-community/go-cfclient v0.0.0-20190201205600-f136f9222381 h1:rdRS5BT13Iae9ssvcslol66gfOOXjaLYwqerEn/cl9s= github.com/cloudfoundry-community/go-cfclient v0.0.0-20190201205600-f136f9222381/go.mod h1:e5+USP2j8Le2M0Jo3qKPFnNhuo1wueU4nWHCXBOfQ14= github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc= @@ -990,7 +992,17 @@ github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXl github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= github.com/syndtr/gocapability v0.0.0-20170704070218-db04d3cc01c8/go.mod h1:hkRG7XYTFWNJGYcbNJQlaLq0fg1yr4J4t/NcTQtrfww= +github.com/tidwall/gjson v1.14.2/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= +github.com/tidwall/gjson v1.14.4 h1:uo0p8EbA09J7RQaflQ1aBRffTR7xedD2bcIVSYxLnkM= +github.com/tidwall/gjson v1.14.4/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= +github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA= +github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM= github.com/tidwall/pretty v1.0.0/go.mod h1:XNkn88O1ChpSDQmQeStsy+sBenx6DDtFZJxhVysOjyk= +github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU= +github.com/tidwall/pretty v1.2.1 h1:qjsOFOWWQl+N3RsoF5/ssm1pHmJJwhjlSbZ51I6wMl4= +github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU= +github.com/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY= +github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28= github.com/tmc/grpc-websocket-proxy v0.0.0-20170815181823-89b8d40f7ca8/go.mod h1:ncp9v5uamzpCO7NfCPTXjqaC+bZgJeR0sMTm6dMHP7U= github.com/tmc/grpc-websocket-proxy v0.0.0-20190109142713-0ad062ec5ee5/go.mod h1:ncp9v5uamzpCO7NfCPTXjqaC+bZgJeR0sMTm6dMHP7U= github.com/transip/gotransip/v6 v6.26.0 h1:Aejfvh8rSp8Mj2GX/RpdBjMCv+Iy/DmgfNgczPDP550= diff --git a/provider/cloudflare/cloudflare.go b/provider/cloudflare/cloudflare.go index 930370089..21d4a945c 100644 --- a/provider/cloudflare/cloudflare.go +++ b/provider/cloudflare/cloudflare.go @@ -29,6 +29,9 @@ import ( "strings" "github.com/cloudflare/cloudflare-go" + cloudflarev4 "github.com/cloudflare/cloudflare-go/v4" + "github.com/cloudflare/cloudflare-go/v4/addressing" + "github.com/cloudflare/cloudflare-go/v4/option" log "github.com/sirupsen/logrus" "golang.org/x/net/publicsuffix" @@ -109,17 +112,18 @@ type cloudFlareDNS interface { CreateDNSRecord(ctx context.Context, rc *cloudflare.ResourceContainer, rp cloudflare.CreateDNSRecordParams) (cloudflare.DNSRecord, error) DeleteDNSRecord(ctx context.Context, rc *cloudflare.ResourceContainer, recordID string) error UpdateDNSRecord(ctx context.Context, rc *cloudflare.ResourceContainer, rp cloudflare.UpdateDNSRecordParams) error - ListDataLocalizationRegionalHostnames(ctx context.Context, rc *cloudflare.ResourceContainer, rp cloudflare.ListDataLocalizationRegionalHostnamesParams) ([]cloudflare.RegionalHostname, error) - CreateDataLocalizationRegionalHostname(ctx context.Context, rc *cloudflare.ResourceContainer, rp cloudflare.CreateDataLocalizationRegionalHostnameParams) error - UpdateDataLocalizationRegionalHostname(ctx context.Context, rc *cloudflare.ResourceContainer, rp cloudflare.UpdateDataLocalizationRegionalHostnameParams) error - DeleteDataLocalizationRegionalHostname(ctx context.Context, rc *cloudflare.ResourceContainer, hostname string) error + ListDataLocalizationRegionalHostnames(ctx context.Context, params addressing.RegionalHostnameListParams) autoPager[addressing.RegionalHostnameListResponse] + CreateDataLocalizationRegionalHostname(ctx context.Context, params addressing.RegionalHostnameNewParams) error + UpdateDataLocalizationRegionalHostname(ctx context.Context, hostname string, params addressing.RegionalHostnameEditParams) error + DeleteDataLocalizationRegionalHostname(ctx context.Context, hostname string, params addressing.RegionalHostnameDeleteParams) error CustomHostnames(ctx context.Context, zoneID string, page int, filter cloudflare.CustomHostname) ([]cloudflare.CustomHostname, cloudflare.ResultInfo, error) DeleteCustomHostname(ctx context.Context, zoneID string, customHostnameID string) error CreateCustomHostname(ctx context.Context, zoneID string, ch cloudflare.CustomHostname) (*cloudflare.CustomHostnameResponse, error) } type zoneService struct { - service *cloudflare.API + service *cloudflare.API + serviceV4 *cloudflarev4.Client } func (z zoneService) ZoneIDByName(zoneName string) (string, error) { @@ -287,8 +291,9 @@ func NewCloudFlareProvider( ) (*CloudFlareProvider, error) { // initialize via chosen auth method and returns new API object var ( - config *cloudflare.API - err error + config *cloudflare.API + configV4 *cloudflarev4.Client + err error ) if os.Getenv("CF_API_TOKEN") != "" { token := os.Getenv("CF_API_TOKEN") @@ -300,8 +305,15 @@ func NewCloudFlareProvider( token = strings.TrimSpace(string(tokenBytes)) } config, err = cloudflare.NewWithAPIToken(token) + configV4 = cloudflarev4.NewClient( + option.WithAPIToken(token), + ) } else { config, err = cloudflare.New(os.Getenv("CF_API_KEY"), os.Getenv("CF_API_EMAIL")) + configV4 = cloudflarev4.NewClient( + option.WithAPIKey(os.Getenv("CF_API_KEY")), + option.WithAPIEmail(os.Getenv("CF_API_EMAIL")), + ) } if err != nil { return nil, fmt.Errorf("failed to initialize cloudflare provider: %w", err) @@ -312,7 +324,7 @@ func NewCloudFlareProvider( } return &CloudFlareProvider{ - Client: zoneService{config}, + Client: zoneService{config, configV4}, domainFilter: domainFilter, zoneIDFilter: zoneIDFilter, proxiedByDefault: proxiedByDefault, @@ -646,12 +658,12 @@ func (p *CloudFlareProvider) submitChanges(ctx context.Context, changes []*cloud return fmt.Errorf("failed to build desired regional hostnames: %w", err) } if len(desiredRegionalHostnames) > 0 { - regionalHostnames, err := p.listDataLocalisationRegionalHostnames(ctx, resourceContainer) + 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, regionalHostnamesChanges, resourceContainer) { + if !p.submitRegionalHostnameChanges(ctx, zoneID, regionalHostnamesChanges) { failedChange = true } } diff --git a/provider/cloudflare/cloudflare_regional.go b/provider/cloudflare/cloudflare_regional.go index 9b7b9f988..9502bcc92 100644 --- a/provider/cloudflare/cloudflare_regional.go +++ b/provider/cloudflare/cloudflare_regional.go @@ -22,7 +22,9 @@ import ( "maps" "slices" - cloudflare "github.com/cloudflare/cloudflare-go" + cloudflarev4 "github.com/cloudflare/cloudflare-go/v4" + "github.com/cloudflare/cloudflare-go/v4/addressing" + log "github.com/sirupsen/logrus" "sigs.k8s.io/external-dns/endpoint" @@ -53,46 +55,62 @@ type regionalHostnameChange struct { regionalHostname } -func (z zoneService) ListDataLocalizationRegionalHostnames(ctx context.Context, rc *cloudflare.ResourceContainer, rp cloudflare.ListDataLocalizationRegionalHostnamesParams) ([]cloudflare.RegionalHostname, error) { - return z.service.ListDataLocalizationRegionalHostnames(ctx, rc, rp) +func (z zoneService) ListDataLocalizationRegionalHostnames(ctx context.Context, params addressing.RegionalHostnameListParams) autoPager[addressing.RegionalHostnameListResponse] { + return z.serviceV4.Addressing.RegionalHostnames.ListAutoPaging(ctx, params) } -func (z zoneService) CreateDataLocalizationRegionalHostname(ctx context.Context, rc *cloudflare.ResourceContainer, rp cloudflare.CreateDataLocalizationRegionalHostnameParams) error { - _, err := z.service.CreateDataLocalizationRegionalHostname(ctx, rc, rp) +func (z zoneService) CreateDataLocalizationRegionalHostname(ctx context.Context, params addressing.RegionalHostnameNewParams) error { + _, err := z.serviceV4.Addressing.RegionalHostnames.New(ctx, params) return err } -func (z zoneService) UpdateDataLocalizationRegionalHostname(ctx context.Context, rc *cloudflare.ResourceContainer, rp cloudflare.UpdateDataLocalizationRegionalHostnameParams) error { - _, err := z.service.UpdateDataLocalizationRegionalHostname(ctx, rc, rp) +func (z zoneService) UpdateDataLocalizationRegionalHostname(ctx context.Context, hostname string, params addressing.RegionalHostnameEditParams) error { + _, err := z.serviceV4.Addressing.RegionalHostnames.Edit(ctx, hostname, params) return err } -func (z zoneService) DeleteDataLocalizationRegionalHostname(ctx context.Context, rc *cloudflare.ResourceContainer, hostname string) error { - return z.service.DeleteDataLocalizationRegionalHostname(ctx, rc, hostname) +func (z zoneService) DeleteDataLocalizationRegionalHostname(ctx context.Context, hostname string, params addressing.RegionalHostnameDeleteParams) error { + _, err := z.serviceV4.Addressing.RegionalHostnames.Delete(ctx, hostname, params) + return err +} + +// listDataLocalizationRegionalHostnamesParams is a function that returns the appropriate RegionalHostname List Param based on the zoneID +func listDataLocalizationRegionalHostnamesParams(zoneID string) addressing.RegionalHostnameListParams { + return addressing.RegionalHostnameListParams{ + ZoneID: cloudflarev4.F(zoneID), + } } // createDataLocalizationRegionalHostnameParams is a function that returns the appropriate RegionalHostname Param based on the cloudFlareChange passed in -func createDataLocalizationRegionalHostnameParams(rhc regionalHostnameChange) cloudflare.CreateDataLocalizationRegionalHostnameParams { - return cloudflare.CreateDataLocalizationRegionalHostnameParams{ - Hostname: rhc.hostname, - RegionKey: rhc.regionKey, +func createDataLocalizationRegionalHostnameParams(zoneID string, rhc regionalHostnameChange) addressing.RegionalHostnameNewParams { + return addressing.RegionalHostnameNewParams{ + ZoneID: cloudflarev4.F(zoneID), + Hostname: cloudflarev4.F(rhc.hostname), + RegionKey: cloudflarev4.F(rhc.regionKey), } } // updateDataLocalizationRegionalHostnameParams is a function that returns the appropriate RegionalHostname Param based on the cloudFlareChange passed in -func updateDataLocalizationRegionalHostnameParams(rhc regionalHostnameChange) cloudflare.UpdateDataLocalizationRegionalHostnameParams { - return cloudflare.UpdateDataLocalizationRegionalHostnameParams{ - Hostname: rhc.hostname, - RegionKey: rhc.regionKey, +func updateDataLocalizationRegionalHostnameParams(zoneID string, rhc regionalHostnameChange) addressing.RegionalHostnameEditParams { + return addressing.RegionalHostnameEditParams{ + ZoneID: cloudflarev4.F(zoneID), + RegionKey: cloudflarev4.F(rhc.regionKey), + } +} + +// deleteDataLocalizationRegionalHostnameParams is a function that returns the appropriate RegionalHostname Param based on the cloudFlareChange passed in +func deleteDataLocalizationRegionalHostnameParams(zoneID string, rhc regionalHostnameChange) addressing.RegionalHostnameDeleteParams { + return addressing.RegionalHostnameDeleteParams{ + ZoneID: cloudflarev4.F(zoneID), } } // submitRegionalHostnameChanges applies a set of regional hostname changes, returns false if at least one fails -func (p *CloudFlareProvider) submitRegionalHostnameChanges(ctx context.Context, rhChanges []regionalHostnameChange, resourceContainer *cloudflare.ResourceContainer) bool { +func (p *CloudFlareProvider) submitRegionalHostnameChanges(ctx context.Context, zoneID string, rhChanges []regionalHostnameChange) bool { failedChange := false for _, rhChange := range rhChanges { - if !p.submitRegionalHostnameChange(ctx, rhChange, resourceContainer) { + if !p.submitRegionalHostnameChange(ctx, zoneID, rhChange) { failedChange = true } } @@ -101,12 +119,12 @@ func (p *CloudFlareProvider) submitRegionalHostnameChanges(ctx context.Context, } // submitRegionalHostnameChange applies a single regional hostname change, returns false if it fails -func (p *CloudFlareProvider) submitRegionalHostnameChange(ctx context.Context, rhChange regionalHostnameChange, resourceContainer *cloudflare.ResourceContainer) bool { +func (p *CloudFlareProvider) submitRegionalHostnameChange(ctx context.Context, zoneID string, rhChange regionalHostnameChange) bool { changeLog := log.WithFields(log.Fields{ "hostname": rhChange.hostname, "region_key": rhChange.regionKey, "action": rhChange.action.String(), - "zone": resourceContainer.Identifier, + "zone": zoneID, }) if p.DryRun { changeLog.Debug("Dry run: skipping regional hostname change", rhChange.action) @@ -115,21 +133,22 @@ func (p *CloudFlareProvider) submitRegionalHostnameChange(ctx context.Context, r switch rhChange.action { case cloudFlareCreate: changeLog.Debug("Creating regional hostname") - regionalHostnameParam := createDataLocalizationRegionalHostnameParams(rhChange) - if err := p.Client.CreateDataLocalizationRegionalHostname(ctx, resourceContainer, regionalHostnameParam); err != nil { + params := createDataLocalizationRegionalHostnameParams(zoneID, rhChange) + if err := p.Client.CreateDataLocalizationRegionalHostname(ctx, params); err != nil { changeLog.Errorf("failed to create regional hostname: %v", err) return false } case cloudFlareUpdate: changeLog.Debug("Updating regional hostname") - regionalHostnameParam := updateDataLocalizationRegionalHostnameParams(rhChange) - if err := p.Client.UpdateDataLocalizationRegionalHostname(ctx, resourceContainer, regionalHostnameParam); err != nil { + params := updateDataLocalizationRegionalHostnameParams(zoneID, rhChange) + if err := p.Client.UpdateDataLocalizationRegionalHostname(ctx, rhChange.hostname, params); err != nil { changeLog.Errorf("failed to update regional hostname: %v", err) return false } case cloudFlareDelete: changeLog.Debug("Deleting regional hostname") - if err := p.Client.DeleteDataLocalizationRegionalHostname(ctx, resourceContainer, rhChange.hostname); err != nil { + params := deleteDataLocalizationRegionalHostnameParams(zoneID, rhChange) + if err := p.Client.DeleteDataLocalizationRegionalHostname(ctx, rhChange.hostname, params); err != nil { changeLog.Errorf("failed to delete regional hostname: %v", err) return false } @@ -137,18 +156,22 @@ func (p *CloudFlareProvider) submitRegionalHostnameChange(ctx context.Context, r return true } -func (p *CloudFlareProvider) listDataLocalisationRegionalHostnames(ctx context.Context, resourceContainer *cloudflare.ResourceContainer) (regionalHostnamesMap, error) { - rhs, err := p.Client.ListDataLocalizationRegionalHostnames(ctx, resourceContainer, cloudflare.ListDataLocalizationRegionalHostnamesParams{}) - if err != nil { - return nil, convertCloudflareError(err) - } +// listDataLocalisationRegionalHostnames fetches the current regional hostnames for the given zone ID. +// +// It returns a map of hostnames to regional hostnames, or an error if the request fails. +func (p *CloudFlareProvider) listDataLocalisationRegionalHostnames(ctx context.Context, zoneID string) (regionalHostnamesMap, error) { + params := listDataLocalizationRegionalHostnamesParams(zoneID) + iter := p.Client.ListDataLocalizationRegionalHostnames(ctx, params) rhsMap := make(regionalHostnamesMap) - for _, rh := range rhs { + for rh := range autoPagerIterator(iter) { rhsMap[rh.Hostname] = regionalHostname{ hostname: rh.Hostname, regionKey: rh.RegionKey, } } + if iter.Err() != nil { + return nil, convertCloudflareError(iter.Err()) + } return rhsMap, nil } @@ -193,7 +216,7 @@ func (p *CloudFlareProvider) addEnpointsProviderSpecificRegionKeyProperty(ctx co return nil } - regionalHostnames, err := p.listDataLocalisationRegionalHostnames(ctx, cloudflare.ZoneIdentifier(zoneID)) + regionalHostnames, err := p.listDataLocalisationRegionalHostnames(ctx, zoneID) if err != nil { return err } diff --git a/provider/cloudflare/cloudflare_regional_test.go b/provider/cloudflare/cloudflare_regional_test.go index 1aafbebac..aee88dfe0 100644 --- a/provider/cloudflare/cloudflare_regional_test.go +++ b/provider/cloudflare/cloudflare_regional_test.go @@ -24,6 +24,7 @@ import ( "testing" cloudflare "github.com/cloudflare/cloudflare-go" + "github.com/cloudflare/cloudflare-go/v4/addressing" log "github.com/sirupsen/logrus" "github.com/stretchr/testify/assert" @@ -33,61 +34,64 @@ import ( "sigs.k8s.io/external-dns/plan" ) -func (m *mockCloudFlareClient) ListDataLocalizationRegionalHostnames(ctx context.Context, rc *cloudflare.ResourceContainer, rp cloudflare.ListDataLocalizationRegionalHostnamesParams) ([]cloudflare.RegionalHostname, error) { - if strings.Contains(rc.Identifier, "rherror") { - return nil, fmt.Errorf("failed to list regional hostnames") +func (m *mockCloudFlareClient) ListDataLocalizationRegionalHostnames(ctx context.Context, params addressing.RegionalHostnameListParams) autoPager[addressing.RegionalHostnameListResponse] { + zoneID := params.ZoneID.Value + if strings.Contains(zoneID, "rherror") { + return &mockAutoPager[addressing.RegionalHostnameListResponse]{err: fmt.Errorf("failed to list regional hostnames")} } - rhs := make([]cloudflare.RegionalHostname, 0, len(m.regionalHostnames[rc.Identifier])) - for _, rh := range m.regionalHostnames[rc.Identifier] { - rhs = append(rhs, cloudflare.RegionalHostname{ + results := make([]addressing.RegionalHostnameListResponse, 0, len(m.regionalHostnames[zoneID])) + for _, rh := range m.regionalHostnames[zoneID] { + results = append(results, addressing.RegionalHostnameListResponse{ Hostname: rh.hostname, RegionKey: rh.regionKey, }) } - return rhs, nil + return &mockAutoPager[addressing.RegionalHostnameListResponse]{ + items: results, + } } -func (m *mockCloudFlareClient) CreateDataLocalizationRegionalHostname(ctx context.Context, rc *cloudflare.ResourceContainer, rp cloudflare.CreateDataLocalizationRegionalHostnameParams) error { - if strings.Contains(rp.Hostname, "rherror") { +func (m *mockCloudFlareClient) CreateDataLocalizationRegionalHostname(ctx context.Context, params addressing.RegionalHostnameNewParams) error { + if strings.Contains(params.Hostname.Value, "rherror") { return fmt.Errorf("failed to create regional hostname") } m.Actions = append(m.Actions, MockAction{ Name: "CreateDataLocalizationRegionalHostname", - ZoneId: rc.Identifier, + ZoneId: params.ZoneID.Value, RecordId: "", RegionalHostname: regionalHostname{ - hostname: rp.Hostname, - regionKey: rp.RegionKey, + hostname: params.Hostname.Value, + regionKey: params.RegionKey.Value, }, }) return nil } -func (m *mockCloudFlareClient) UpdateDataLocalizationRegionalHostname(ctx context.Context, rc *cloudflare.ResourceContainer, rp cloudflare.UpdateDataLocalizationRegionalHostnameParams) error { - if strings.Contains(rp.Hostname, "rherror") { +func (m *mockCloudFlareClient) UpdateDataLocalizationRegionalHostname(ctx context.Context, hostname string, params addressing.RegionalHostnameEditParams) error { + if strings.Contains(hostname, "rherror") { return fmt.Errorf("failed to update regional hostname") } m.Actions = append(m.Actions, MockAction{ Name: "UpdateDataLocalizationRegionalHostname", - ZoneId: rc.Identifier, + ZoneId: params.ZoneID.Value, RecordId: "", RegionalHostname: regionalHostname{ - hostname: rp.Hostname, - regionKey: rp.RegionKey, + hostname: hostname, + regionKey: params.RegionKey.Value, }, }) return nil } -func (m *mockCloudFlareClient) DeleteDataLocalizationRegionalHostname(ctx context.Context, rc *cloudflare.ResourceContainer, hostname string) error { +func (m *mockCloudFlareClient) DeleteDataLocalizationRegionalHostname(ctx context.Context, hostname string, params addressing.RegionalHostnameDeleteParams) error { if strings.Contains(hostname, "rherror") { return fmt.Errorf("failed to delete regional hostname") } m.Actions = append(m.Actions, MockAction{ Name: "DeleteDataLocalizationRegionalHostname", - ZoneId: rc.Identifier, + ZoneId: params.ZoneID.Value, RecordId: "", RegionalHostname: regionalHostname{ hostname: hostname, diff --git a/provider/cloudflare/pagination.go b/provider/cloudflare/pagination.go new file mode 100644 index 000000000..2b8337ae2 --- /dev/null +++ b/provider/cloudflare/pagination.go @@ -0,0 +1,34 @@ +/* +Copyright 2025 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 + +type autoPager[T any] interface { + Next() bool + Current() T + Err() error +} + +// autoPagerIterator returns an iterator over an autoPager. +func autoPagerIterator[T any](iter autoPager[T]) func(yield func(T) bool) { + return func(yield func(T) bool) { + for iter.Next() { + if !yield(iter.Current()) { + return + } + } + } +} diff --git a/provider/cloudflare/pagination_test.go b/provider/cloudflare/pagination_test.go new file mode 100644 index 000000000..1f0c28e82 --- /dev/null +++ b/provider/cloudflare/pagination_test.go @@ -0,0 +1,94 @@ +/* +Copyright 2025 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 ( + "errors" + "slices" + "testing" + + "github.com/stretchr/testify/assert" +) + +type mockAutoPager[T any] struct { + items []T + index int + err error + errIndex int +} + +func (m *mockAutoPager[T]) Next() bool { + m.index++ + return !m.hasError() && m.hasNext() +} + +func (m *mockAutoPager[T]) Current() T { + if m.hasNext() && !m.hasError() { + return m.items[m.index-1] + } + var zero T + return zero +} + +func (m *mockAutoPager[T]) Err() error { + return m.err +} + +func (m *mockAutoPager[T]) hasError() bool { + return m.err != nil && m.errIndex <= m.index +} + +func (m *mockAutoPager[T]) hasNext() bool { + return m.index > 0 && m.index <= len(m.items) +} + +func TestAutoPagerIterator(t *testing.T) { + t.Run("iterate empty", func(t *testing.T) { + pager := &mockAutoPager[string]{} + iterator := autoPagerIterator(pager) + collected := slices.Collect(iterator) + assert.Empty(t, collected) + }) + + t.Run("iterate all items", func(t *testing.T) { + pager := &mockAutoPager[int]{items: []int{1, 2, 3, 4, 5}} + iterator := autoPagerIterator(pager) + collected := slices.Collect(iterator) + assert.Equal(t, []int{1, 2, 3, 4, 5}, collected) + }) + + t.Run("iterate with early termination", func(t *testing.T) { + pager := &mockAutoPager[int]{items: []int{1, 2, 3, 4, 5}} + iterator := autoPagerIterator(pager) + var collected []int + for item := range iterator { + collected = append(collected, item) + if item == 3 { + break + } + } + assert.Equal(t, []int{1, 2, 3}, collected) + }) + + t.Run("iterate with error at index", func(t *testing.T) { + expectedErr := errors.New("pager error") + pager := &mockAutoPager[int]{items: []int{1, 2, 3, 4, 5}, err: expectedErr, errIndex: 3} + iterator := autoPagerIterator(pager) + collected := slices.Collect(iterator) + assert.Equal(t, []int{1, 2}, collected) + }) +}