Repository: kubernetes-sigs/external-dns (language: go)

Issue:
Oracle DNS: ExternalDNS may hit API Rate limit
<!-- Please use this template while reporting a bug and provide as much info as possible. Not doing so may result in your bug not being addressed in a timely manner. Thanks!

-->

**What happened**:

When using Oracle provider, ExternalDNS may hit the OCI DNS rate limit, especially if multiple clusters in the same tenancy are using ExternalDNS, or if you manage a large amount of records.

When the rate limit is consistently hit, users experience a delay in DNS propagation.

**What you expected to happen**:

ExternalDNS works seamlessly without errors creating/listing records.

**How to reproduce it (as minimally and precisely as possible)**:

1. Install ExternalDNS using the oracle provider on multiple clusters (5+).
2. Create several records (10+) on each cluster using ExternalDNS.
3. See rate limiting errors and delays in DNS propagation.

**Anything else we need to know?**:

Some providers, e.g., AWs, implement DNS zone caching. A similar implementation in the Oracle provider will help with rate limiting by reducing the amount of outbound calls.

**Environment**:
- External-DNS version (use `external-dns --version`): v0.13.1
- DNS provider: oracle
- Others:



Discussion / hints:
I am happy to work on this issue.

Fix patch:
diff --git a/docs/tutorials/oracle.md b/docs/tutorials/oracle.md
index d9fa15fbd5..994f296386 100644
--- a/docs/tutorials/oracle.md
+++ b/docs/tutorials/oracle.md
@@ -180,6 +180,9 @@ spec:
         # Specifies the OCI DNS Zone scope, defaults to GLOBAL.
         # May be GLOBAL, PRIVATE, or an empty value to specify both GLOBAL and PRIVATE OCI DNS Zones
         # - --oci-zone-scope=GLOBAL
+        # Specifies the zone cache duration, defaults to 0s. If set to 0s, the zone cache is disabled.
+        # Use of zone caching is recommended to reduce the amount of requests sent to OCI DNS.
+        # - --oci-zones-cache-duration=0s
         volumeMounts:
           - name: config
             mountPath: /etc/kubernetes/
diff --git a/main.go b/main.go
index b86fea3f7b..d0756f84d3 100644
--- a/main.go
+++ b/main.go
@@ -360,7 +360,7 @@ func main() {
 		} else {
 			config, err = oci.LoadOCIConfig(cfg.OCIConfigFile)
 		}
-
+		config.ZoneCacheDuration = cfg.OCIZoneCacheDuration
 		if err == nil {
 			p, err = oci.NewOCIProvider(*config, domainFilter, zoneIDFilter, cfg.OCIZoneScope, cfg.DryRun)
 		}
diff --git a/pkg/apis/externaldns/types.go b/pkg/apis/externaldns/types.go
index 5d65704c7a..0ae71197d9 100644
--- a/pkg/apis/externaldns/types.go
+++ b/pkg/apis/externaldns/types.go
@@ -136,6 +136,7 @@ type Config struct {
 	OCICompartmentOCID                 string
 	OCIAuthInstancePrincipal           bool
 	OCIZoneScope                       string
+	OCIZoneCacheDuration               time.Duration
 	InMemoryZones                      []string
 	OVHEndpoint                        string
 	OVHApiRateLimit                    int
@@ -293,6 +294,7 @@ var defaultConfig = &Config{
 	InfobloxCacheDuration:       0,
 	OCIConfigFile:               "/etc/kubernetes/oci.yaml",
 	OCIZoneScope:                "GLOBAL",
+	OCIZoneCacheDuration:        0 * time.Second,
 	InMemoryZones:               []string{},
 	OVHEndpoint:                 "ovh-eu",
 	OVHApiRateLimit:             20,
@@ -527,6 +529,7 @@ func (cfg *Config) ParseFlags(args []string) error {
 	app.Flag("oci-compartment-ocid", "When using the OCI provider, specify the OCID of the OCI compartment containing all managed zones and records.  Required when using OCI IAM instance principal authentication.").StringVar(&cfg.OCICompartmentOCID)
 	app.Flag("oci-zone-scope", "When using OCI provider, filter for zones with this scope (optional, options: GLOBAL, PRIVATE). Defaults to GLOBAL, setting to empty value will target both.").Default(defaultConfig.OCIZoneScope).EnumVar(&cfg.OCIZoneScope, "", "GLOBAL", "PRIVATE")
 	app.Flag("oci-auth-instance-principal", "When using the OCI provider, specify whether OCI IAM instance principal authentication should be used (instead of key-based auth via the OCI config file).").Default(strconv.FormatBool(defaultConfig.OCIAuthInstancePrincipal)).BoolVar(&cfg.OCIAuthInstancePrincipal)
+	app.Flag("oci-zones-cache-duration", "When using the OCI provider, set the zones list cache TTL (0s to disable).").Default(defaultConfig.OCIZoneCacheDuration.String()).DurationVar(&cfg.OCIZoneCacheDuration)
 	app.Flag("rcodezero-txt-encrypt", "When using the Rcodezero provider with txt registry option, set if TXT rrs are encrypted (default: false)").Default(strconv.FormatBool(defaultConfig.RcodezeroTXTEncrypt)).BoolVar(&cfg.RcodezeroTXTEncrypt)
 	app.Flag("inmemory-zone", "Provide a list of pre-configured zones for the inmemory provider; specify multiple times for multiple zones (optional)").Default("").StringsVar(&cfg.InMemoryZones)
 	app.Flag("ovh-endpoint", "When using the OVH provider, specify the endpoint (default: ovh-eu)").Default(defaultConfig.OVHEndpoint).StringVar(&cfg.OVHEndpoint)
diff --git a/provider/oci/cache.go b/provider/oci/cache.go
new file mode 100644
index 0000000000..1e099355c8
--- /dev/null
+++ b/provider/oci/cache.go
@@ -0,0 +1,44 @@
+/*
+Copyright 2023 The Kubernetes Authors.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+    http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+package oci
+
+import (
+	"time"
+
+	"github.com/oracle/oci-go-sdk/v65/dns"
+)
+
+type zoneCache struct {
+	age      time.Time
+	duration time.Duration
+	zones    map[string]dns.ZoneSummary
+}
+
+func (z *zoneCache) Reset(zones map[string]dns.ZoneSummary) {
+	if z.duration > time.Duration(0) {
+		z.age = time.Now()
+		z.zones = zones
+	}
+}
+
+func (z *zoneCache) Get() map[string]dns.ZoneSummary {
+	return z.zones
+}
+
+func (z *zoneCache) Expired() bool {
+	return len(z.zones) < 1 || time.Since(z.age) > z.duration
+}
diff --git a/provider/oci/oci.go b/provider/oci/oci.go
index 97b1ea31f9..c811c14d39 100644
--- a/provider/oci/oci.go
+++ b/provider/oci/oci.go
@@ -20,6 +20,7 @@ import (
 	"context"
 	"os"
 	"strings"
+	"time"
 
 	"github.com/oracle/oci-go-sdk/v65/common"
 	"github.com/oracle/oci-go-sdk/v65/common/auth"
@@ -49,8 +50,9 @@ type OCIAuthConfig struct {
 
 // OCIConfig holds the configuration for the OCI Provider.
 type OCIConfig struct {
-	Auth          OCIAuthConfig `yaml:"auth"`
-	CompartmentID string        `yaml:"compartment"`
+	Auth              OCIAuthConfig `yaml:"auth"`
+	CompartmentID     string        `yaml:"compartment"`
+	ZoneCacheDuration time.Duration
 }
 
 // OCIProvider is an implementation of Provider for Oracle Cloud Infrastructure
@@ -63,6 +65,7 @@ type OCIProvider struct {
 	domainFilter endpoint.DomainFilter
 	zoneIDFilter provider.ZoneIDFilter
 	zoneScope    string
+	zoneCache    *zoneCache
 	dryRun       bool
 }
 
@@ -135,11 +138,18 @@ func NewOCIProvider(cfg OCIConfig, domainFilter endpoint.DomainFilter, zoneIDFil
 		domainFilter: domainFilter,
 		zoneIDFilter: zoneIDFilter,
 		zoneScope:    zoneScope,
-		dryRun:       dryRun,
+		zoneCache: &zoneCache{
+			duration: cfg.ZoneCacheDuration,
+		},
+		dryRun: dryRun,
 	}, nil
 }
 
 func (p *OCIProvider) zones(ctx context.Context) (map[string]dns.ZoneSummary, error) {
+	if !p.zoneCache.Expired() {
+		log.Debug("Using cached zones list")
+		return p.zoneCache.zones, nil
+	}
 	zones := make(map[string]dns.ZoneSummary)
 	scopes := []dns.GetZoneScopeEnum{dns.GetZoneScopeEnum(p.zoneScope)}
 	// If zone scope is empty, list all zones types.
@@ -155,6 +165,7 @@ func (p *OCIProvider) zones(ctx context.Context) (map[string]dns.ZoneSummary, er
 	if len(zones) == 0 {
 		log.Warnf("No zones in compartment %q match domain filters %v", p.cfg.CompartmentID, p.domainFilter)
 	}
+	p.zoneCache.Reset(zones)
 	return zones, nil
 }
 


Test patch:
diff --git a/pkg/apis/externaldns/types_test.go b/pkg/apis/externaldns/types_test.go
index b48072f006..a8093fe1eb 100644
--- a/pkg/apis/externaldns/types_test.go
+++ b/pkg/apis/externaldns/types_test.go
@@ -95,6 +95,7 @@ var (
 		InfobloxMaxResults:          0,
 		OCIConfigFile:               "/etc/kubernetes/oci.yaml",
 		OCIZoneScope:                "GLOBAL",
+		OCIZoneCacheDuration:        0 * time.Second,
 		InMemoryZones:               []string{""},
 		OVHEndpoint:                 "ovh-eu",
 		OVHApiRateLimit:             20,
@@ -205,6 +206,7 @@ var (
 		InfobloxMaxResults:          2000,
 		OCIConfigFile:               "oci.yaml",
 		OCIZoneScope:                "PRIVATE",
+		OCIZoneCacheDuration:        30 * time.Second,
 		InMemoryZones:               []string{"example.org", "company.com"},
 		OVHEndpoint:                 "ovh-ca",
 		OVHApiRateLimit:             42,
@@ -328,6 +330,7 @@ func TestParseFlags(t *testing.T) {
 				"--pdns-skip-tls-verify",
 				"--oci-config-file=oci.yaml",
 				"--oci-zone-scope=PRIVATE",
+				"--oci-zones-cache-duration=30s",
 				"--tls-ca=/path/to/ca.crt",
 				"--tls-client-cert=/path/to/cert.pem",
 				"--tls-client-cert-key=/path/to/key.pem",
@@ -449,6 +452,7 @@ func TestParseFlags(t *testing.T) {
 				"EXTERNAL_DNS_INFOBLOX_MAX_RESULTS":            "2000",
 				"EXTERNAL_DNS_OCI_CONFIG_FILE":                 "oci.yaml",
 				"EXTERNAL_DNS_OCI_ZONE_SCOPE":                  "PRIVATE",
+				"EXTERNAL_DNS_OCI_ZONES_CACHE_DURATION":        "30s",
 				"EXTERNAL_DNS_INMEMORY_ZONE":                   "example.org\ncompany.com",
 				"EXTERNAL_DNS_OVH_ENDPOINT":                    "ovh-ca",
 				"EXTERNAL_DNS_OVH_API_RATE_LIMIT":              "42",
diff --git a/provider/oci/cache_test.go b/provider/oci/cache_test.go
new file mode 100644
index 0000000000..a485e200a7
--- /dev/null
+++ b/provider/oci/cache_test.go
@@ -0,0 +1,75 @@
+/*
+Copyright 2023 The Kubernetes Authors.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+    http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+package oci
+
+import (
+	"github.com/oracle/oci-go-sdk/v65/dns"
+	"github.com/stretchr/testify/assert"
+	"testing"
+	"time"
+)
+
+func TestZoneCache(t *testing.T) {
+	now := time.Now()
+	var testCases = map[string]struct {
+		z       *zoneCache
+		expired bool
+	}{
+		"inactive-zone-cache": {
+			&zoneCache{
+				duration: 0 * time.Second,
+			},
+			true,
+		},
+		"empty-active-zone-cache": {
+			&zoneCache{
+				duration: 30 * time.Second,
+			},
+			true,
+		},
+		"expired-zone-cache": {
+			&zoneCache{
+				age:      now.Add(300 * time.Second),
+				duration: 30 * time.Second,
+			},
+			true,
+		},
+		"active-zone-cache": {
+			&zoneCache{
+				zones: map[string]dns.ZoneSummary{
+					zoneIdBaz: testPrivateZoneSummaryBaz,
+				},
+				duration: 30 * time.Second,
+			},
+			true,
+		},
+	}
+
+	for name, testCase := range testCases {
+		t.Run(name, func(t *testing.T) {
+			assert.Equal(t, testCase.expired, testCase.z.Expired())
+			var resetZoneLength = 1
+			if testCase.z.duration == 0 {
+				resetZoneLength = 0
+			}
+			testCase.z.Reset(map[string]dns.ZoneSummary{
+				zoneIdQux: testPrivateZoneSummaryQux,
+			})
+			assert.Len(t, testCase.z.Get(), resetZoneLength)
+		})
+	}
+}
diff --git a/provider/oci/oci_test.go b/provider/oci/oci_test.go
index 2f9bf5fd68..82a82169d2 100644
--- a/provider/oci/oci_test.go
+++ b/provider/oci/oci_test.go
@@ -21,6 +21,7 @@ import (
 	"sort"
 	"strings"
 	"testing"
+	"time"
 
 	"github.com/oracle/oci-go-sdk/v65/common"
 	"github.com/oracle/oci-go-sdk/v65/dns"
@@ -137,7 +138,10 @@ func newOCIProvider(client ociDNSClient, domainFilter endpoint.DomainFilter, zon
 		domainFilter: domainFilter,
 		zoneIDFilter: zoneIDFilter,
 		zoneScope:    zoneScope,
-		dryRun:       dryRun,
+		zoneCache: &zoneCache{
+			duration: 0 * time.Second,
+		},
+		dryRun: dryRun,
 	}
 }