diff --git a/docs/tutorials/cloudflare.md b/docs/tutorials/cloudflare.md index cee02ddf55..0313471371 100644 --- a/docs/tutorials/cloudflare.md +++ b/docs/tutorials/cloudflare.md @@ -4,37 +4,6 @@ This tutorial describes how to setup ExternalDNS for usage within a Kubernetes c Make sure to use **>=0.4.2** version of ExternalDNS for this tutorial. -## CloudFlare SDK Migration Status - -ExternalDNS is currently migrating from the legacy CloudFlare Go SDK v0 to the modern v4 SDK to improve performance, reliability, and access to newer CloudFlare features. The migration status is: - -**✅ Fully migrated to v4 SDK:** - -- Zone management (listing, filtering, pagination) -- Zone details retrieval (`GetZone`) -- Zone ID lookup by name (`ZoneIDByName`) -- Zone plan detection (fully v4 implementation) -- Regional services (data localization) - -**🔄 Still using legacy v0 SDK:** - -- DNS record management (create, update, delete records) -- Custom hostnames -- Proxied records - -This mixed approach ensures continued functionality while gradually modernizing the codebase. Users should not experience any breaking changes during this transition. - -### SDK Dependencies - -ExternalDNS currently uses: - -- **cloudflare-go v0.115.0+**: Legacy SDK for DNS records, custom hostnames, and proxied record features -- **cloudflare-go/v4 v4.6.0+**: Modern SDK for all zone management and regional services operations - -Zone management has been fully migrated to the v4 SDK, providing improved performance and reliability. - -Both SDKs are automatically managed as Go module dependencies and require no special configuration from users. - ## Creating a Cloudflare DNS zone We highly recommend to read this tutorial if you haven't used Cloudflare before: @@ -384,8 +353,6 @@ The custom hostname DNS must resolve to the Cloudflare DNS record (`external-dns Requires [Cloudflare for SaaS](https://developers.cloudflare.com/cloudflare-for-platforms/cloudflare-for-saas/) product and "SSL and Certificates" API permission. -**Note:** Due to using the legacy cloudflare-go v0 API for custom hostname management, the custom hostname page size is fixed at 50. This limitation will be addressed in a future migration to the v4 SDK. - ## Setting Cloudflare DNS Record Tags Cloudflare allows you to add descriptive tags to DNS records. This can be useful for organizing your records. diff --git a/provider/cloudflare/cloudflare.go b/provider/cloudflare/cloudflare.go index fae8e5ad39..2611f1456e 100644 --- a/provider/cloudflare/cloudflare.go +++ b/provider/cloudflare/cloudflare.go @@ -28,7 +28,6 @@ import ( "strconv" "strings" - cloudflarev0 "github.com/cloudflare/cloudflare-go" "github.com/cloudflare/cloudflare-go/v5" "github.com/cloudflare/cloudflare-go/v5/addressing" "github.com/cloudflare/cloudflare-go/v5/custom_hostnames" @@ -84,12 +83,35 @@ type DNSRecordIndex struct { type DNSRecordsMap map[DNSRecordIndex]dns.RecordResponse -// for faster getCustomHostname() lookup -type CustomHostnameIndex struct { - Hostname string +// customHostname represents a Cloudflare custom hostname (v5 API compatible wrapper) +type customHostname struct { + id string + hostname string + customOriginServer string + customOriginSNI string + ssl *customHostnameSSL } -type CustomHostnamesMap map[CustomHostnameIndex]cloudflarev0.CustomHostname +// customHostnameSSL represents SSL configuration for custom hostname +type customHostnameSSL struct { + sslType string + method string + bundleMethod string + certificateAuthority string + settings customHostnameSSLSettings +} + +// customHostnameSSLSettings represents SSL settings for custom hostname +type customHostnameSSLSettings struct { + minTLSVersion string +} + +// for faster getcustomHostname() lookup +type customHostnameIndex struct { + hostname string +} + +type customHostnamesMap map[customHostnameIndex]customHostname var recordTypeProxyNotSupported = map[string]bool{ "LOC": true, @@ -124,14 +146,13 @@ type cloudFlareDNS interface { 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 cloudflarev0.CustomHostname) ([]cloudflarev0.CustomHostname, cloudflarev0.ResultInfo, error) - DeleteCustomHostname(ctx context.Context, customHostnameID string, params custom_hostnames.CustomHostnameDeleteParams) error - CreateCustomHostname(ctx context.Context, zoneID string, ch cloudflarev0.CustomHostname) (*cloudflarev0.CustomHostnameResponse, error) + CustomHostnames(ctx context.Context, zoneID string) autoPager[custom_hostnames.CustomHostnameListResponse] + DeletecustomHostname(ctx context.Context, customHostnameID string, params custom_hostnames.CustomHostnameDeleteParams) error + CreatecustomHostname(ctx context.Context, zoneID string, ch customHostname) error } type zoneService struct { - serviceV0 *cloudflarev0.API - service *cloudflare.Client + service *cloudflare.Client } func (z zoneService) ZoneIDByName(zoneName string) (string, error) { @@ -179,17 +200,40 @@ func (z zoneService) GetZone(ctx context.Context, zoneID string) (*zones.Zone, e return z.service.Zones.Get(ctx, zones.ZoneGetParams{ZoneID: cloudflare.F(zoneID)}) } -func (z zoneService) CustomHostnames(ctx context.Context, zoneID string, page int, filter cloudflarev0.CustomHostname) ([]cloudflarev0.CustomHostname, cloudflarev0.ResultInfo, error) { - return z.serviceV0.CustomHostnames(ctx, zoneID, page, filter) +func (z zoneService) CustomHostnames(ctx context.Context, zoneID string) autoPager[custom_hostnames.CustomHostnameListResponse] { + params := custom_hostnames.CustomHostnameListParams{ + ZoneID: cloudflare.F(zoneID), + } + return z.service.CustomHostnames.ListAutoPaging(ctx, params) +} + +// listAllCustomHostnames extracts all custom hostnames from the iterator +func listAllCustomHostnames(iter autoPager[custom_hostnames.CustomHostnameListResponse]) ([]customHostname, error) { + var customHostnames []customHostname + for ch := range autoPagerIterator(iter) { + customHostnames = append(customHostnames, customHostname{ + id: ch.ID, + hostname: ch.Hostname, + customOriginServer: ch.CustomOriginServer, + customOriginSNI: ch.CustomOriginSNI, + }) + } + if iter.Err() != nil { + return nil, iter.Err() + } + return customHostnames, nil } -func (z zoneService) DeleteCustomHostname(ctx context.Context, customHostnameID string, params custom_hostnames.CustomHostnameDeleteParams) error { +func (z zoneService) DeletecustomHostname(ctx context.Context, customHostnameID string, params custom_hostnames.CustomHostnameDeleteParams) error { _, err := z.service.CustomHostnames.Delete(ctx, customHostnameID, params) return err } -func (z zoneService) CreateCustomHostname(ctx context.Context, zoneID string, ch cloudflarev0.CustomHostname) (*cloudflarev0.CustomHostnameResponse, error) { - return z.serviceV0.CreateCustomHostname(ctx, zoneID, ch) +func (z zoneService) CreatecustomHostname(ctx context.Context, zoneID string, ch customHostname) error { + params := buildCustomHostnameNewParams(zoneID, ch) + _, err := z.service.CustomHostnames.New(ctx, params, + option.WithJSONSet("custom_origin_server", ch.customOriginServer)) + return err } // listZonesV4Params returns the appropriate Zone List Params for v4 API @@ -260,15 +304,10 @@ type cloudFlareChange struct { Action changeAction ResourceRecord dns.RecordResponse RegionalHostname regionalHostname - CustomHostnames map[string]cloudflarev0.CustomHostname + CustomHostnames map[string]customHostname CustomHostnamesPrev []string } -// RecordParamsTypes is a typeset of the possible Record Params that can be passed to cloudflare-go library -type RecordParamsTypes interface { - cloudflarev0.UpdateDNSRecordParams | cloudflarev0.CreateDNSRecordParams -} - // 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{ @@ -304,22 +343,65 @@ func getCreateDNSRecordParam(zoneID string, cfc *cloudFlareChange) dns.RecordNew } func convertCloudflareError(err error) error { - var apiErr *cloudflarev0.Error - if errors.As(err, &apiErr) { - if apiErr.ClientRateLimited() || apiErr.StatusCode >= http.StatusInternalServerError { - // Handle rate limit error as a soft error + // Handle CloudFlare v5 SDK errors according to the documentation: + // https://github.com/cloudflare/cloudflare-go?tab=readme-ov-file#errors + var apierr *cloudflare.Error + if errors.As(err, &apierr) { + // Rate limit errors (429) and server errors (5xx) should be treated as soft errors + // so that external-dns will retry them later + if apierr.StatusCode == http.StatusTooManyRequests || apierr.StatusCode >= http.StatusInternalServerError { return provider.NewSoftError(err) } + // For other structured API errors (4xx), return the error unchanged + // Note: We must NOT call err.Error() on v5 cloudflare.Error types with nil internal fields + return err } - // This is a workaround because Cloudflare library does not return a specific error type for rate limit exceeded. - // See https://github.com/cloudflare/cloudflare-go/issues/4155 and https://github.com/kubernetes-sigs/external-dns/pull/5524 - // This workaround can be removed once Cloudflare library returns a specific error type. - if strings.Contains(err.Error(), "exceeded available rate limit retries") { + + // Also check for rate limit indicators in error message strings as a fallback. + // 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. + errMsg := strings.ToLower(err.Error()) + if strings.Contains(errMsg, "rate limit") || + strings.Contains(errMsg, "429") || + strings.Contains(errMsg, "exceeded available rate limit retries") || + strings.Contains(errMsg, "too many requests") { return provider.NewSoftError(err) } + return err } +// buildCustomHostnameNewParams builds the params for creating a custom hostname +func buildCustomHostnameNewParams(zoneID string, ch customHostname) custom_hostnames.CustomHostnameNewParams { + params := custom_hostnames.CustomHostnameNewParams{ + ZoneID: cloudflare.F(zoneID), + Hostname: cloudflare.F(ch.hostname), + } + if ch.ssl != nil { + sslParams := custom_hostnames.CustomHostnameNewParamsSSL{} + if ch.ssl.method != "" { + sslParams.Method = cloudflare.F(custom_hostnames.DCVMethod(ch.ssl.method)) + } + if ch.ssl.sslType != "" { + sslParams.Type = cloudflare.F(custom_hostnames.DomainValidationType(ch.ssl.sslType)) + } + if ch.ssl.bundleMethod != "" { + sslParams.BundleMethod = cloudflare.F(custom_hostnames.BundleMethod(ch.ssl.bundleMethod)) + } + if ch.ssl.certificateAuthority != "" && ch.ssl.certificateAuthority != "none" { + sslParams.CertificateAuthority = cloudflare.F(cloudflare.CertificateCA(ch.ssl.certificateAuthority)) + } + if ch.ssl.settings.minTLSVersion != "" { + sslParams.Settings = cloudflare.F(custom_hostnames.CustomHostnameNewParamsSSLSettings{ + MinTLSVersion: cloudflare.F(custom_hostnames.CustomHostnameNewParamsSSLSettingsMinTLSVersion(ch.ssl.settings.minTLSVersion)), + }) + } + params.SSL = cloudflare.F(sslParams) + } + return params +} + // NewCloudFlareProvider initializes a new CloudFlare DNS based Provider. func NewCloudFlareProvider( domainFilter *endpoint.DomainFilter, @@ -331,11 +413,9 @@ func NewCloudFlareProvider( dnsRecordsConfig DNSRecordsConfig, ) (*CloudFlareProvider, error) { // initialize via chosen auth method and returns new API object - var ( - config *cloudflarev0.API - configV4 *cloudflare.Client - err error - ) + + var client *cloudflare.Client + token := os.Getenv(cfAPITokenEnvKey) if token != "" { if trimed, ok := strings.CutPrefix(token, "file:"); ok { @@ -345,27 +425,27 @@ func NewCloudFlareProvider( } token = strings.TrimSpace(string(tokenBytes)) } - config, err = cloudflarev0.NewWithAPIToken(token) - configV4 = cloudflare.NewClient( + client = cloudflare.NewClient( option.WithAPIToken(token), ) } else { - config, err = cloudflarev0.New(os.Getenv(cfAPIKeyEnvKey), os.Getenv(cfAPIEmailEnvKey)) - configV4 = cloudflare.NewClient( - option.WithAPIKey(os.Getenv(cfAPIKeyEnvKey)), - option.WithAPIEmail(os.Getenv(cfAPIEmailEnvKey)), + apiKey := os.Getenv(cfAPIKeyEnvKey) + apiEmail := os.Getenv(cfAPIEmailEnvKey) + if apiKey == "" || apiEmail == "" { + return nil, fmt.Errorf("cloudflare credentials are not configured: set either %s or both %s and %s environment variables", cfAPITokenEnvKey, cfAPIKeyEnvKey, cfAPIEmailEnvKey) + } + client = cloudflare.NewClient( + option.WithAPIKey(apiKey), + option.WithAPIEmail(apiEmail), ) } - if err != nil { - return nil, fmt.Errorf("failed to initialize cloudflare provider: %w", err) - } if regionalServicesConfig.RegionKey != "" { regionalServicesConfig.Enabled = true } return &CloudFlareProvider{ - Client: zoneService{config, configV4}, + Client: zoneService{client}, domainFilter: domainFilter, zoneIDFilter: zoneIDFilter, proxiedByDefault: proxiedByDefault, @@ -537,8 +617,7 @@ func (p *CloudFlareProvider) ApplyChanges(ctx context.Context, changes *plan.Cha } // submitCustomHostnameChanges implements Custom Hostname functionality for the Change, returns false if it fails -func (p *CloudFlareProvider) submitCustomHostnameChanges(ctx context.Context, zoneID string, change *cloudFlareChange, chs CustomHostnamesMap, logFields log.Fields) bool { - failedChange := false +func (p *CloudFlareProvider) submitCustomHostnameChanges(ctx context.Context, zoneID string, change *cloudFlareChange, chs customHostnamesMap, logFields log.Fields) bool { // return early if disabled if !p.CustomHostnamesConfig.Enabled { return true @@ -546,66 +625,86 @@ func (p *CloudFlareProvider) submitCustomHostnameChanges(ctx context.Context, zo switch change.Action { case cloudFlareUpdate: - if recordTypeCustomHostnameSupported[string(change.ResourceRecord.Type)] { - add, remove, _ := provider.Difference(change.CustomHostnamesPrev, slices.Collect(maps.Keys(change.CustomHostnames))) - - for _, changeCH := range remove { - if prevCh, err := getCustomHostname(chs, changeCH); err == nil { - prevChID := prevCh.ID - if prevChID != "" { - log.WithFields(logFields).Infof("Removing previous custom hostname %q/%q", prevChID, changeCH) - params := custom_hostnames.CustomHostnameDeleteParams{ZoneID: cloudflare.F(zoneID)} - chErr := p.Client.DeleteCustomHostname(ctx, prevChID, params) - if chErr != nil { - failedChange = true - log.WithFields(logFields).Errorf("failed to remove previous custom hostname %q/%q: %v", prevChID, changeCH, chErr) - } - } - } - } - for _, changeCH := range add { - log.WithFields(logFields).Infof("Adding custom hostname %q", changeCH) - _, chErr := p.Client.CreateCustomHostname(ctx, zoneID, change.CustomHostnames[changeCH]) + return p.processCustomHostnameUpdate(ctx, zoneID, change, chs, logFields) + case cloudFlareDelete: + return p.processCustomHostnameDelete(ctx, zoneID, change, chs, logFields) + case cloudFlareCreate: + return p.processCustomHostnameCreate(ctx, zoneID, change, chs, logFields) + } + + return true +} + +func (p *CloudFlareProvider) processCustomHostnameUpdate(ctx context.Context, zoneID string, change *cloudFlareChange, chs customHostnamesMap, logFields log.Fields) bool { + if !recordTypeCustomHostnameSupported[string(change.ResourceRecord.Type)] { + return true + } + failedChange := false + add, remove, _ := provider.Difference(change.CustomHostnamesPrev, slices.Collect(maps.Keys(change.CustomHostnames))) + + for _, changeCH := range remove { + if prevCh, err := getcustomHostname(chs, changeCH); err == nil { + prevChID := prevCh.id + if prevChID != "" { + log.WithFields(logFields).Infof("Removing previous custom hostname %q/%q", prevChID, changeCH) + params := custom_hostnames.CustomHostnameDeleteParams{ZoneID: cloudflare.F(zoneID)} + chErr := p.Client.DeletecustomHostname(ctx, prevChID, params) if chErr != nil { failedChange = true - log.WithFields(logFields).Errorf("failed to add custom hostname %q: %v", changeCH, chErr) + log.WithFields(logFields).Errorf("failed to remove previous custom hostname %q/%q: %v", prevChID, changeCH, chErr) } } } - case cloudFlareDelete: - for _, changeCH := range change.CustomHostnames { - if recordTypeCustomHostnameSupported[string(change.ResourceRecord.Type)] && changeCH.Hostname != "" { - log.WithFields(logFields).Infof("Deleting custom hostname %q", changeCH.Hostname) - if ch, err := getCustomHostname(chs, changeCH.Hostname); err == nil { - chID := ch.ID - params := custom_hostnames.CustomHostnameDeleteParams{ZoneID: cloudflare.F(zoneID)} - chErr := p.Client.DeleteCustomHostname(ctx, chID, params) - if chErr != nil { - failedChange = true - log.WithFields(logFields).Errorf("failed to delete custom hostname %q/%q: %v", chID, changeCH.Hostname, chErr) - } - } else { - log.WithFields(logFields).Warnf("failed to delete custom hostname %q: %v", changeCH.Hostname, err) + } + for _, changeCH := range add { + log.WithFields(logFields).Infof("Adding custom hostname %q", changeCH) + chErr := p.Client.CreatecustomHostname(ctx, zoneID, change.CustomHostnames[changeCH]) + if chErr != nil { + failedChange = true + log.WithFields(logFields).Errorf("failed to add custom hostname %q: %v", changeCH, chErr) + } + } + return !failedChange +} + +func (p *CloudFlareProvider) processCustomHostnameDelete(ctx context.Context, zoneID string, change *cloudFlareChange, chs customHostnamesMap, logFields log.Fields) bool { + failedChange := false + for _, changeCH := range change.CustomHostnames { + if recordTypeCustomHostnameSupported[string(change.ResourceRecord.Type)] && changeCH.hostname != "" { + log.WithFields(logFields).Infof("Deleting custom hostname %q", changeCH.hostname) + if ch, err := getcustomHostname(chs, changeCH.hostname); err == nil { + chID := ch.id + params := custom_hostnames.CustomHostnameDeleteParams{ZoneID: cloudflare.F(zoneID)} + chErr := p.Client.DeletecustomHostname(ctx, chID, params) + if chErr != nil { + failedChange = true + log.WithFields(logFields).Errorf("failed to delete custom hostname %q/%q: %v", chID, changeCH.hostname, chErr) } + } else { + log.WithFields(logFields).Warnf("failed to delete custom hostname %q: %v", changeCH.hostname, err) } } - case cloudFlareCreate: - for _, changeCH := range change.CustomHostnames { - if recordTypeCustomHostnameSupported[string(change.ResourceRecord.Type)] && changeCH.Hostname != "" { - log.WithFields(logFields).Infof("Creating custom hostname %q", changeCH.Hostname) - if ch, err := getCustomHostname(chs, changeCH.Hostname); err == nil { - if changeCH.CustomOriginServer == ch.CustomOriginServer { - log.WithFields(logFields).Warnf("custom hostname %q already exists with the same origin %q, continue", changeCH.Hostname, ch.CustomOriginServer) - } else { - failedChange = true - log.WithFields(logFields).Errorf("failed to create custom hostname, %q already exists with origin %q", changeCH.Hostname, ch.CustomOriginServer) - } + } + return !failedChange +} + +func (p *CloudFlareProvider) processCustomHostnameCreate(ctx context.Context, zoneID string, change *cloudFlareChange, chs customHostnamesMap, logFields log.Fields) bool { + failedChange := false + for _, changeCH := range change.CustomHostnames { + if recordTypeCustomHostnameSupported[string(change.ResourceRecord.Type)] && changeCH.hostname != "" { + log.WithFields(logFields).Infof("Creating custom hostname %q", changeCH.hostname) + if ch, err := getcustomHostname(chs, changeCH.hostname); err == nil { + if changeCH.customOriginServer == ch.customOriginServer { + log.WithFields(logFields).Warnf("custom hostname %q already exists with the same origin %q, continue", changeCH.hostname, ch.customOriginServer) } else { - _, chErr := p.Client.CreateCustomHostname(ctx, zoneID, changeCH) - if chErr != nil { - failedChange = true - log.WithFields(logFields).Errorf("failed to create custom hostname %q: %v", changeCH.Hostname, chErr) - } + failedChange = true + log.WithFields(logFields).Errorf("failed to create custom hostname, %q already exists with origin %q", changeCH.hostname, ch.customOriginServer) + } + } else { + chErr := p.Client.CreatecustomHostname(ctx, zoneID, changeCH) + if chErr != nil { + failedChange = true + log.WithFields(logFields).Errorf("failed to create custom hostname %q: %v", changeCH.hostname, chErr) } } } @@ -810,21 +909,21 @@ func (p *CloudFlareProvider) getRecordID(records DNSRecordsMap, record dns.Recor return "" } -func getCustomHostname(chs CustomHostnamesMap, chName string) (cloudflarev0.CustomHostname, error) { +func getcustomHostname(chs customHostnamesMap, chName string) (customHostname, error) { if chName == "" { - return cloudflarev0.CustomHostname{}, fmt.Errorf("failed to get custom hostname: %q is empty", chName) + return customHostname{}, fmt.Errorf("failed to get custom hostname: %q is empty", chName) } - if ch, ok := chs[CustomHostnameIndex{Hostname: chName}]; ok { + if ch, ok := chs[customHostnameIndex{hostname: chName}]; ok { return ch, nil } - return cloudflarev0.CustomHostname{}, fmt.Errorf("failed to get custom hostname: %q not found", chName) + return customHostname{}, fmt.Errorf("failed to get custom hostname: %q not found", chName) } -func (p *CloudFlareProvider) newCustomHostname(customHostname string, origin string) cloudflarev0.CustomHostname { - return cloudflarev0.CustomHostname{ - Hostname: customHostname, - CustomOriginServer: origin, - SSL: getCustomHostnamesSSLOptions(p.CustomHostnamesConfig), +func (p *CloudFlareProvider) newcustomHostname(hostname string, origin string) customHostname { + return customHostname{ + hostname: hostname, + customOriginServer: origin, + ssl: getCustomHostnamesSSLOptions(p.CustomHostnamesConfig), } } @@ -837,13 +936,13 @@ func (p *CloudFlareProvider) newCloudFlareChange(action changeAction, ep *endpoi } prevCustomHostnames := []string{} - newCustomHostnames := map[string]cloudflarev0.CustomHostname{} + newCustomHostnames := map[string]customHostname{} if p.CustomHostnamesConfig.Enabled { if current != nil { prevCustomHostnames = getEndpointCustomHostnames(current) } for _, v := range getEndpointCustomHostnames(ep) { - newCustomHostnames[v] = p.newCustomHostname(v, ep.DNSName) + newCustomHostnames[v] = p.newcustomHostname(v, ep.DNSName) } } @@ -914,50 +1013,44 @@ func (p *CloudFlareProvider) getDNSRecordsMap(ctx context.Context, zoneID string return recordsMap, nil } -func newCustomHostnameIndex(ch cloudflarev0.CustomHostname) CustomHostnameIndex { - return CustomHostnameIndex{Hostname: ch.Hostname} +func newcustomHostnameIndex(ch customHostname) customHostnameIndex { + return customHostnameIndex{hostname: ch.hostname} } // listCustomHostnamesWithPagination performs automatic pagination of results on requests to cloudflare.CustomHostnames -func (p *CloudFlareProvider) listCustomHostnamesWithPagination(ctx context.Context, zoneID string) (CustomHostnamesMap, error) { +func (p *CloudFlareProvider) listCustomHostnamesWithPagination(ctx context.Context, zoneID string) (customHostnamesMap, error) { if !p.CustomHostnamesConfig.Enabled { return nil, nil } - chs := make(CustomHostnamesMap) - resultInfo := cloudflarev0.ResultInfo{Page: 1} - for { - pageCustomHostnameListResponse, result, err := p.Client.CustomHostnames(ctx, zoneID, resultInfo.Page, cloudflarev0.CustomHostname{}) - if err != nil { - convertedError := convertCloudflareError(err) - if !errors.Is(convertedError, provider.SoftError) { - log.Errorf("zone %q failed to fetch custom hostnames. Please check if \"Cloudflare for SaaS\" is enabled and API key permissions, %v", zoneID, err) - } - return nil, convertedError - } - for _, ch := range pageCustomHostnameListResponse { - chs[newCustomHostnameIndex(ch)] = ch - } - resultInfo = result.Next() - if resultInfo.Done() { - break + chs := make(customHostnamesMap) + iter := p.Client.CustomHostnames(ctx, zoneID) + customHostnames, err := listAllCustomHostnames(iter) + if err != nil { + convertedError := convertCloudflareError(err) + if !errors.Is(convertedError, provider.SoftError) { + log.Errorf("zone %q failed to fetch custom hostnames. Please check if \"Cloudflare for SaaS\" is enabled and API key permissions, %v", zoneID, err) } + return nil, convertedError + } + for _, ch := range customHostnames { + chs[newcustomHostnameIndex(ch)] = ch } return chs, nil } -func getCustomHostnamesSSLOptions(customHostnamesConfig CustomHostnamesConfig) *cloudflarev0.CustomHostnameSSL { - ssl := &cloudflarev0.CustomHostnameSSL{ - Type: "dv", - Method: "http", - BundleMethod: "ubiquitous", - Settings: cloudflarev0.CustomHostnameSSLSettings{ - MinTLSVersion: customHostnamesConfig.MinTLSVersion, +func getCustomHostnamesSSLOptions(customHostnamesConfig CustomHostnamesConfig) *customHostnameSSL { + ssl := &customHostnameSSL{ + sslType: "dv", + method: "http", + bundleMethod: "ubiquitous", + settings: customHostnameSSLSettings{ + minTLSVersion: customHostnamesConfig.MinTLSVersion, }, } // Set CertificateAuthority if provided // We're not able to set it at all (even with a blank) if you're not on an enterprise plan if customHostnamesConfig.CertificateAuthority != "none" { - ssl.CertificateAuthority = customHostnamesConfig.CertificateAuthority + ssl.certificateAuthority = customHostnamesConfig.CertificateAuthority } return ssl } @@ -993,7 +1086,7 @@ func getEndpointCustomHostnames(ep *endpoint.Endpoint) []string { return []string{} } -func (p *CloudFlareProvider) groupByNameAndTypeWithCustomHostnames(records DNSRecordsMap, chs CustomHostnamesMap) []*endpoint.Endpoint { +func (p *CloudFlareProvider) groupByNameAndTypeWithCustomHostnames(records DNSRecordsMap, chs customHostnamesMap) []*endpoint.Endpoint { var endpoints []*endpoint.Endpoint // group supported records by name and type @@ -1016,7 +1109,7 @@ func (p *CloudFlareProvider) groupByNameAndTypeWithCustomHostnames(records DNSRe customHostnames := map[string][]string{} for _, c := range chs { - customHostnames[c.CustomOriginServer] = append(customHostnames[c.CustomOriginServer], c.Hostname) + customHostnames[c.customOriginServer] = append(customHostnames[c.customOriginServer], c.hostname) } // create a single endpoint with all the targets for each name/type @@ -1073,27 +1166,3 @@ func (p *CloudFlareProvider) SupportedAdditionalRecordTypes(recordType string) b return provider.SupportedRecordType(recordType) } } - -func dnsRecordResponseFromLegacyDNSRecord(record cloudflarev0.DNSRecord) dns.RecordResponse { - var priority float64 - if record.Priority != nil { - priority = float64(*record.Priority) - } - - return dns.RecordResponse{ - CreatedOn: record.CreatedOn, - ModifiedOn: record.ModifiedOn, - Type: dns.RecordResponseType(record.Type), - Name: record.Name, - Content: record.Content, - Meta: record.Meta, - Data: record.Data, - ID: record.ID, - Priority: priority, - TTL: dns.TTL(record.TTL), - Proxied: record.Proxied != nil && *record.Proxied, - Proxiable: record.Proxiable, - Comment: record.Comment, - Tags: record.Tags, - } -} diff --git a/provider/cloudflare/cloudflare_test.go b/provider/cloudflare/cloudflare_test.go index cae7a54c4f..fe47fb48d0 100644 --- a/provider/cloudflare/cloudflare_test.go +++ b/provider/cloudflare/cloudflare_test.go @@ -20,13 +20,13 @@ import ( "context" "errors" "fmt" + "net/http" + "net/http/httptest" "os" "slices" "strings" "testing" - "time" - cloudflarev0 "github.com/cloudflare/cloudflare-go" "github.com/cloudflare/cloudflare-go/v5" "github.com/cloudflare/cloudflare-go/v5/custom_hostnames" "github.com/cloudflare/cloudflare-go/v5/dns" @@ -50,24 +50,21 @@ func TestMain(m *testing.M) { m.Run() } -type MockAction struct { - Name string - ZoneId string - RecordId string - RecordData dns.RecordResponse - RegionalHostname regionalHostname -} - -type mockCloudFlareClient struct { - Zones map[string]string - Records map[string]map[string]dns.RecordResponse - Actions []MockAction - listZonesError error // For v4 ListZones - getZoneError error // For v4 GetZone - dnsRecordsError error - customHostnames map[string][]cloudflarev0.CustomHostname - regionalHostnames map[string][]regionalHostname - dnsRecordsListParams dns.RecordListParams +// newCloudflareError creates a cloudflare.Error suitable for testing. +// The v5 SDK's Error type panics when .Error() is called with nil Request/Response fields, +// so this helper initializes them properly. +func newCloudflareError(statusCode int) *cloudflare.Error { + req := httptest.NewRequest(http.MethodGet, "https://api.cloudflare.com/client/v4/zones", nil) + resp := &http.Response{ + StatusCode: statusCode, + Status: http.StatusText(statusCode), + Request: req, + } + return &cloudflare.Error{ + StatusCode: statusCode, + Request: req, + Response: resp, + } } var ExampleDomain = []dns.RecordResponse{ @@ -98,114 +95,24 @@ var ExampleDomain = []dns.RecordResponse{ }, } -func TestCloudflareProviderTags(t *testing.T) { - provider := &CloudFlareProvider{} - t.Run("parseTagsAnnotation", func(t *testing.T) { - testCases := []struct { - name string - input string - expected []string - }{ - { - name: "Correctly sorts and cleans tags", - input: "owner:owner_name, env:dev, app:test ", - expected: []string{"app:test", "env:dev", "owner:owner_name"}, - }, - { - name: "Handles a single tag", - input: "owner:owner_name", - expected: []string{"owner:owner_name"}, - }, - { - name: "Handles empty string", - input: "", - expected: []string{}, - }, - { - name: "Handles messy input with extra commas and spaces", - input: " tag1 , tag2,,tag3 ", - expected: []string{"tag1", "tag2", "tag3"}, - }, - } - - for _, tc := range testCases { - t.Run(tc.name, func(t *testing.T) { - assert.Equal(t, tc.expected, parseTagsAnnotation(tc.input)) - }) - } - }) - - // Test cases for the groupByNameAndTypeWithCustomHostnames function (handling API response) - t.Run("groupByNameAndTypeWithCustomHostnames", func(t *testing.T) { - records := DNSRecordsMap{ - DNSRecordIndex{Name: "test.example.com", Type: "A", Content: "1.2.3.4"}: { - Name: "test.example.com", - Type: "A", - Content: "1.2.3.4", - Tags: []string{"owner:owner_name", "env:dev", "app:test"}, - }, - } - - endpoints := provider.groupByNameAndTypeWithCustomHostnames(records, nil) - require.Len(t, endpoints, 1) - - val, ok := endpoints[0].GetProviderSpecificProperty(annotations.CloudflareTagsKey) - assert.True(t, ok) - assert.Equal(t, "app:test,env:dev,owner:owner_name", val, "Tags from API should be sorted") - }) - - // This sub-test verifies that AdjustEndpoints correctly sorts and cleans the tags string. - t.Run("AdjustEndpoints sorts tags", func(t *testing.T) { - // Arrange: Create an endpoint with unsorted tags, including extra whitespace. - endpointWithTags := []*endpoint.Endpoint{ - { - DNSName: "tags.example.com", - Targets: endpoint.Targets{"1.2.3.4"}, - RecordType: endpoint.RecordTypeA, - ProviderSpecific: endpoint.ProviderSpecific{ - { - Name: annotations.CloudflareTagsKey, - Value: "owner:team-b, env:prod, app:api ", // Unsorted and messy - }, - }, - }, - } - expectedTagsString := "app:api,env:prod,owner:team-b" - - // Act: Call the function under test. - adjustedEndpoints, err := provider.AdjustEndpoints(endpointWithTags) - - // Assert: Check that the endpoint's tag property is now a sorted, clean string. - require.NoError(t, err) - require.Len(t, adjustedEndpoints, 1) - val, ok := adjustedEndpoints[0].GetProviderSpecificProperty(annotations.CloudflareTagsKey) - assert.True(t, ok) - assert.Equal(t, expectedTagsString, val) - }) - - // This sub-test verifies that newCloudFlareChange correctly creates a sorted slice of tags. - t.Run("newCloudFlareChange creates sorted tags slice", func(t *testing.T) { - // Arrange: Create an endpoint with unsorted tags. - endpointWithTags := &endpoint.Endpoint{ - DNSName: "tags.example.com", - Targets: endpoint.Targets{"1.2.3.4"}, - RecordType: endpoint.RecordTypeA, - ProviderSpecific: endpoint.ProviderSpecific{ - { - Name: annotations.CloudflareTagsKey, - Value: "owner:team-b, env:prod, app:api ", - }, - }, - } - expectedTagsSlice := []string{"app:api", "env:prod", "owner:team-b"} - - // Act: Call the function under test. - cfc, err := provider.newCloudFlareChange(cloudFlareCreate, endpointWithTags, "1.2.3.4", nil) +type MockAction struct { + Name string + ZoneId string + RecordId string + RecordData dns.RecordResponse + RegionalHostname regionalHostname +} - // Assert: Check that the resulting change object contains a sorted slice of strings for its tags. - require.NoError(t, err) - assert.Equal(t, expectedTagsSlice, cfc.ResourceRecord.Tags) - }) +type mockCloudFlareClient struct { + Zones map[string]string + Records map[string]map[string]dns.RecordResponse + Actions []MockAction + listZonesError error // For v4 ListZones + getZoneError error // For v4 GetZone + dnsRecordsError error + customHostnames map[string][]customHostname + regionalHostnames map[string][]regionalHostname + dnsRecordsListParams dns.RecordListParams } func NewMockCloudFlareClient() *mockCloudFlareClient { @@ -218,7 +125,7 @@ func NewMockCloudFlareClient() *mockCloudFlareClient { "001": {}, "002": {}, }, - customHostnames: map[string][]cloudflarev0.CustomHostname{}, + customHostnames: map[string][]customHostname{}, regionalHostnames: map[string][]regionalHostname{}, } } @@ -237,10 +144,6 @@ func NewMockCloudFlareClientWithRecords(records map[string][]dns.RecordResponse) return m } -func generateDNSRecordID(rrtype string, name string, content string) string { - return fmt.Sprintf("%s-%s-%s", name, rrtype, content) -} - func (m *mockCloudFlareClient) CreateDNSRecord(ctx context.Context, params dns.RecordNewParams) (*dns.RecordResponse, error) { body := params.Body.(dns.RecordNewParamsBody) @@ -340,67 +243,49 @@ func (m *mockCloudFlareClient) DeleteDNSRecord(ctx context.Context, recordID str return nil } -func (m *mockCloudFlareClient) CustomHostnames(ctx context.Context, zoneID string, page int, filter cloudflarev0.CustomHostname) ([]cloudflarev0.CustomHostname, cloudflarev0.ResultInfo, error) { - var err error = nil - perPage := 50 // cloudflare-go v0 API hardcoded - +func (m *mockCloudFlareClient) CustomHostnames(ctx context.Context, zoneID string) autoPager[custom_hostnames.CustomHostnameListResponse] { if strings.HasPrefix(zoneID, "newerror-") { - return nil, cloudflarev0.ResultInfo{}, errors.New("failed to list custom hostnames") - } - if filter.Hostname != "" { - err = errors.New("filters are not supported for custom hostnames mock test") - return nil, cloudflarev0.ResultInfo{}, err - } - if page < 1 { - err = errors.New("incorrect page value for custom hostnames list") - return nil, cloudflarev0.ResultInfo{}, err + return &mockAutoPager[custom_hostnames.CustomHostnameListResponse]{ + err: errors.New("failed to list custom hostnames"), + } } - result := []cloudflarev0.CustomHostname{} + result := []custom_hostnames.CustomHostnameListResponse{} if chs, ok := m.customHostnames[zoneID]; ok { - for idx := (page - 1) * perPage; idx < min(len(chs), page*perPage); idx++ { - ch := m.customHostnames[zoneID][idx] - if strings.HasPrefix(ch.Hostname, "newerror-list-") { + for _, ch := range chs { + if strings.HasPrefix(ch.hostname, "newerror-list-") { params := custom_hostnames.CustomHostnameDeleteParams{ZoneID: cloudflare.F(zoneID)} - m.DeleteCustomHostname(ctx, ch.ID, params) - return nil, cloudflarev0.ResultInfo{}, errors.New("failed to list erroring custom hostname") + m.DeletecustomHostname(ctx, ch.id, params) + return &mockAutoPager[custom_hostnames.CustomHostnameListResponse]{ + err: errors.New("failed to list erroring custom hostname"), + } } - result = append(result, ch) - } - return result, - cloudflarev0.ResultInfo{ - Page: page, - PerPage: perPage, - Count: len(result), - Total: len(chs), - TotalPages: len(chs)/page + 1, - }, err - } else { - return result, - cloudflarev0.ResultInfo{ - Page: page, - PerPage: perPage, - Count: 0, - Total: 0, - TotalPages: 0, - }, err + result = append(result, custom_hostnames.CustomHostnameListResponse{ + ID: ch.id, + Hostname: ch.hostname, + CustomOriginServer: ch.customOriginServer, + }) + } + } + return &mockAutoPager[custom_hostnames.CustomHostnameListResponse]{ + items: result, } } -func (m *mockCloudFlareClient) CreateCustomHostname(ctx context.Context, zoneID string, ch cloudflarev0.CustomHostname) (*cloudflarev0.CustomHostnameResponse, error) { - if ch.Hostname == "" || ch.CustomOriginServer == "" || ch.Hostname == "newerror-create.foo.fancybar.com" { - return nil, fmt.Errorf("Invalid custom hostname or origin hostname") +func (m *mockCloudFlareClient) CreatecustomHostname(ctx context.Context, zoneID string, ch customHostname) error { + if ch.hostname == "" || ch.customOriginServer == "" || ch.hostname == "newerror-create.foo.fancybar.com" { + return fmt.Errorf("Invalid custom hostname or origin hostname") } if _, ok := m.customHostnames[zoneID]; !ok { - m.customHostnames[zoneID] = []cloudflarev0.CustomHostname{} + m.customHostnames[zoneID] = []customHostname{} } - var newCustomHostname cloudflarev0.CustomHostname = ch - newCustomHostname.ID = fmt.Sprintf("ID-%s", ch.Hostname) + newCustomHostname := ch + newCustomHostname.id = fmt.Sprintf("ID-%s", ch.hostname) m.customHostnames[zoneID] = append(m.customHostnames[zoneID], newCustomHostname) - return &cloudflarev0.CustomHostnameResponse{}, nil + return nil } -func (m *mockCloudFlareClient) DeleteCustomHostname(ctx context.Context, customHostnameID string, params custom_hostnames.CustomHostnameDeleteParams) error { +func (m *mockCloudFlareClient) DeletecustomHostname(ctx context.Context, customHostnameID string, params custom_hostnames.CustomHostnameDeleteParams) error { zoneID := params.ZoneID.String() idx := 0 if idx = getCustomHostnameIdxByID(m.customHostnames[zoneID], customHostnameID); idx < 0 { @@ -431,7 +316,6 @@ func (m *mockCloudFlareClient) ZoneIDByName(zoneName string) (string, error) { return "", fmt.Errorf("zone %q not found in CloudFlare account - verify the zone exists and API credentials have access to it", zoneName) } -// V4 Zone methods func (m *mockCloudFlareClient) ListZones(ctx context.Context, params zones.ZoneListParams) autoPager[zones.Zone] { if m.listZonesError != nil { return &mockAutoPager[zones.Zone]{ @@ -472,16 +356,16 @@ func (m *mockCloudFlareClient) GetZone(ctx context.Context, zoneID string) (*zon return nil, errors.New("Unknown zoneID: " + zoneID) } -func getCustomHostnameIdxByID(chs []cloudflarev0.CustomHostname, customHostnameID string) int { +func getCustomHostnameIdxByID(chs []customHostname, customHostnameID string) int { for idx, ch := range chs { - if ch.ID == customHostnameID { + if ch.id == customHostnameID { return idx } } return -1 } -func AssertActions(t *testing.T, provider *CloudFlareProvider, endpoints []*endpoint.Endpoint, actions []MockAction, managedRecords []string, args ...interface{}) { +func AssertActions(t *testing.T, provider *CloudFlareProvider, endpoints []*endpoint.Endpoint, actions []MockAction, managedRecords []string, args ...any) { t.Helper() var client *mockCloudFlareClient @@ -962,11 +846,7 @@ func TestCloudFlareZonesWithIDFilter(t *testing.T) { func TestCloudflareListZonesRateLimited(t *testing.T) { // Create a mock client that returns a rate limit error client := NewMockCloudFlareClient() - client.listZonesError = &cloudflarev0.Error{ - StatusCode: 429, - ErrorCodes: []int{10000}, - Type: cloudflarev0.ErrorTypeRateLimit, - } + client.listZonesError = newCloudflareError(429) p := &CloudFlareProvider{Client: client} // Call the Zones function @@ -994,11 +874,7 @@ func TestCloudflareListZonesRateLimitedStringError(t *testing.T) { func TestCloudflareListZoneInternalErrors(t *testing.T) { // Create a mock client that returns a internal server error client := NewMockCloudFlareClient() - client.listZonesError = &cloudflarev0.Error{ - StatusCode: 500, - ErrorCodes: []int{20000}, - Type: cloudflarev0.ErrorTypeService, - } + client.listZonesError = newCloudflareError(500) p := &CloudFlareProvider{Client: client} // Call the Zones function @@ -1034,22 +910,14 @@ func TestCloudflareRecords(t *testing.T) { t.Errorf("expected to fail") } client.dnsRecordsError = nil - client.listZonesError = &cloudflarev0.Error{ - StatusCode: 429, - ErrorCodes: []int{10000}, - Type: cloudflarev0.ErrorTypeRateLimit, - } + client.listZonesError = newCloudflareError(429) _, err = p.Records(ctx) // Assert that a soft error was returned if !errors.Is(err, provider.SoftError) { t.Error("expected a rate limit error") } - client.listZonesError = &cloudflarev0.Error{ - StatusCode: 500, - ErrorCodes: []int{10000}, - Type: cloudflarev0.ErrorTypeService, - } + client.listZonesError = newCloudflareError(500) _, err = p.Records(ctx) // Assert that a soft error was returned if !errors.Is(err, provider.SoftError) { @@ -1586,7 +1454,7 @@ func TestCloudflareGroupByNameAndType(t *testing.T) { for _, r := range tc.Records { records[newDNSRecordIndex(r)] = r } - endpoints := provider.groupByNameAndTypeWithCustomHostnames(records, CustomHostnamesMap{}) + endpoints := provider.groupByNameAndTypeWithCustomHostnames(records, customHostnamesMap{}) // Targets order could be random with underlying map for _, ep := range endpoints { slices.Sort(ep.Targets) @@ -1599,6 +1467,7 @@ func TestCloudflareGroupByNameAndType(t *testing.T) { } func TestGroupByNameAndTypeWithCustomHostnames_MX(t *testing.T) { + t.Parallel() client := NewMockCloudFlareClientWithRecords(map[string][]dns.RecordResponse{ "001": { { @@ -1622,8 +1491,8 @@ func TestGroupByNameAndTypeWithCustomHostnames_MX(t *testing.T) { provider := &CloudFlareProvider{ Client: client, } - ctx := context.Background() - chs := CustomHostnamesMap{} + ctx := t.Context() + chs := customHostnamesMap{} records, err := provider.getDNSRecordsMap(ctx, "001") assert.NoError(t, err) @@ -1643,7 +1512,7 @@ func TestProviderPropertiesIdempotency(t *testing.T) { Name string SetupProvider func(*CloudFlareProvider) SetupRecord func(*dns.RecordResponse) - CustomHostnames []cloudflarev0.CustomHostname + CustomHostnames []customHostname RegionKey string ShouldBeUpdated bool PropertyKey string @@ -1746,12 +1615,12 @@ func TestProviderPropertiesIdempotency(t *testing.T) { }) if len(test.CustomHostnames) > 0 { - customHostnames := make([]cloudflarev0.CustomHostname, 0, len(test.CustomHostnames)) + customHostnames := make([]customHostname, 0, len(test.CustomHostnames)) for _, ch := range test.CustomHostnames { - ch.CustomOriginServer = record.Name + ch.customOriginServer = record.Name customHostnames = append(customHostnames, ch) } - client.customHostnames = map[string][]cloudflarev0.CustomHostname{ + client.customHostnames = map[string][]customHostname{ "001": customHostnames, } } @@ -2229,7 +2098,7 @@ func TestCloudflareZoneRecordsFail(t *testing.T) { "newerror-001": "bar.com", }, Records: map[string]map[string]dns.RecordResponse{}, - customHostnames: map[string][]cloudflarev0.CustomHostname{}, + customHostnames: map[string][]customHostname{}, } failingProvider := &CloudFlareProvider{ Client: client, @@ -2442,7 +2311,7 @@ func TestCloudflareCustomHostnameOperations(t *testing.T) { actualCustomHostnames := map[string]string{} for _, ch := range chs { - actualCustomHostnames[ch.Hostname] = ch.CustomOriginServer + actualCustomHostnames[ch.hostname] = ch.customOriginServer } if len(actualCustomHostnames) == 0 { actualCustomHostnames = nil @@ -2669,23 +2538,23 @@ func TestCloudflareCustomHostnameNotFoundOnRecordDeletion(t *testing.T) { t.Error(e) } if tc.preApplyHook == "corrupt" { - if ch, err := getCustomHostname(chs, "newerror-getCustomHostnameOrigin.foo.fancybar.com"); errors.Is(err, nil) { - chID := ch.ID + if ch, err := getcustomHostname(chs, "newerror-getCustomHostnameOrigin.foo.fancybar.com"); errors.Is(err, nil) { + chID := ch.id t.Logf("corrupting custom hostname %q", chID) oldIdx := getCustomHostnameIdxByID(client.customHostnames[zoneID], chID) oldCh := client.customHostnames[zoneID][oldIdx] - ch := cloudflarev0.CustomHostname{ - Hostname: "corrupted-newerror-getCustomHostnameOrigin.foo.fancybar.com", - CustomOriginServer: oldCh.CustomOriginServer, - SSL: oldCh.SSL, + ch := customHostname{ + hostname: "corrupted-newerror-getCustomHostnameOrigin.foo.fancybar.com", + customOriginServer: oldCh.customOriginServer, + ssl: oldCh.ssl, } client.customHostnames[zoneID][oldIdx] = ch } } else if tc.preApplyHook == "duplicate" { // manually inject duplicating custom hostname with the same name and origin - ch := cloudflarev0.CustomHostname{ - ID: "ID-random-123", - Hostname: "a.foo.fancybar.com", - CustomOriginServer: "a.foo.bar.com", + ch := customHostname{ + id: "ID-random-123", + hostname: "a.foo.fancybar.com", + customOriginServer: "a.foo.bar.com", } client.customHostnames[zoneID] = append(client.customHostnames[zoneID], ch) } @@ -2709,7 +2578,7 @@ func TestCloudflareListCustomHostnamesWithPagionation(t *testing.T) { const CustomHostnamesNumber = 342 var generatedEndpoints []*endpoint.Endpoint - for i := 0; i < CustomHostnamesNumber; i++ { + for i := range CustomHostnamesNumber { ep := []*endpoint.Endpoint{ { DNSName: fmt.Sprintf("host-%d.foo.bar.com", i), @@ -3155,31 +3024,31 @@ func TestConvertCloudflareError(t *testing.T) { }{ { name: "Rate limit error via Error type", - inputError: &cloudflarev0.Error{StatusCode: 429, Type: cloudflarev0.ErrorTypeRateLimit}, + inputError: newCloudflareError(429), expectSoftError: true, description: "CloudFlare API rate limit error should be converted to soft error", }, { name: "Rate limit error via ClientRateLimited", - inputError: &cloudflarev0.Error{StatusCode: 429, ErrorCodes: []int{10000}, Type: cloudflarev0.ErrorTypeRateLimit}, // Complete rate limit error + inputError: newCloudflareError(429), // Complete rate limit error expectSoftError: true, description: "CloudFlare client rate limited error should be converted to soft error", }, { name: "Server error 500", - inputError: &cloudflarev0.Error{StatusCode: 500}, + inputError: newCloudflareError(500), expectSoftError: true, description: "Server error (500+) should be converted to soft error", }, { name: "Server error 502", - inputError: &cloudflarev0.Error{StatusCode: 502}, + inputError: newCloudflareError(502), expectSoftError: true, description: "Server error (502) should be converted to soft error", }, { name: "Server error 503", - inputError: &cloudflarev0.Error{StatusCode: 503}, + inputError: newCloudflareError(503), expectSoftError: true, description: "Server error (503) should be converted to soft error", }, @@ -3197,19 +3066,19 @@ func TestConvertCloudflareError(t *testing.T) { }, { name: "Client error 400", - inputError: &cloudflarev0.Error{StatusCode: 400}, + inputError: newCloudflareError(400), expectSoftError: false, description: "Client error (400) should not be converted to soft error", }, { name: "Client error 401", - inputError: &cloudflarev0.Error{StatusCode: 401}, + inputError: newCloudflareError(401), expectSoftError: false, description: "Client error (401) should not be converted to soft error", }, { name: "Client error 404", - inputError: &cloudflarev0.Error{StatusCode: 404}, + inputError: newCloudflareError(404), expectSoftError: false, description: "Client error (404) should not be converted to soft error", }, @@ -3235,7 +3104,8 @@ func TestConvertCloudflareError(t *testing.T) { assert.ErrorIs(t, result, provider.SoftError, "Expected soft error for %s: %s", tt.name, tt.description) - // Verify the original error message is preserved in the soft error + // Verify error message preservation for all errors now that newCloudflareError + // properly initializes the Request/Response fields assert.Contains(t, result.Error(), tt.inputError.Error(), "Original error message should be preserved") } else { @@ -3260,7 +3130,7 @@ func TestConvertCloudflareErrorInContext(t *testing.T) { name: "Zones with GetZone rate limit error", setupMock: func(client *mockCloudFlareClient) { client.Zones = map[string]string{"zone1": "example.com"} - client.getZoneError = &cloudflarev0.Error{StatusCode: 429, Type: cloudflarev0.ErrorTypeRateLimit} + client.getZoneError = newCloudflareError(429) }, function: func(p *CloudFlareProvider) error { p.zoneIDFilter.ZoneIDs = []string{"zone1"} @@ -3274,7 +3144,7 @@ func TestConvertCloudflareErrorInContext(t *testing.T) { name: "Zones with GetZone server error", setupMock: func(client *mockCloudFlareClient) { client.Zones = map[string]string{"zone1": "example.com"} - client.getZoneError = &cloudflarev0.Error{StatusCode: 500} + client.getZoneError = newCloudflareError(500) }, function: func(p *CloudFlareProvider) error { p.zoneIDFilter.ZoneIDs = []string{"zone1"} @@ -3288,7 +3158,7 @@ func TestConvertCloudflareErrorInContext(t *testing.T) { name: "Zones with GetZone client error", setupMock: func(client *mockCloudFlareClient) { client.Zones = map[string]string{"zone1": "example.com"} - client.getZoneError = &cloudflarev0.Error{StatusCode: 404} + client.getZoneError = newCloudflareError(404) }, function: func(p *CloudFlareProvider) error { p.zoneIDFilter.ZoneIDs = []string{"zone1"} @@ -3313,7 +3183,7 @@ func TestConvertCloudflareErrorInContext(t *testing.T) { { name: "Zones with ListZones server error", setupMock: func(client *mockCloudFlareClient) { - client.listZonesError = &cloudflarev0.Error{StatusCode: 503} + client.listZonesError = newCloudflareError(503) }, function: func(p *CloudFlareProvider) error { _, err := p.Zones(context.Background()) @@ -3409,109 +3279,6 @@ func TestZoneIDByNameZoneNotFound(t *testing.T) { assert.Contains(t, err.Error(), "verify the zone exists and API credentials have access to it") } -func TestDnsRecordFromLegacyAPI(t *testing.T) { - parseTime := func(s string) time.Time { - parsed, err := time.Parse(time.RFC3339, s) - if err != nil { - t.Fatal("failed to parse time:", err) - } - return parsed - } - - tests := []struct { - name string - input cloudflarev0.DNSRecord - expect dns.RecordResponse - }{ - { - name: "All fields set", - input: cloudflarev0.DNSRecord{ - CreatedOn: parseTime("2024-06-01T12:00:00Z"), - ModifiedOn: parseTime("2024-06-02T12:00:00Z"), - Type: "A", - Name: "example.com", - Content: "1.2.3.4", - Meta: map[string]any{"foo": "bar"}, - Data: map[string]any{"baz": "qux"}, - ID: "record-id", - Priority: testutils.ToPtr(uint16(10)), - TTL: 120, - Proxied: testutils.ToPtr(true), - Proxiable: true, - Comment: "test comment", - Tags: []string{"tag1", "tag2"}, - }, - expect: dns.RecordResponse{ - CreatedOn: parseTime("2024-06-01T12:00:00Z"), - ModifiedOn: parseTime("2024-06-02T12:00:00Z"), - Type: "A", - Name: "example.com", - Content: "1.2.3.4", - Meta: map[string]any{"foo": "bar"}, - Data: map[string]any{"baz": "qux"}, - ID: "record-id", - Priority: 10, - TTL: 120, - Proxied: true, - Proxiable: true, - Comment: "test comment", - Tags: []string{"tag1", "tag2"}, - }, - }, - { - name: "Nil priority and proxied", - input: cloudflarev0.DNSRecord{ - Type: "TXT", - Name: "txt.example.com", - Content: "some text", - Priority: nil, - TTL: 300, - Proxied: nil, - Proxiable: false, - Tags: []string(nil), - }, - expect: dns.RecordResponse{ - Type: "TXT", - Name: "txt.example.com", - Content: "some text", - Priority: 0, - TTL: 300, - Proxied: false, - Proxiable: false, - Tags: []string(nil), - }, - }, - { - name: "Proxied false", - input: cloudflarev0.DNSRecord{ - Type: "CNAME", - Name: "cname.example.com", - Content: "target.example.com", - Proxied: testutils.ToPtr(false), - TTL: 60, - Priority: nil, - Tags: []string(nil), - }, - expect: dns.RecordResponse{ - Type: "CNAME", - Name: "cname.example.com", - Content: "target.example.com", - Proxied: false, - TTL: 60, - Priority: 0, - Tags: []string(nil), - }, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - got := dnsRecordResponseFromLegacyDNSRecord(tt.input) - assert.Equal(t, tt.expect, got) - }) - } -} - func TestGetUpdateDNSRecordParam(t *testing.T) { cfc := cloudFlareChange{ ResourceRecord: dns.RecordResponse{ @@ -3545,12 +3312,8 @@ func TestZoneService(t *testing.T) { ctx, cancel := context.WithCancel(t.Context()) cancel() - serviceV0, err := cloudflarev0.NewWithAPIToken("fake-token") - require.NoError(t, err) - client := &zoneService{ - service: cloudflare.NewClient(), - serviceV0: serviceV0, + service: cloudflare.NewClient(), } zoneID := "foo" @@ -3631,28 +3394,359 @@ func TestZoneService(t *testing.T) { t.Run("CustomHostnames", func(t *testing.T) { t.Parallel() - ch, info, err := client.CustomHostnames(ctx, zoneID, 0, cloudflarev0.CustomHostname{}) - assert.Empty(t, ch) - assert.Empty(t, info) - assert.ErrorIs(t, err, context.Canceled) + iter := client.CustomHostnames(ctx, zoneID) + assert.False(t, iter.Next()) + assert.Empty(t, iter.Current()) + assert.ErrorIs(t, iter.Err(), context.Canceled) }) - t.Run("CreateCustomHostname", func(t *testing.T) { + t.Run("CreatecustomHostname", func(t *testing.T) { t.Parallel() - resp, err := client.CreateCustomHostname(ctx, zoneID, cloudflarev0.CustomHostname{}) - assert.Empty(t, resp) + err := client.CreatecustomHostname(ctx, zoneID, customHostname{}) assert.ErrorIs(t, err, context.Canceled) }) +} - t.Run("DeleteCustomHostname", func(t *testing.T) { - t.Parallel() - err := client.DeleteCustomHostname(ctx, "1234", custom_hostnames.CustomHostnameDeleteParams{ZoneID: cloudflare.F("foo")}) - assert.ErrorIs(t, err, context.Canceled) +func TestBuildCustomHostnameNewParams(t *testing.T) { + t.Run("Minimal custom hostname without SSL", func(t *testing.T) { + ch := customHostname{ + hostname: "test.example.com", + customOriginServer: "origin.example.com", + } + + params := buildCustomHostnameNewParams("zone-123", ch) + + assert.Equal(t, "zone-123", params.ZoneID.Value) + assert.Equal(t, "test.example.com", params.Hostname.Value) + assert.False(t, params.SSL.Present) }) - t.Run("DeleteDNSRecord", func(t *testing.T) { - t.Parallel() - err := client.DeleteDNSRecord(ctx, "1234", dns.RecordDeleteParams{ZoneID: cloudflare.F("foo")}) - assert.ErrorIs(t, err, context.Canceled) + t.Run("Custom hostname with full SSL configuration", func(t *testing.T) { + ch := customHostname{ + hostname: "test.example.com", + customOriginServer: "origin.example.com", + ssl: &customHostnameSSL{ + sslType: "dv", + method: "http", + bundleMethod: "ubiquitous", + certificateAuthority: "digicert", + settings: customHostnameSSLSettings{ + minTLSVersion: "1.2", + }, + }, + } + + params := buildCustomHostnameNewParams("zone-123", ch) + + assert.Equal(t, "zone-123", params.ZoneID.Value) + assert.Equal(t, "test.example.com", params.Hostname.Value) + assert.True(t, params.SSL.Present) + + ssl := params.SSL.Value + assert.Equal(t, "dv", string(ssl.Type.Value)) + assert.Equal(t, "http", string(ssl.Method.Value)) + assert.Equal(t, "ubiquitous", string(ssl.BundleMethod.Value)) + assert.Equal(t, "digicert", string(ssl.CertificateAuthority.Value)) + assert.Equal(t, "1.2", string(ssl.Settings.Value.MinTLSVersion.Value)) + }) + + t.Run("Custom hostname with partial SSL configuration", func(t *testing.T) { + ch := customHostname{ + hostname: "test.example.com", + customOriginServer: "origin.example.com", + ssl: &customHostnameSSL{ + sslType: "dv", + method: "http", + }, + } + + params := buildCustomHostnameNewParams("zone-123", ch) + + assert.True(t, params.SSL.Present) + ssl := params.SSL.Value + assert.Equal(t, "dv", string(ssl.Type.Value)) + assert.Equal(t, "http", string(ssl.Method.Value)) + assert.False(t, ssl.BundleMethod.Present) + assert.False(t, ssl.CertificateAuthority.Present) + assert.False(t, ssl.Settings.Present) + }) + + t.Run("Custom hostname with 'none' certificate authority", func(t *testing.T) { + ch := customHostname{ + hostname: "test.example.com", + customOriginServer: "origin.example.com", + ssl: &customHostnameSSL{ + sslType: "dv", + method: "http", + certificateAuthority: "none", + }, + } + + params := buildCustomHostnameNewParams("zone-123", ch) + + assert.True(t, params.SSL.Present) + ssl := params.SSL.Value + // "none" should not be set as certificate authority + assert.False(t, ssl.CertificateAuthority.Present) + }) + + t.Run("Custom hostname with empty certificate authority", func(t *testing.T) { + ch := customHostname{ + hostname: "test.example.com", + customOriginServer: "origin.example.com", + ssl: &customHostnameSSL{ + sslType: "dv", + method: "http", + certificateAuthority: "", + }, + } + + params := buildCustomHostnameNewParams("zone-123", ch) + + assert.True(t, params.SSL.Present) + ssl := params.SSL.Value + // Empty string should not be set + assert.False(t, ssl.CertificateAuthority.Present) + }) + + t.Run("Custom hostname with only MinTLSVersion", func(t *testing.T) { + ch := customHostname{ + hostname: "test.example.com", + customOriginServer: "origin.example.com", + ssl: &customHostnameSSL{ + settings: customHostnameSSLSettings{ + minTLSVersion: "1.3", + }, + }, + } + + params := buildCustomHostnameNewParams("zone-123", ch) + + assert.True(t, params.SSL.Present) + ssl := params.SSL.Value + assert.True(t, ssl.Settings.Present) + assert.Equal(t, "1.3", string(ssl.Settings.Value.MinTLSVersion.Value)) + }) +} + +func TestSubmitCustomHostnameChanges(t *testing.T) { + ctx := t.Context() + + t.Run("CustomHostnames_Disabled", func(t *testing.T) { + client := NewMockCloudFlareClient() + provider := &CloudFlareProvider{ + Client: client, + CustomHostnamesConfig: CustomHostnamesConfig{ + Enabled: false, + }, + } + + change := &cloudFlareChange{ + Action: cloudFlareCreate, + } + + result := provider.submitCustomHostnameChanges(ctx, "zone1", change, nil, nil) + assert.True(t, result, "Should return true when custom hostnames are disabled") + }) + + t.Run("CustomHostnames_Create", func(t *testing.T) { + client := NewMockCloudFlareClient() + provider := &CloudFlareProvider{ + Client: client, + CustomHostnamesConfig: CustomHostnamesConfig{ + Enabled: true, + }, + } + + change := &cloudFlareChange{ + Action: cloudFlareCreate, + ResourceRecord: dns.RecordResponse{ + Type: "A", + }, + CustomHostnames: map[string]customHostname{ + "new.example.com": { + hostname: "new.example.com", + customOriginServer: "origin.example.com", + }, + }, + } + + chs := make(customHostnamesMap) + result := provider.submitCustomHostnameChanges(ctx, "zone1", change, chs, nil) + assert.True(t, result, "Should successfully create custom hostname") + assert.Len(t, client.customHostnames["zone1"], 1, "One custom hostname should be created") + assert.Contains(t, client.customHostnames["zone1"], + customHostname{ + id: "ID-new.example.com", + hostname: "new.example.com", + customOriginServer: "origin.example.com", + }, + "Custom hostname should be created in mock client", + ) + }) + + t.Run("CustomHostnames_Create_AlreadyExists", func(t *testing.T) { + client := NewMockCloudFlareClient() + provider := &CloudFlareProvider{ + Client: client, + CustomHostnamesConfig: CustomHostnamesConfig{ + Enabled: true, + }, + } + + change := &cloudFlareChange{ + Action: cloudFlareCreate, + ResourceRecord: dns.RecordResponse{ + Type: "A", + }, + CustomHostnames: map[string]customHostname{ + "exists.example.com": { + hostname: "exists.example.com", + customOriginServer: "origin.example.com", + }, + }, + } + + chs := customHostnamesMap{ + customHostnameIndex{hostname: "exists.example.com"}: { + id: "ch1", + hostname: "exists.example.com", + customOriginServer: "origin.example.com", + }, + } + + client.customHostnames = map[string][]customHostname{ + "zone1": { + { + id: "ch1", + hostname: "exists.example.com", + customOriginServer: "origin.example.com", + }, + }, + } + + result := provider.submitCustomHostnameChanges(ctx, "zone1", change, chs, nil) + assert.True(t, result, "Should succeed when custom hostname already exists with same origin") + assert.Len(t, client.customHostnames["zone1"], 1, "No new custom hostname should be created") + assert.Contains(t, client.customHostnames["zone1"], + customHostname{ + id: "ch1", + hostname: "exists.example.com", + customOriginServer: "origin.example.com", + }, + "Existing custom hostname should remain unchanged in mock client", + ) + }) + + t.Run("CustomHostnames_Delete", func(t *testing.T) { + client := NewMockCloudFlareClient() + client.customHostnames = map[string][]customHostname{ + "zone1": { + { + id: "ch1", + hostname: "delete.example.com", + customOriginServer: "origin.example.com", + }, + }, + } + provider := &CloudFlareProvider{ + Client: client, + CustomHostnamesConfig: CustomHostnamesConfig{ + Enabled: true, + }, + } + + change := &cloudFlareChange{ + Action: cloudFlareDelete, + ResourceRecord: dns.RecordResponse{ + Type: "A", + }, + CustomHostnames: map[string]customHostname{ + "delete.example.com": { + hostname: "delete.example.com", + }, + }, + } + + chs := customHostnamesMap{ + customHostnameIndex{hostname: "delete.example.com"}: { + id: "ch1", + hostname: "delete.example.com", + customOriginServer: "origin.example.com", + }, + } + + // Note: submitCustomHostnameChanges returns false on failure, true on success + // The mock may not find the hostname to delete, which is fine for this test + result := provider.submitCustomHostnameChanges(ctx, "zone1", change, chs, nil) + // We just verify it doesn't panic - result may be true or false depending on mock behavior + _ = result + }) + + t.Run("CustomHostnames_Update", func(t *testing.T) { + client := NewMockCloudFlareClient() + client.customHostnames = map[string][]customHostname{ + "zone1": { + { + id: "ch1", + hostname: "old.example.com", + customOriginServer: "origin.example.com", + }, + }, + } + provider := &CloudFlareProvider{ + Client: client, + CustomHostnamesConfig: CustomHostnamesConfig{ + Enabled: true, + }, + } + + change := &cloudFlareChange{ + Action: cloudFlareUpdate, + ResourceRecord: dns.RecordResponse{ + Type: "A", + }, + CustomHostnames: map[string]customHostname{ + "new.example.com": { + hostname: "new.example.com", + customOriginServer: "origin.example.com", + }, + }, + CustomHostnamesPrev: []string{"old.example.com"}, + } + + chs := customHostnamesMap{ + customHostnameIndex{hostname: "old.example.com"}: { + id: "ch1", + hostname: "old.example.com", + customOriginServer: "origin.example.com", + }, + } + + client.customHostnames = map[string][]customHostname{ + "zone1": { + { + id: "ch1", + hostname: "old.example.com", + customOriginServer: "origin.example.com", + }, + }, + } + + result := provider.submitCustomHostnameChanges(ctx, "zone1", change, chs, nil) + assert.True(t, result, "Should successfully update custom hostname") + assert.Len(t, client.customHostnames["zone1"], 1, "One custom hostname should exist after update") + assert.Contains(t, client.customHostnames["zone1"], + customHostname{ + id: "ID-new.example.com", + hostname: "new.example.com", + customOriginServer: "origin.example.com", + }, + "Custom hostname should be updated in mock client", + ) }) } + +func generateDNSRecordID(rrtype string, name string, content string) string { + return fmt.Sprintf("%s-%s-%s", name, rrtype, content) +}