From c2dd31bfb80a3dd6ca218979f3b68696f5454938 Mon Sep 17 00:00:00 2001 From: Sanjay Tripathi Date: Fri, 31 Jul 2026 10:59:14 +0530 Subject: [PATCH] Add intervalSeconds and Infoblox maxResults to ExternalDNS CR Expose sync interval and Infoblox WAPI max results through the ExternalDNS API so operator-managed deployments can configure --interval and --infoblox-max-results without manual Deployment patches. intervalSeconds is provider-agnostic (int32, min 60 / max 3600) with no CRD default; when unset the operator omits --interval and external-dns keeps its own default. maxResults is Infoblox-only (min 0 / max 10000) and is omitted when unset/0. Includes unit coverage in TestDesiredExternalDNSDeployment, a provider-agnostic e2e that verifies real sync spacing from operand logs, and regenerated CRD/bundle manifests. --- api/v1beta1/externaldns_types.go | 19 ++ ...al-dns-operator.clusterserviceversion.yaml | 2 + ...naldns.olm.openshift.io_externaldnses.yaml | 18 ++ ...naldns.olm.openshift.io_externaldnses.yaml | 18 ++ .../operator_v1beta1_infoblox_openshift.yaml | 2 + docs/usage.md | 3 + .../controller/externaldns/deployment_test.go | 112 +++++++++++ pkg/operator/controller/externaldns/pod.go | 8 + test/e2e/operator_test.go | 176 ++++++++++++++++++ 9 files changed, 358 insertions(+) diff --git a/api/v1beta1/externaldns_types.go b/api/v1beta1/externaldns_types.go index 4af220a47..e685ffcae 100644 --- a/api/v1beta1/externaldns_types.go +++ b/api/v1beta1/externaldns_types.go @@ -101,6 +101,16 @@ type ExternalDNSSpec struct { // +kubebuilder:validation:Optional // +optional Zones []string `json:"zones,omitempty"` + + // intervalSeconds specifies the interval in seconds between two consecutive + // synchronizations performed by ExternalDNS. When unset, the default is determined by + // ExternalDNS, which is currently 60 seconds, but is subject to change over time. + // The minimum of 60 seconds is a safe lower bound to avoid hitting provider rate limits. + // + // +kubebuilder:validation:Minimum=60 + // +kubebuilder:validation:Maximum=3600 + // +optional + IntervalSeconds int32 `json:"intervalSeconds,omitempty"` } // ExternalDNSDomain describes how sets of included @@ -361,6 +371,15 @@ type ExternalDNSInfobloxProviderOptions struct { // +kubebuilder:validation:Required // +required WAPIVersion string `json:"wapiVersion"` + + // maxResults sets the maximum number of DNS records that Infoblox returns per request. + // The Infoblox default is currently 1000 and requests exceeding it will fail. Increase this when + // managing zones with more than 1000 DNS records. + // + // +kubebuilder:validation:Minimum=0 + // +kubebuilder:validation:Maximum=10000 + // +optional + MaxResults int `json:"maxResults,omitempty"` } // SecretReference contains the information to let you locate the desired secret. diff --git a/bundle/manifests/external-dns-operator.clusterserviceversion.yaml b/bundle/manifests/external-dns-operator.clusterserviceversion.yaml index d772b0a8c..e52306ffa 100644 --- a/bundle/manifests/external-dns-operator.clusterserviceversion.yaml +++ b/bundle/manifests/external-dns-operator.clusterserviceversion.yaml @@ -307,12 +307,14 @@ metadata: "name": "myzonedomain.com" } ], + "intervalSeconds": 300, "provider": { "infoblox": { "credentials": { "name": "infoblox-credentials" }, "gridHost": "100.100.100.100", + "maxResults": 2000, "wapiPort": 443, "wapiVersion": "2.12.2" }, diff --git a/bundle/manifests/externaldns.olm.openshift.io_externaldnses.yaml b/bundle/manifests/externaldns.olm.openshift.io_externaldnses.yaml index d971607a0..3f1f78a93 100644 --- a/bundle/manifests/externaldns.olm.openshift.io_externaldnses.yaml +++ b/bundle/manifests/externaldns.olm.openshift.io_externaldnses.yaml @@ -692,6 +692,16 @@ spec: - matchType type: object type: array + intervalSeconds: + description: |- + intervalSeconds specifies the interval in seconds between two consecutive + synchronizations performed by ExternalDNS. When unset, the default is determined by + ExternalDNS, which is currently 60 seconds, but is subject to change over time. + The minimum of 60 seconds is a safe lower bound to avoid hitting provider rate limits. + format: int32 + maximum: 3600 + minimum: 60 + type: integer provider: description: |- Provider refers to the DNS provider that ExternalDNS @@ -855,6 +865,14 @@ spec: gridHost: description: GridHost is the IP of the Infoblox Grid host. type: string + maxResults: + description: |- + maxResults sets the maximum number of DNS records that Infoblox returns per request. + The Infoblox default is currently 1000 and requests exceeding it will fail. Increase this when + managing zones with more than 1000 DNS records. + maximum: 10000 + minimum: 0 + type: integer wapiPort: description: WAPIPort is the port for the Infoblox WAPI. type: integer diff --git a/config/crd/bases/externaldns.olm.openshift.io_externaldnses.yaml b/config/crd/bases/externaldns.olm.openshift.io_externaldnses.yaml index f03bb9f6f..58805a2cf 100644 --- a/config/crd/bases/externaldns.olm.openshift.io_externaldnses.yaml +++ b/config/crd/bases/externaldns.olm.openshift.io_externaldnses.yaml @@ -692,6 +692,16 @@ spec: - matchType type: object type: array + intervalSeconds: + description: |- + intervalSeconds specifies the interval in seconds between two consecutive + synchronizations performed by ExternalDNS. When unset, the default is determined by + ExternalDNS, which is currently 60 seconds, but is subject to change over time. + The minimum of 60 seconds is a safe lower bound to avoid hitting provider rate limits. + format: int32 + maximum: 3600 + minimum: 60 + type: integer provider: description: |- Provider refers to the DNS provider that ExternalDNS @@ -855,6 +865,14 @@ spec: gridHost: description: GridHost is the IP of the Infoblox Grid host. type: string + maxResults: + description: |- + maxResults sets the maximum number of DNS records that Infoblox returns per request. + The Infoblox default is currently 1000 and requests exceeding it will fail. Increase this when + managing zones with more than 1000 DNS records. + maximum: 10000 + minimum: 0 + type: integer wapiPort: description: WAPIPort is the port for the Infoblox WAPI. type: integer diff --git a/config/samples/infoblox/operator_v1beta1_infoblox_openshift.yaml b/config/samples/infoblox/operator_v1beta1_infoblox_openshift.yaml index 93edacf23..28faef604 100644 --- a/config/samples/infoblox/operator_v1beta1_infoblox_openshift.yaml +++ b/config/samples/infoblox/operator_v1beta1_infoblox_openshift.yaml @@ -15,6 +15,8 @@ spec: gridHost: "100.100.100.100" wapiPort: 443 wapiVersion: "2.12.2" + maxResults: 2000 + intervalSeconds: 300 source: # Source Type is route resource of OpenShift type: OpenShiftRoute diff --git a/docs/usage.md b/docs/usage.md index 490ba9de0..342172da9 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -210,6 +210,9 @@ the following information is required: gridHost: # the grid master host from the previous step. eg: 172.26.1.200 wapiPort: # the WAPI port, eg: 80, 443, 8080 wapiVersion: # the WAPI version, eg: 2.11, 2.3.1 + maxResults: 2000 # optional; Infoblox default is currently 1000 + # intervalSeconds applies to all providers (optional; ExternalDNS default is currently 60 when unset and may change) + intervalSeconds: 300 zones: # Replace with the desired hosted zones - "ZG5zLm5ldHdvcmtfdmlldyQw" source: diff --git a/pkg/operator/controller/externaldns/deployment_test.go b/pkg/operator/controller/externaldns/deployment_test.go index e69b99705..e34c0493b 100644 --- a/pkg/operator/controller/externaldns/deployment_test.go +++ b/pkg/operator/controller/externaldns/deployment_test.go @@ -1484,6 +1484,111 @@ func TestDesiredExternalDNSDeployment(t *testing.T) { }, }, }, + { + name: "Infoblox with intervalSeconds and maxResults", + inputSecretName: infobloxsecret, + inputExternalDNS: testInfobloxExternalDNSWithSyncOptions(operatorv1beta1.SourceTypeService, 120, 2000), + expectedSpec: appsv1.DeploymentSpec{ + Replicas: &one, + Selector: &metav1.LabelSelector{ + MatchLabels: map[string]string{ + "app.kubernetes.io/name": "external-dns", + "app.kubernetes.io/instance": "test", + }, + }, + Strategy: appsv1.DeploymentStrategy{ + Type: "Recreate", + }, + Template: corev1.PodTemplateSpec{ + ObjectMeta: metav1.ObjectMeta{ + Labels: map[string]string{ + "app.kubernetes.io/name": "external-dns", + "app.kubernetes.io/instance": "test", + }, + Annotations: map[string]string{ + "externaldns.olm.openshift.io/credentials-secret-hash": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + }, + }, + Spec: corev1.PodSpec{ + ServiceAccountName: test.OperandName, + NodeSelector: map[string]string{ + osLabel: linuxOS, + }, + Tolerations: []corev1.Toleration{ + { + Key: masterNodeRoleLabel, + Operator: corev1.TolerationOpExists, + Effect: corev1.TaintEffectNoSchedule, + }, + }, + Containers: []corev1.Container{ + { + Name: ExternalDNSContainerName, + Image: test.OperandImage, + Args: []string{ + "--metrics-address=127.0.0.1:7979", + "--txt-owner-id=external-dns-test", + "--zone-id-filter=my-dns-public-zone", + "--provider=infoblox", + "--source=service", + "--policy=sync", + "--registry=txt", + "--log-level=debug", + "--service-type-filter=NodePort", + "--service-type-filter=LoadBalancer", + "--service-type-filter=ClusterIP", + "--service-type-filter=ExternalName", + "--publish-internal-services", + "--ignore-hostname-annotation", + "--fqdn-template={{.Name}}.test.com", + "--interval=120s", + "--infoblox-wapi-port=443", + "--infoblox-grid-host=gridhost.example.com", + "--infoblox-wapi-version=2.12.2", + "--infoblox-max-results=2000", + "--txt-prefix=external-dns-", + }, + Env: []corev1.EnvVar{ + { + Name: infobloxWAPIUsernameEnvVar, + ValueFrom: &corev1.EnvVarSource{ + SecretKeyRef: &corev1.SecretKeySelector{ + LocalObjectReference: corev1.LocalObjectReference{ + Name: infobloxsecret, + }, + Key: infobloxWAPIUsernameEnvVar, + }, + }, + }, + { + Name: infobloxWAPIPasswordEnvVar, + ValueFrom: &corev1.EnvVarSource{ + SecretKeyRef: &corev1.SecretKeySelector{ + LocalObjectReference: corev1.LocalObjectReference{ + Name: infobloxsecret, + }, + Key: infobloxWAPIPasswordEnvVar, + }, + }, + }, + }, + SecurityContext: &corev1.SecurityContext{ + Capabilities: &corev1.Capabilities{ + Drop: []corev1.Capability{allCapabilities}, + }, + Privileged: ptr.To[bool](false), + RunAsNonRoot: ptr.To[bool](true), + AllowPrivilegeEscalation: ptr.To[bool](false), + SeccompProfile: &corev1.SeccompProfile{ + Type: corev1.SeccompProfileTypeRuntimeDefault, + }, + }, + }, + }, + }, + }, + }, + }, { name: "No credentials Infoblox", inputExternalDNS: testInfobloxExternalDNS(operatorv1beta1.SourceTypeService), @@ -6732,6 +6837,13 @@ func testInfobloxExternalDNS(source operatorv1beta1.ExternalDNSSourceType) *oper return extdns } +func testInfobloxExternalDNSWithSyncOptions(source operatorv1beta1.ExternalDNSSourceType, intervalSeconds int32, maxResults int) *operatorv1beta1.ExternalDNS { + extdns := testInfobloxExternalDNS(source) + extdns.Spec.IntervalSeconds = intervalSeconds + extdns.Spec.Provider.Infoblox.MaxResults = maxResults + return extdns +} + func testAWSExternalDNSDomainFilter(zones []string, source operatorv1beta1.ExternalDNSSourceType) *operatorv1beta1.ExternalDNS { extdns := testCreateDNSFromSourceWRTCloudProvider(source, operatorv1beta1.ProviderTypeAWS, zones, "") extdns.Spec.Domains = []operatorv1beta1.ExternalDNSDomain{ diff --git a/pkg/operator/controller/externaldns/pod.go b/pkg/operator/controller/externaldns/pod.go index be6ab44eb..daf004c4e 100644 --- a/pkg/operator/controller/externaldns/pod.go +++ b/pkg/operator/controller/externaldns/pod.go @@ -215,6 +215,10 @@ func (b *externalDNSContainerBuilder) fillProviderAgnosticFields(seq int, zone s args = append(args, fmt.Sprintf("--openshift-router-name=%s", b.externalDNS.Spec.Source.OpenShiftRoute.RouterName)) } + if b.externalDNS.Spec.IntervalSeconds > 0 { + args = append(args, fmt.Sprintf("--interval=%ds", b.externalDNS.Spec.IntervalSeconds)) + } + filterArgs, err := b.domainFilters() if err != nil { return err @@ -479,6 +483,10 @@ func (b *externalDNSContainerBuilder) fillInfobloxFields(container *corev1.Conta args = append(args, fmt.Sprintf("--infoblox-wapi-version=%s", b.externalDNS.Spec.Provider.Infoblox.WAPIVersion)) } + if b.externalDNS.Spec.Provider.Infoblox.MaxResults > 0 { + args = append(args, fmt.Sprintf("--infoblox-max-results=%d", b.externalDNS.Spec.Provider.Infoblox.MaxResults)) + } + args = addTXTPrefixFlag(args) env := []corev1.EnvVar{ diff --git a/test/e2e/operator_test.go b/test/e2e/operator_test.go index cbd74efd1..ff04a94a2 100644 --- a/test/e2e/operator_test.go +++ b/test/e2e/operator_test.go @@ -4,9 +4,12 @@ package e2e import ( + "bufio" "context" "fmt" + "io" "os" + "regexp" "strconv" "strings" "testing" @@ -25,6 +28,7 @@ import ( metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/types" "k8s.io/apimachinery/pkg/util/wait" + "sigs.k8s.io/controller-runtime/pkg/client" ) const ( @@ -537,8 +541,180 @@ func TestExternalDNSSecretCredentialUpdate(t *testing.T) { } } +func TestExternalDNSSyncInterval(t *testing.T) { + const ( + extDNSName = "test-extdns-interval" + intervalSeconds int32 = 120 + syncLogMessage = "All records are already up to date" + minObservedSyncGap = 115 * time.Second + maxObservedSyncGap = 125 * time.Second + deploymentTimeout = 3 * time.Minute + logCheckTimeout = 6 * time.Minute + ) + logTimestampPattern := regexp.MustCompile(`time="([^"]+)"`) + + t.Log("Creating credentials secret") + credSecret := helper.makeCredentialsSecret(common.OperatorNamespace) + if err := common.KubeClient.Create(context.TODO(), credSecret); err != nil { + t.Fatalf("Failed to create credentials secret %s/%s: %v", credSecret.Namespace, credSecret.Name, err) + } + defer func() { + if err := common.KubeClient.Delete(context.TODO(), credSecret); err != nil && !errors.IsNotFound(err) { + t.Errorf("Failed to delete credentials secret %s/%s: %v", credSecret.Namespace, credSecret.Name, err) + } + }() + + t.Log("Creating external dns instance with intervalSeconds") + extDNS := helper.buildOpenShiftExternalDNS(extDNSName, hostedZoneID, hostedZoneDomain, "", credSecret) + extDNS.Spec.IntervalSeconds = intervalSeconds + if err := common.KubeClient.Create(context.TODO(), &extDNS); err != nil { + t.Fatalf("Failed to create external DNS %q: %v", extDNSName, err) + } + defer func() { + if err := common.KubeClient.Delete(context.TODO(), &extDNS); err != nil && !errors.IsNotFound(err) { + t.Errorf("Failed to delete external DNS %q: %v", extDNSName, err) + } + }() + + deploymentKey := types.NamespacedName{ + Namespace: common.OperatorNamespace, + Name: fmt.Sprintf("external-dns-%s", extDNSName), + } + intervalArg := fmt.Sprintf("--interval=%ds", intervalSeconds) + + t.Logf("Waiting for operand deployment %s with %s", deploymentKey, intervalArg) + var containerName string + if err := wait.PollUntilContextTimeout(context.TODO(), common.DnsPollingInterval, deploymentTimeout, true, func(ctx context.Context) (bool, error) { + deployment := &appsv1.Deployment{} + if err := common.KubeClient.Get(ctx, deploymentKey, deployment); err != nil { + t.Logf("Operand deployment not created yet: %v", err) + return false, nil + } + if deployment.Status.AvailableReplicas < 1 { + t.Log("Operand deployment is not available yet") + return false, nil + } + + for _, container := range deployment.Spec.Template.Spec.Containers { + if !strings.HasPrefix(container.Name, "external-dns") { + continue + } + if containsContainerArg(container.Args, intervalArg) { + containerName = container.Name + return true, nil + } + } + return false, fmt.Errorf("%q was not found", intervalArg) + }); err != nil { + t.Fatalf("Operand deployment did not get expected interval arg: %v", err) + } + + t.Logf("Verifying sync interval from operand logs in %s", deploymentKey.Namespace) + if err := wait.PollUntilContextTimeout(context.TODO(), common.DnsPollingInterval, logCheckTimeout, true, func(ctx context.Context) (bool, error) { + list := &corev1.PodList{} + if err := common.KubeClient.List(ctx, list, + client.InNamespace(deploymentKey.Namespace), + client.MatchingLabels{ + "app.kubernetes.io/name": "external-dns", + "app.kubernetes.io/instance": extDNSName, + }, + ); err != nil { + t.Logf("Failed to list pods: %v", err) + return false, nil + } + var podName string + for i := range list.Items { + if list.Items[i].Status.Phase == corev1.PodRunning { + podName = list.Items[i].Name + break + } + } + if podName == "" { + t.Log("Operand pod is not running yet") + return false, nil + } + + timestamps, err := collectSyncTimestamps(ctx, deploymentKey.Namespace, podName, containerName, syncLogMessage, logTimestampPattern) + if err != nil { + t.Logf("Failed to read pod logs: %v", err) + return false, nil + } + if len(timestamps) < 2 { + t.Logf("Waiting for at least 2 sync log entries, got %d", len(timestamps)) + return false, nil + } + + prev := timestamps[len(timestamps)-2] + curr := timestamps[len(timestamps)-1] + gap := curr.Sub(prev) + t.Logf("Observed sync gap between %s and %s: %s", prev.Format(time.RFC3339), curr.Format(time.RFC3339), gap) + if gap < minObservedSyncGap || gap > maxObservedSyncGap { + t.Logf("Sync gap %s outside expected range [%s, %s]; waiting for another cycle", gap, minObservedSyncGap, maxObservedSyncGap) + return false, nil + } + return true, nil + }); err != nil { + t.Fatalf("Failed to verify sync interval from operand logs: %v", err) + } +} + // HELPER FUNCTIONS +func containsContainerArg(args []string, expected string) bool { + for _, arg := range args { + if arg == expected { + return true + } + } + return false +} + +func collectSyncTimestamps(ctx context.Context, namespace, podName, containerName, syncLogMessage string, logTimestampPattern *regexp.Regexp) ([]time.Time, error) { + opts := &corev1.PodLogOptions{ + Container: containerName, + Follow: false, + } + readCloser, err := common.KubeClientSet.CoreV1().Pods(namespace).GetLogs(podName, opts).Stream(ctx) + if err != nil { + return nil, err + } + defer func() { + _ = readCloser.Close() + }() + + return parseSyncTimestamps(readCloser, syncLogMessage, logTimestampPattern) +} + +func parseSyncTimestamps(r io.Reader, syncLogMessage string, logTimestampPattern *regexp.Regexp) ([]time.Time, error) { + var timestamps []time.Time + scanner := bufio.NewScanner(r) + buf := make([]byte, 0, 64*1024) + scanner.Buffer(buf, 1024*1024) + + for scanner.Scan() { + line := scanner.Text() + if !strings.Contains(line, syncLogMessage) { + continue + } + matches := logTimestampPattern.FindStringSubmatch(line) + if len(matches) != 2 { + continue + } + ts, err := time.Parse(time.RFC3339Nano, matches[1]) + if err != nil { + ts, err = time.Parse(time.RFC3339, matches[1]) + if err != nil { + continue + } + } + timestamps = append(timestamps, ts) + } + if err := scanner.Err(); err != nil { + return nil, err + } + return timestamps, nil +} + func verifyCNAMERecordForOpenshiftRoute(ctx context.Context, t *testing.T, canonicalName, host string) { // try all nameservers and fail only if all failed recordExist := false