diff --git a/controller/execute.go b/controller/execute.go index 4113fd644e..571440b2b8 100644 --- a/controller/execute.go +++ b/controller/execute.go @@ -199,13 +199,18 @@ func Execute() { zoneIDFilter, cfg.CloudflareProxied, cfg.DryRun, - cfg.CloudflareDNSRecordsPerPage, cfg.CloudflareRegionKey, cloudflare.CustomHostnamesConfig{ Enabled: cfg.CloudflareCustomHostnames, MinTLSVersion: cfg.CloudflareCustomHostnamesMinTLSVersion, CertificateAuthority: cfg.CloudflareCustomHostnamesCertificateAuthority, - }) + }, + cloudflare.DNSRecordsConfig{ + PerPage: cfg.CloudflareDNSRecordsPerPage, + Comment: cfg.CloudflareRecordComment, + Tags: cfg.CloudflareRecordTags, + }, + ) case "google": p, err = google.NewGoogleProvider(ctx, cfg.GoogleProject, domainFilter, zoneIDFilter, cfg.GoogleBatchChangeSize, cfg.GoogleBatchChangeInterval, cfg.GoogleZoneVisibility, cfg.DryRun) case "digitalocean": diff --git a/docs/flags.md b/docs/flags.md index 06af52774f..43a03544eb 100644 --- a/docs/flags.md +++ b/docs/flags.md @@ -94,6 +94,8 @@ | `--cloudflare-custom-hostnames-certificate-authority=google` | When using the Cloudflare provider with the Custom Hostnames, specify which Cerrtificate Authority will be used by default. (default: google, options: google, ssl_com, lets_encrypt) | | `--cloudflare-dns-records-per-page=100` | When using the Cloudflare provider, specify how many DNS records listed per page, max possible 5,000 (default: 100) | | `--cloudflare-region-key=CLOUDFLARE-REGION-KEY` | When using the Cloudflare provider, specify the region (default: earth) | +| `--cloudflare-record-comment=""` | When using the Cloudflare provider, specify the comment for the DNS records (default: '') | +| `--cloudflare-record-tags=""` | When using the Cloudflare provider, specify the tags for the DNS records as a comma-separated string (default: '') | | `--coredns-prefix="/skydns/"` | When using the CoreDNS provider, specify the prefix name | | `--akamai-serviceconsumerdomain=""` | When using the Akamai provider, specify the base URL (required when --provider=akamai and edgerc-path not specified) | | `--akamai-client-token=""` | When using the Akamai provider, specify the client token (required when --provider=akamai and edgerc-path not specified) | diff --git a/pkg/apis/externaldns/types.go b/pkg/apis/externaldns/types.go index b8e8d5c9b1..b4ae141e33 100644 --- a/pkg/apis/externaldns/types.go +++ b/pkg/apis/externaldns/types.go @@ -112,6 +112,8 @@ type Config struct { CloudflareCustomHostnamesCertificateAuthority string CloudflareDNSRecordsPerPage int CloudflareRegionKey string + CloudflareRecordComment string + CloudflareRecordTags string CoreDNSPrefix string AkamaiServiceConsumerDomain string AkamaiClientToken string @@ -256,129 +258,130 @@ var defaultConfig = &Config{ CloudflareDNSRecordsPerPage: 100, CloudflareProxied: false, CloudflareRegionKey: "earth", - - CombineFQDNAndAnnotation: false, - Compatibility: "", - ConnectorSourceServer: "localhost:8080", - CoreDNSPrefix: "/skydns/", - CRDSourceAPIVersion: "externaldns.k8s.io/v1alpha1", - CRDSourceKind: "DNSEndpoint", - DefaultTargets: []string{}, - DigitalOceanAPIPageSize: 50, - DomainFilter: []string{}, - DryRun: false, - ExcludeDNSRecordTypes: []string{}, - ExcludeDomains: []string{}, - ExcludeTargetNets: []string{}, - ExcludeUnschedulable: true, - ExoscaleAPIEnvironment: "api", - ExoscaleAPIKey: "", - ExoscaleAPISecret: "", - ExoscaleAPIZone: "ch-gva-2", - ExposeInternalIPV6: true, - FQDNTemplate: "", - GatewayLabelFilter: "", - GatewayName: "", - GatewayNamespace: "", - GlooNamespaces: []string{"gloo-system"}, - GoDaddyAPIKey: "", - GoDaddyOTE: false, - GoDaddySecretKey: "", - GoDaddyTTL: 600, - GoogleBatchChangeInterval: time.Second, - GoogleBatchChangeSize: 1000, - GoogleProject: "", - GoogleZoneVisibility: "", - IBMCloudConfigFile: "/etc/kubernetes/ibmcloud.json", - IBMCloudProxied: false, - IgnoreHostnameAnnotation: false, - IgnoreIngressRulesSpec: false, - IgnoreIngressTLSSpec: false, - IngressClassNames: nil, - InMemoryZones: []string{}, - Interval: time.Minute, - KubeConfig: "", - LabelFilter: labels.Everything().String(), - LogFormat: "text", - LogLevel: logrus.InfoLevel.String(), - ManagedDNSRecordTypes: []string{endpoint.RecordTypeA, endpoint.RecordTypeAAAA, endpoint.RecordTypeCNAME}, - MetricsAddress: ":7979", - MinEventSyncInterval: 5 * time.Second, - Namespace: "", - NAT64Networks: []string{}, - NS1Endpoint: "", - NS1IgnoreSSL: false, - OCIConfigFile: "/etc/kubernetes/oci.yaml", - OCIZoneCacheDuration: 0 * time.Second, - OCIZoneScope: "GLOBAL", - Once: false, - OVHApiRateLimit: 20, - OVHEnableCNAMERelative: false, - OVHEndpoint: "ovh-eu", - PDNSAPIKey: "", - PDNSServer: "http://localhost:8081", - PDNSServerID: "localhost", - PDNSSkipTLSVerify: false, - PiholeApiVersion: "5", - PiholePassword: "", - PiholeServer: "", - PiholeTLSInsecureSkipVerify: false, - PluralCluster: "", - PluralProvider: "", - PodSourceDomain: "", - Policy: "sync", - Provider: "", - ProviderCacheTime: 0, - PublishHostIP: false, - PublishInternal: false, - RegexDomainExclusion: regexp.MustCompile(""), - RegexDomainFilter: regexp.MustCompile(""), - Registry: "txt", - RequestTimeout: time.Second * 30, - RFC2136BatchChangeSize: 50, - RFC2136GSSTSIG: false, - RFC2136Host: []string{""}, - RFC2136Insecure: false, - RFC2136KerberosPassword: "", - RFC2136KerberosRealm: "", - RFC2136KerberosUsername: "", - RFC2136LoadBalancingStrategy: "disabled", - RFC2136MinTTL: 0, - RFC2136Port: 0, - RFC2136SkipTLSVerify: false, - RFC2136TAXFR: true, - RFC2136TSIGKeyName: "", - RFC2136TSIGSecret: "", - RFC2136TSIGSecretAlg: "", - RFC2136UseTLS: false, - RFC2136Zone: []string{}, - ServiceTypeFilter: []string{}, - SkipperRouteGroupVersion: "zalando.org/v1", - Sources: nil, - TargetNetFilter: []string{}, - TencentCloudConfigFile: "/etc/kubernetes/tencent-cloud.json", - TencentCloudZoneType: "", - TLSCA: "", - TLSClientCert: "", - TLSClientCertKey: "", - TraefikDisableLegacy: false, - TraefikDisableNew: false, - TransIPAccountName: "", - TransIPPrivateKeyFile: "", - TXTCacheInterval: 0, - TXTEncryptAESKey: "", - TXTEncryptEnabled: false, - TXTNewFormatOnly: false, - TXTOwnerID: "default", - TXTPrefix: "", - TXTSuffix: "", - TXTWildcardReplacement: "", - UpdateEvents: false, - WebhookProviderReadTimeout: 5 * time.Second, - WebhookProviderURL: "http://localhost:8888", - WebhookProviderWriteTimeout: 10 * time.Second, - WebhookServer: false, - ZoneIDFilter: []string{}, + CloudflareRecordComment: "", + CloudflareRecordTags: "", + CombineFQDNAndAnnotation: false, + Compatibility: "", + ConnectorSourceServer: "localhost:8080", + CoreDNSPrefix: "/skydns/", + CRDSourceAPIVersion: "externaldns.k8s.io/v1alpha1", + CRDSourceKind: "DNSEndpoint", + DefaultTargets: []string{}, + DigitalOceanAPIPageSize: 50, + DomainFilter: []string{}, + DryRun: false, + ExcludeDNSRecordTypes: []string{}, + ExcludeDomains: []string{}, + ExcludeTargetNets: []string{}, + ExcludeUnschedulable: true, + ExoscaleAPIEnvironment: "api", + ExoscaleAPIKey: "", + ExoscaleAPISecret: "", + ExoscaleAPIZone: "ch-gva-2", + ExposeInternalIPV6: true, + FQDNTemplate: "", + GatewayLabelFilter: "", + GatewayName: "", + GatewayNamespace: "", + GlooNamespaces: []string{"gloo-system"}, + GoDaddyAPIKey: "", + GoDaddyOTE: false, + GoDaddySecretKey: "", + GoDaddyTTL: 600, + GoogleBatchChangeInterval: time.Second, + GoogleBatchChangeSize: 1000, + GoogleProject: "", + GoogleZoneVisibility: "", + IBMCloudConfigFile: "/etc/kubernetes/ibmcloud.json", + IBMCloudProxied: false, + IgnoreHostnameAnnotation: false, + IgnoreIngressRulesSpec: false, + IgnoreIngressTLSSpec: false, + IngressClassNames: nil, + InMemoryZones: []string{}, + Interval: time.Minute, + KubeConfig: "", + LabelFilter: labels.Everything().String(), + LogFormat: "text", + LogLevel: logrus.InfoLevel.String(), + ManagedDNSRecordTypes: []string{endpoint.RecordTypeA, endpoint.RecordTypeAAAA, endpoint.RecordTypeCNAME}, + MetricsAddress: ":7979", + MinEventSyncInterval: 5 * time.Second, + Namespace: "", + NAT64Networks: []string{}, + NS1Endpoint: "", + NS1IgnoreSSL: false, + OCIConfigFile: "/etc/kubernetes/oci.yaml", + OCIZoneCacheDuration: 0 * time.Second, + OCIZoneScope: "GLOBAL", + Once: false, + OVHApiRateLimit: 20, + OVHEnableCNAMERelative: false, + OVHEndpoint: "ovh-eu", + PDNSAPIKey: "", + PDNSServer: "http://localhost:8081", + PDNSServerID: "localhost", + PDNSSkipTLSVerify: false, + PiholeApiVersion: "5", + PiholePassword: "", + PiholeServer: "", + PiholeTLSInsecureSkipVerify: false, + PluralCluster: "", + PluralProvider: "", + PodSourceDomain: "", + Policy: "sync", + Provider: "", + ProviderCacheTime: 0, + PublishHostIP: false, + PublishInternal: false, + RegexDomainExclusion: regexp.MustCompile(""), + RegexDomainFilter: regexp.MustCompile(""), + Registry: "txt", + RequestTimeout: time.Second * 30, + RFC2136BatchChangeSize: 50, + RFC2136GSSTSIG: false, + RFC2136Host: []string{""}, + RFC2136Insecure: false, + RFC2136KerberosPassword: "", + RFC2136KerberosRealm: "", + RFC2136KerberosUsername: "", + RFC2136LoadBalancingStrategy: "disabled", + RFC2136MinTTL: 0, + RFC2136Port: 0, + RFC2136SkipTLSVerify: false, + RFC2136TAXFR: true, + RFC2136TSIGKeyName: "", + RFC2136TSIGSecret: "", + RFC2136TSIGSecretAlg: "", + RFC2136UseTLS: false, + RFC2136Zone: []string{}, + ServiceTypeFilter: []string{}, + SkipperRouteGroupVersion: "zalando.org/v1", + Sources: nil, + TargetNetFilter: []string{}, + TencentCloudConfigFile: "/etc/kubernetes/tencent-cloud.json", + TencentCloudZoneType: "", + TLSCA: "", + TLSClientCert: "", + TLSClientCertKey: "", + TraefikDisableLegacy: false, + TraefikDisableNew: false, + TransIPAccountName: "", + TransIPPrivateKeyFile: "", + TXTCacheInterval: 0, + TXTEncryptAESKey: "", + TXTEncryptEnabled: false, + TXTNewFormatOnly: false, + TXTOwnerID: "default", + TXTPrefix: "", + TXTSuffix: "", + TXTWildcardReplacement: "", + UpdateEvents: false, + WebhookProviderReadTimeout: 5 * time.Second, + WebhookProviderURL: "http://localhost:8888", + WebhookProviderWriteTimeout: 10 * time.Second, + WebhookServer: false, + ZoneIDFilter: []string{}, } // NewConfig returns new Config object @@ -530,12 +533,16 @@ func App(cfg *Config) *kingpin.Application { app.Flag("tencent-cloud-config-file", "When using the Tencent Cloud provider, specify the Tencent Cloud configuration file (required when --provider=tencentcloud)").Default(defaultConfig.TencentCloudConfigFile).StringVar(&cfg.TencentCloudConfigFile) app.Flag("tencent-cloud-zone-type", "When using the Tencent Cloud provider, filter for zones with visibility (optional, options: public, private)").Default(defaultConfig.TencentCloudZoneType).EnumVar(&cfg.TencentCloudZoneType, "", "public", "private") + // Flags related to Cloudflare app.Flag("cloudflare-proxied", "When using the Cloudflare provider, specify if the proxy mode must be enabled (default: disabled)").BoolVar(&cfg.CloudflareProxied) app.Flag("cloudflare-custom-hostnames", "When using the Cloudflare provider, specify if the Custom Hostnames feature will be used. Requires \"Cloudflare for SaaS\" enabled. (default: disabled)").BoolVar(&cfg.CloudflareCustomHostnames) app.Flag("cloudflare-custom-hostnames-min-tls-version", "When using the Cloudflare provider with the Custom Hostnames, specify which Minimum TLS Version will be used by default. (default: 1.0, options: 1.0, 1.1, 1.2, 1.3)").Default("1.0").EnumVar(&cfg.CloudflareCustomHostnamesMinTLSVersion, "1.0", "1.1", "1.2", "1.3") app.Flag("cloudflare-custom-hostnames-certificate-authority", "When using the Cloudflare provider with the Custom Hostnames, specify which Cerrtificate Authority will be used by default. (default: google, options: google, ssl_com, lets_encrypt)").Default("google").EnumVar(&cfg.CloudflareCustomHostnamesCertificateAuthority, "google", "ssl_com", "lets_encrypt") app.Flag("cloudflare-dns-records-per-page", "When using the Cloudflare provider, specify how many DNS records listed per page, max possible 5,000 (default: 100)").Default(strconv.Itoa(defaultConfig.CloudflareDNSRecordsPerPage)).IntVar(&cfg.CloudflareDNSRecordsPerPage) app.Flag("cloudflare-region-key", "When using the Cloudflare provider, specify the region (default: earth)").StringVar(&cfg.CloudflareRegionKey) + app.Flag("cloudflare-record-comment", "When using the Cloudflare provider, specify the comment for the DNS records (default: '')").Default("").StringVar(&cfg.CloudflareRecordComment) + app.Flag("cloudflare-record-tags", "When using the Cloudflare provider, specify the tags for the DNS records as a comma-separated string (default: '')").Default("").StringVar(&cfg.CloudflareRecordTags) + app.Flag("coredns-prefix", "When using the CoreDNS provider, specify the prefix name").Default(defaultConfig.CoreDNSPrefix).StringVar(&cfg.CoreDNSPrefix) app.Flag("akamai-serviceconsumerdomain", "When using the Akamai provider, specify the base URL (required when --provider=akamai and edgerc-path not specified)").Default(defaultConfig.AkamaiServiceConsumerDomain).StringVar(&cfg.AkamaiServiceConsumerDomain) app.Flag("akamai-client-token", "When using the Akamai provider, specify the client token (required when --provider=akamai and edgerc-path not specified)").Default(defaultConfig.AkamaiClientToken).StringVar(&cfg.AkamaiClientToken) diff --git a/provider/cloudflare/cloudflare.go b/provider/cloudflare/cloudflare.go index e89d82cf11..8e661e378a 100644 --- a/provider/cloudflare/cloudflare.go +++ b/provider/cloudflare/cloudflare.go @@ -30,6 +30,7 @@ import ( cloudflare "github.com/cloudflare/cloudflare-go" log "github.com/sirupsen/logrus" + "golang.org/x/net/publicsuffix" "sigs.k8s.io/external-dns/endpoint" "sigs.k8s.io/external-dns/plan" @@ -46,6 +47,12 @@ const ( cloudFlareUpdate = "UPDATE" // defaultTTL 1 = automatic defaultTTL = 1 + + // Cloudflare tier limitations https://developers.cloudflare.com/dns/manage-dns-records/reference/record-attributes/#availability + // freeZoneCommentMaxLength is the maximum length of a DNS record comment on free zones + freeZoneCommentMaxLength = 100 + // paidZoneCommentMaxLength is the maximum length of a DNS record comment on paid zones + paidZoneCommentMaxLength = 500 ) // We have to use pointers to bools now, as the upstream cloudflare-go library requires them @@ -93,6 +100,20 @@ type CustomHostnamesConfig struct { CertificateAuthority string } +type DNSRecordsConfig struct { + PerPage int + Comment string + Tags string +} + +func (c *DNSRecordsConfig) GetTags() []string { + if c.Tags == "" { + return nil + } + tags := strings.Split(c.Tags, ",") + return tags +} + var recordTypeCustomHostnameSupported = map[string]bool{ "A": true, "CNAME": true, @@ -198,9 +219,9 @@ type CloudFlareProvider struct { domainFilter endpoint.DomainFilter zoneIDFilter provider.ZoneIDFilter proxiedByDefault bool - CustomHostnamesConfig CustomHostnamesConfig DryRun bool - DNSRecordsPerPage int + CustomHostnamesConfig CustomHostnamesConfig + DNSRecordsConfig DNSRecordsConfig RegionKey string } @@ -226,6 +247,8 @@ func updateDNSRecordParam(cfc cloudFlareChange) cloudflare.UpdateDNSRecordParams Proxied: cfc.ResourceRecord.Proxied, Type: cfc.ResourceRecord.Type, Content: cfc.ResourceRecord.Content, + Comment: &cfc.ResourceRecord.Comment, + Tags: cfc.ResourceRecord.Tags, } } @@ -253,11 +276,13 @@ func getCreateDNSRecordParam(cfc cloudFlareChange) cloudflare.CreateDNSRecordPar Proxied: cfc.ResourceRecord.Proxied, Type: cfc.ResourceRecord.Type, Content: cfc.ResourceRecord.Content, + Comment: cfc.ResourceRecord.Comment, + Tags: cfc.ResourceRecord.Tags, } } // NewCloudFlareProvider initializes a new CloudFlare DNS based Provider. -func NewCloudFlareProvider(domainFilter endpoint.DomainFilter, zoneIDFilter provider.ZoneIDFilter, proxiedByDefault bool, dryRun bool, dnsRecordsPerPage int, regionKey string, customHostnamesConfig CustomHostnamesConfig) (*CloudFlareProvider, error) { +func NewCloudFlareProvider(domainFilter endpoint.DomainFilter, zoneIDFilter provider.ZoneIDFilter, proxiedByDefault bool, dryRun bool, regionKey string, customHostnamesConfig CustomHostnamesConfig, dnsRecordsConfig DNSRecordsConfig) (*CloudFlareProvider, error) { // initialize via chosen auth method and returns new API object var ( config *cloudflare.API @@ -287,8 +312,8 @@ func NewCloudFlareProvider(domainFilter endpoint.DomainFilter, zoneIDFilter prov proxiedByDefault: proxiedByDefault, CustomHostnamesConfig: customHostnamesConfig, DryRun: dryRun, - DNSRecordsPerPage: dnsRecordsPerPage, RegionKey: regionKey, + DNSRecordsConfig: dnsRecordsConfig, }, nil } @@ -341,6 +366,28 @@ func (p *CloudFlareProvider) Zones(ctx context.Context) ([]cloudflare.Zone, erro return result, nil } +// ZoneHasPaidPlan returns wether a zone has a paid plan or not +func (p *CloudFlareProvider) ZoneHasPaidPlan(hostname string) bool { + zone, err := publicsuffix.EffectiveTLDPlusOne(hostname) + if err != nil { + log.Errorf("Failed to get effective TLD+1 for hostname %s %v", hostname, err) + return false + } + zoneID, err := p.Client.ZoneIDByName(zone) + if err != nil { + log.Errorf("Failed to get zone %s by name %v", zone, err) + return false + } + + zoneDetails, err := p.Client.ZoneDetails(context.Background(), zoneID) + if err != nil { + log.Errorf("Failed to get zone %s details %v", zone, err) + return false + } + + return zoneDetails.Plan.IsSubscribed +} + // Records returns the list of records. func (p *CloudFlareProvider) Records(ctx context.Context) ([]*endpoint.Endpoint, error) { zones, err := p.Zones(ctx) @@ -825,6 +872,39 @@ func (p *CloudFlareProvider) newCloudFlareChange(action string, ep *endpoint.End RegionKey: regionKey, } } + + comment := p.DNSRecordsConfig.Comment + if val, ok := ep.GetProviderSpecificProperty(source.CloudflareRecordCommentKey); ok { + comment = val + } + + if len(comment) > paidZoneCommentMaxLength { + log.Warnf("DNS record comment is invalid. Trimming comment of %s. To avoid endless syncs, please set it to less than %d chars for free zones and less than %d chars for paid zones.", ep.DNSName, freeZoneCommentMaxLength, paidZoneCommentMaxLength) + comment = comment[:paidZoneCommentMaxLength-1] + } + + tags := p.DNSRecordsConfig.GetTags() + if val, ok := ep.GetProviderSpecificProperty(source.CloudflareRecordTagsKey); ok { + tags = strings.Split(val, ",") + } + + // Free account checks + if tags != nil || len(comment) > freeZoneCommentMaxLength { + free := !p.ZoneHasPaidPlan(ep.DNSName) + if free && tags != nil { + log.Infof("DNS tags are only available for paid accounts, skipping for %s.", ep.DNSName) + tags = nil + } + if free && len(comment) > freeZoneCommentMaxLength { + log.Warnf("DNS record comment is limited to %d chars for free zones, trimming comment of %s. Please set it to less than %d characters to avoid endless syncs.", freeZoneCommentMaxLength, ep.DNSName, freeZoneCommentMaxLength) + comment = comment[:freeZoneCommentMaxLength-1] + } + + if len(tags) > 1 { + sort.Strings(tags) + } + } + return &cloudFlareChange{ Action: action, ResourceRecord: cloudflare.DNSRecord{ @@ -835,6 +915,8 @@ func (p *CloudFlareProvider) newCloudFlareChange(action string, ep *endpoint.End Proxied: &proxied, Type: ep.RecordType, Content: target, + Comment: comment, + Tags: tags, }, RegionalHostname: regionalHostname, CustomHostnamesPrev: prevCustomHostnames, @@ -850,7 +932,7 @@ func newDNSRecordIndex(r cloudflare.DNSRecord) DNSRecordIndex { func (p *CloudFlareProvider) listDNSRecordsWithAutoPagination(ctx context.Context, zoneID string) (DNSRecordsMap, error) { // for faster getRecordID lookup records := make(DNSRecordsMap) - resultInfo := cloudflare.ResultInfo{PerPage: p.DNSRecordsPerPage, Page: 1} + resultInfo := cloudflare.ResultInfo{PerPage: p.DNSRecordsConfig.PerPage, Page: 1} params := cloudflare.ListDNSRecordsParams{ResultInfo: resultInfo} for { pageRecords, resultInfo, err := p.Client.ListDNSRecords(ctx, cloudflare.ZoneIdentifier(zoneID), params) @@ -1021,6 +1103,15 @@ func groupByNameAndTypeWithCustomHostnames(records DNSRecordsMap, chs CustomHost e = e.WithProviderSpecific(source.CloudflareCustomHostnameKey, strings.Join(customHostnames, ",")) } + if records[0].Comment != "" { + e = e.WithProviderSpecific(source.CloudflareRecordCommentKey, records[0].Comment) + } + + if len(records[0].Tags) > 0 { + tags := records[0].Tags + e = e.WithProviderSpecific(source.CloudflareRecordTagsKey, strings.Join(tags, ",")) + } + endpoints = append(endpoints, e) } diff --git a/provider/cloudflare/cloudflare_test.go b/provider/cloudflare/cloudflare_test.go index f509401428..74f152fba7 100644 --- a/provider/cloudflare/cloudflare_test.go +++ b/provider/cloudflare/cloudflare_test.go @@ -35,6 +35,7 @@ import ( "sigs.k8s.io/external-dns/internal/testutils" "sigs.k8s.io/external-dns/plan" "sigs.k8s.io/external-dns/provider" + "sigs.k8s.io/external-dns/source" ) type MockAction struct { @@ -65,6 +66,8 @@ var ExampleDomain = []cloudflare.DNSRecord{ TTL: 120, Content: "1.2.3.4", Proxied: proxyDisabled, + Comment: "needed for example", + Tags: []string{"example"}, }, { ID: "2345678901", @@ -123,6 +126,8 @@ func getDNSRecordFromRecordParams(rp any) cloudflare.DNSRecord { Proxied: params.Proxied, Type: params.Type, Content: params.Content, + Comment: params.Comment, + Tags: params.Tags, } case cloudflare.UpdateDNSRecordParams: return cloudflare.DNSRecord{ @@ -132,6 +137,8 @@ func getDNSRecordFromRecordParams(rp any) cloudflare.DNSRecord { Proxied: params.Proxied, Type: params.Type, Content: params.Content, + Comment: *params.Comment, + Tags: params.Tags, } default: return cloudflare.DNSRecord{} @@ -420,6 +427,9 @@ func (m *mockCloudFlareClient) ZoneDetails(ctx context.Context, zoneID string) ( return cloudflare.Zone{ ID: zoneID, Name: zoneName, + Plan: cloudflare.ZonePlan{ + IsSubscribed: strings.HasSuffix(zoneName, "bar.com"), + }, }, nil } } @@ -979,8 +989,8 @@ func TestCloudflareRecords(t *testing.T) { // Set DNSRecordsPerPage to 1 test the pagination behaviour p := &CloudFlareProvider{ - Client: client, - DNSRecordsPerPage: 1, + Client: client, + DNSRecordsConfig: DNSRecordsConfig{PerPage: 1}, } ctx := context.Background() @@ -1096,9 +1106,14 @@ func TestCloudflareProvider(t *testing.T) { provider.NewZoneIDFilter([]string{""}), false, true, - 5000, "", - CustomHostnamesConfig{Enabled: false}) + CustomHostnamesConfig{Enabled: false}, + DNSRecordsConfig{ + PerPage: 5000, + Comment: "tests", + Tags: "external-dns-test,external-dns-test2", + }, + ) if err != nil && !tc.ShouldFail { t.Errorf("should not fail, %s", err) } @@ -1764,9 +1779,14 @@ func TestCloudFlareProvider_Region(t *testing.T) { provider.ZoneIDFilter{}, true, false, - 50, "us", - CustomHostnamesConfig{Enabled: false}) + CustomHostnamesConfig{Enabled: false}, + DNSRecordsConfig{ + PerPage: 1, + Comment: "tests", + Tags: "external-dns-test,external-dns-test2", + }, + ) if err != nil { t.Fatal(err) } @@ -1780,27 +1800,76 @@ func TestCloudFlareProvider_newCloudFlareChange(t *testing.T) { _ = os.Setenv("CF_API_KEY", "xxxxxxxxxxxxxxxxx") _ = os.Setenv("CF_API_EMAIL", "test@test.com") provider, err := NewCloudFlareProvider( - endpoint.NewDomainFilter([]string{"example.com"}), + endpoint.NewDomainFilter([]string{"example.com", "bar.com"}), provider.ZoneIDFilter{}, true, false, - 50, "us", - CustomHostnamesConfig{Enabled: false}) + CustomHostnamesConfig{Enabled: false}, + DNSRecordsConfig{ + PerPage: 50, + Comment: "tests", + Tags: "external-dns-test,external-dns-test2", + }, + ) if err != nil { t.Fatal(err) } - endpoint := &endpoint.Endpoint{ + endpointFreeZone := &endpoint.Endpoint{ DNSName: "example.com", RecordType: "A", Targets: []string{"192.0.2.1"}, } + var freeCommentBuilder strings.Builder + for range freeZoneCommentMaxLength + 1 { + freeCommentBuilder.WriteString("x") + } + endpointFreeZone = endpointFreeZone.WithProviderSpecific(source.CloudflareRecordTagsKey, "non-supported-tag") + freeComment := freeCommentBuilder.String() + endpointFreeZone = endpointFreeZone.WithProviderSpecific(source.CloudflareRecordCommentKey, freeComment) + changeFreeZone := provider.newCloudFlareChange(cloudFlareCreate, endpointFreeZone, endpointFreeZone.Targets[0], nil) + if changeFreeZone.RegionalHostname.RegionKey != "us" { + t.Errorf("expected region key to be 'us', but got '%s'", changeFreeZone.RegionalHostname.RegionKey) + } - change := provider.newCloudFlareChange(cloudFlareCreate, endpoint, endpoint.Targets[0], nil) - if change.RegionalHostname.RegionKey != "us" { - t.Errorf("expected region key to be 'us', but got '%s'", change.RegionalHostname.RegionKey) + endpointPaidZone := &endpoint.Endpoint{ + DNSName: "bar.com", + RecordType: "A", + Targets: []string{"192.0.2.1"}, + } + var paidCommentBuilder strings.Builder + for range paidZoneCommentMaxLength + 1 { + paidCommentBuilder.WriteString("x") + } + endpointPaidZone = endpointPaidZone.WithProviderSpecific(source.CloudflareRecordTagsKey, "kubernetes,external-dns") + paidComment := paidCommentBuilder.String() + endpointPaidZone = endpointPaidZone.WithProviderSpecific(source.CloudflareRecordCommentKey, paidComment) + changePaidZone := provider.newCloudFlareChange(cloudFlareCreate, endpointPaidZone, endpointPaidZone.Targets[0], nil) + if changePaidZone.RegionalHostname.RegionKey != "us" { + t.Errorf("expected region key to be 'us', but got '%s'", changePaidZone.RegionalHostname.RegionKey) + } +} + +func TestZoneHasPaidPlan(t *testing.T) { + client := NewMockCloudFlareClient() + cfprovider := &CloudFlareProvider{ + Client: client, + domainFilter: endpoint.NewDomainFilter([]string{"foo.com", "bar.com"}), + zoneIDFilter: provider.NewZoneIDFilter([]string{""}), + } + + assert.Equal(t, false, cfprovider.ZoneHasPaidPlan("subdomain.foo.com")) + assert.Equal(t, true, cfprovider.ZoneHasPaidPlan("subdomain.bar.com")) + assert.Equal(t, false, cfprovider.ZoneHasPaidPlan("invaliddomain")) + + client.zoneDetailsError = errors.New("zone lookup failed") + cfproviderWithZoneError := &CloudFlareProvider{ + Client: client, + domainFilter: endpoint.NewDomainFilter([]string{"foo.com", "bar.com"}), + zoneIDFilter: provider.NewZoneIDFilter([]string{""}), } + assert.Equal(t, false, cfproviderWithZoneError.ZoneHasPaidPlan("subdomain.foo.com")) } func TestCloudFlareProvider_submitChangesCNAME(t *testing.T) { diff --git a/source/source.go b/source/source.go index fbcfd61ba3..1b759b401a 100644 --- a/source/source.go +++ b/source/source.go @@ -73,6 +73,8 @@ const ( CloudflareProxiedKey = "external-dns.alpha.kubernetes.io/cloudflare-proxied" CloudflareCustomHostnameKey = "external-dns.alpha.kubernetes.io/cloudflare-custom-hostname" CloudflareRegionKey = "external-dns.alpha.kubernetes.io/cloudflare-region-key" + CloudflareRecordCommentKey = "external-dns.alpha.kubernetes.io/cloudflare-record-comment" + CloudflareRecordTagsKey = "external-dns.alpha.kubernetes.io/cloudflare-record-tags" SetIdentifierKey = "external-dns.alpha.kubernetes.io/set-identifier" ) @@ -181,24 +183,38 @@ func getAliasFromAnnotations(ants map[string]string) bool { func getProviderSpecificAnnotations(ants map[string]string) (endpoint.ProviderSpecific, string) { providerSpecificAnnotations := endpoint.ProviderSpecific{} - if v, exists := ants[CloudflareProxiedKey]; exists { + if v, ok := ants[CloudflareProxiedKey]; ok { providerSpecificAnnotations = append(providerSpecificAnnotations, endpoint.ProviderSpecificProperty{ Name: CloudflareProxiedKey, Value: v, }) } - if v, exists := ants[CloudflareCustomHostnameKey]; exists { + if v, ok := ants[CloudflareCustomHostnameKey]; ok { providerSpecificAnnotations = append(providerSpecificAnnotations, endpoint.ProviderSpecificProperty{ Name: CloudflareCustomHostnameKey, Value: v, }) } - if v, exists := ants[CloudflareRegionKey]; exists { + if v, ok := ants[CloudflareRegionKey]; ok { providerSpecificAnnotations = append(providerSpecificAnnotations, endpoint.ProviderSpecificProperty{ Name: CloudflareRegionKey, Value: v, }) } + + if v, ok := ants[CloudflareRecordCommentKey]; ok { + providerSpecificAnnotations = append(providerSpecificAnnotations, endpoint.ProviderSpecificProperty{ + Name: CloudflareRecordCommentKey, + Value: v, + }) + } + if v, ok := ants[CloudflareRecordTagsKey]; ok { + providerSpecificAnnotations = append(providerSpecificAnnotations, endpoint.ProviderSpecificProperty{ + Name: CloudflareRecordTagsKey, + Value: v, + }) + } + if getAliasFromAnnotations(ants) { providerSpecificAnnotations = append(providerSpecificAnnotations, endpoint.ProviderSpecificProperty{ Name: "alias", diff --git a/source/source_test.go b/source/source_test.go index 413995a63f..8221db47be 100644 --- a/source/source_test.go +++ b/source/source_test.go @@ -209,6 +209,57 @@ func TestGetProviderSpecificCloudflareAnnotations(t *testing.T) { t.Errorf("Cloudflare provider specific annotation %s is not set correctly to %v", tc.expectedKey, tc.expectedValue) }) } + + for _, tc := range []struct { + title string + annotations map[string]string + expectedKey string + expectedValue string + }{ + { + title: "Cloudflare comment is set", + annotations: map[string]string{CloudflareRecordCommentKey: "test"}, + expectedKey: CloudflareRecordCommentKey, + expectedValue: "test", + }, + { + title: "Cloudflare comment is set among other annotations", + annotations: map[string]string{ + "random annotation 1": "random value 1", + CloudflareRecordCommentKey: "test", + "random annotation 2": "random value 2", + }, + expectedKey: CloudflareRecordCommentKey, + expectedValue: "test", + }, + { + title: "Cloudflare tags are set", + annotations: map[string]string{CloudflareRecordTagsKey: "test1,test2"}, + expectedKey: CloudflareRecordTagsKey, + expectedValue: "test1,test2", + }, + { + title: "Cloudflare tags are set among other annotations", + annotations: map[string]string{ + "random annotation 1": "random value 1", + CloudflareRecordTagsKey: "test1,test2", + "random annotation 2": "random value 2", + }, + expectedKey: CloudflareRecordTagsKey, + expectedValue: "test1,test2", + }, + } { + t.Run(tc.title, func(t *testing.T) { + providerSpecificAnnotations, _ := getProviderSpecificAnnotations(tc.annotations) + for _, providerSpecificAnnotation := range providerSpecificAnnotations { + if providerSpecificAnnotation.Name == tc.expectedKey { + assert.Equal(t, tc.expectedValue, providerSpecificAnnotation.Value) + return + } + } + t.Errorf("Cloudflare provider specific annotation %s is not set correctly to %v", tc.expectedKey, tc.expectedValue) + }) + } } func TestGetProviderSpecificAliasAnnotations(t *testing.T) {