From 28ae6538cb06121b58791973e37c4f9865f8be5b Mon Sep 17 00:00:00 2001 From: Ben Vincent Date: Sat, 4 Jul 2026 22:29:55 +1000 Subject: [PATCH] Add a primary (write) Service routing to pod-0 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Secondaries reject RFC2136/nsupdate writes, but the read Service round- robins across all pods. Add an optional per-cluster write endpoint that targets only the primary pod (ordinal 0) via the StatefulSet pod-name label. Reads keep using the all-pods Service. - api: BindCluster.spec.primaryService (*ClusterServiceSpec) — ClusterIP for in-cluster writers (external-dns) or LoadBalancer for external - reconcilePrimaryService creates -primary selecting pod-0 when set, deletes it when unset - regenerate CRDs + install.yaml --- api/v1alpha1/bindcluster_types.go | 10 +++- api/v1alpha1/zz_generated.deepcopy.go | 5 ++ .../bases/bind.unkin.net_bindclusters.yaml | 38 ++++++++++++++- config/crd/install.yaml | 38 ++++++++++++++- internal/controller/bindcluster_controller.go | 47 ++++++++++++++++++- internal/controller/helpers.go | 19 ++++++-- 6 files changed, 148 insertions(+), 9 deletions(-) diff --git a/api/v1alpha1/bindcluster_types.go b/api/v1alpha1/bindcluster_types.go index 6bb66e4..0f50de5 100644 --- a/api/v1alpha1/bindcluster_types.go +++ b/api/v1alpha1/bindcluster_types.go @@ -105,10 +105,18 @@ type BindClusterSpec struct { // +optional Resources corev1.ResourceRequirements `json:"resources,omitempty"` - // Service controls how the cluster is exposed. + // Service controls how the cluster is exposed for reads (all pods). // +optional Service ClusterServiceSpec `json:"service,omitempty"` + // PrimaryService, when set, creates an additional Service routing only to the + // primary pod (ordinal 0) — the write endpoint for RFC2136/nsupdate, since + // secondaries reject updates. Reads still use Service (all pods). Use + // ClusterIP for in-cluster writers (e.g. external-dns) or LoadBalancer for + // external writers. + // +optional + PrimaryService *ClusterServiceSpec `json:"primaryService,omitempty"` + // NodeSelector for the BIND pods. // +optional NodeSelector map[string]string `json:"nodeSelector,omitempty"` diff --git a/api/v1alpha1/zz_generated.deepcopy.go b/api/v1alpha1/zz_generated.deepcopy.go index 05e2ff8..3e3210b 100644 --- a/api/v1alpha1/zz_generated.deepcopy.go +++ b/api/v1alpha1/zz_generated.deepcopy.go @@ -301,6 +301,11 @@ func (in *BindClusterSpec) DeepCopyInto(out *BindClusterSpec) { } in.Resources.DeepCopyInto(&out.Resources) in.Service.DeepCopyInto(&out.Service) + if in.PrimaryService != nil { + in, out := &in.PrimaryService, &out.PrimaryService + *out = new(ClusterServiceSpec) + (*in).DeepCopyInto(*out) + } if in.NodeSelector != nil { in, out := &in.NodeSelector, &out.NodeSelector *out = make(map[string]string, len(*in)) diff --git a/config/crd/bases/bind.unkin.net_bindclusters.yaml b/config/crd/bases/bind.unkin.net_bindclusters.yaml index 7e595aa..0adab69 100644 --- a/config/crd/bases/bind.unkin.net_bindclusters.yaml +++ b/config/crd/bases/bind.unkin.net_bindclusters.yaml @@ -1013,6 +1013,41 @@ spec: type: string description: NodeSelector for the BIND pods. type: object + primaryService: + description: |- + PrimaryService, when set, creates an additional Service routing only to the + primary pod (ordinal 0) — the write endpoint for RFC2136/nsupdate, since + secondaries reject updates. Reads still use Service (all pods). Use + ClusterIP for in-cluster writers (e.g. external-dns) or LoadBalancer for + external writers. + properties: + annotations: + additionalProperties: + type: string + description: Annotations added to the client-facing Service (e.g. + PureLB/MetalLB hints). + type: object + externalTrafficPolicy: + description: |- + ExternalTrafficPolicy for a LoadBalancer/NodePort Service. Local preserves + client source IPs (required for source-IP ACLs on the DNS servers) but + only routes to nodes running a pod. Defaults to Cluster. + enum: + - Cluster + - Local + type: string + loadBalancerIP: + description: LoadBalancerIP requests a specific address when Type + is LoadBalancer. + type: string + type: + description: Type of the client-facing Service. Defaults to ClusterIP. + enum: + - ClusterIP + - LoadBalancer + - NodePort + type: string + type: object recursion: description: |- Recursion overrides the default per-mode recursion setting. When nil, @@ -1086,7 +1121,8 @@ spec: type: object type: object service: - description: Service controls how the cluster is exposed. + description: Service controls how the cluster is exposed for reads + (all pods). properties: annotations: additionalProperties: diff --git a/config/crd/install.yaml b/config/crd/install.yaml index 712be2f..d33523f 100644 --- a/config/crd/install.yaml +++ b/config/crd/install.yaml @@ -1318,6 +1318,41 @@ spec: type: string description: NodeSelector for the BIND pods. type: object + primaryService: + description: |- + PrimaryService, when set, creates an additional Service routing only to the + primary pod (ordinal 0) — the write endpoint for RFC2136/nsupdate, since + secondaries reject updates. Reads still use Service (all pods). Use + ClusterIP for in-cluster writers (e.g. external-dns) or LoadBalancer for + external writers. + properties: + annotations: + additionalProperties: + type: string + description: Annotations added to the client-facing Service (e.g. + PureLB/MetalLB hints). + type: object + externalTrafficPolicy: + description: |- + ExternalTrafficPolicy for a LoadBalancer/NodePort Service. Local preserves + client source IPs (required for source-IP ACLs on the DNS servers) but + only routes to nodes running a pod. Defaults to Cluster. + enum: + - Cluster + - Local + type: string + loadBalancerIP: + description: LoadBalancerIP requests a specific address when Type + is LoadBalancer. + type: string + type: + description: Type of the client-facing Service. Defaults to ClusterIP. + enum: + - ClusterIP + - LoadBalancer + - NodePort + type: string + type: object recursion: description: |- Recursion overrides the default per-mode recursion setting. When nil, @@ -1391,7 +1426,8 @@ spec: type: object type: object service: - description: Service controls how the cluster is exposed. + description: Service controls how the cluster is exposed for reads + (all pods). properties: annotations: additionalProperties: diff --git a/internal/controller/bindcluster_controller.go b/internal/controller/bindcluster_controller.go index 99e6300..9889d43 100644 --- a/internal/controller/bindcluster_controller.go +++ b/internal/controller/bindcluster_controller.go @@ -277,7 +277,52 @@ func (r *BindClusterReconciler) reconcileServices(ctx context.Context, c *bindv1 if svcType == corev1.ServiceTypeLoadBalancer || svcType == corev1.ServiceTypeNodePort { client.Spec.ExternalTrafficPolicy = c.Spec.Service.ExternalTrafficPolicy } - return r.upsertService(ctx, c, client) + if err := r.upsertService(ctx, c, client); err != nil { + return err + } + + // Primary (write) Service: routes only to pod-0. Created when configured, + // deleted when removed. + return r.reconcilePrimaryService(ctx, c, dnsPorts) +} + +func (r *BindClusterReconciler) reconcilePrimaryService(ctx context.Context, c *bindv1alpha1.BindCluster, dnsPorts []corev1.ServicePort) error { + name := primaryServiceName(c.Name) + if c.Spec.PrimaryService == nil { + var existing corev1.Service + err := r.Get(ctx, types.NamespacedName{Namespace: c.Namespace, Name: name}, &existing) + if apierrors.IsNotFound(err) { + return nil + } + if err != nil { + return err + } + return client.IgnoreNotFound(r.Delete(ctx, &existing)) + } + + ps := c.Spec.PrimaryService + psType := ps.Type + if psType == "" { + psType = corev1.ServiceTypeClusterIP + } + svc := &corev1.Service{ + ObjectMeta: metav1.ObjectMeta{ + Name: name, + Namespace: c.Namespace, + Labels: commonLabels(c.Name), + Annotations: ps.Annotations, + }, + Spec: corev1.ServiceSpec{ + Type: psType, + Selector: primaryPodSelector(c.Name), + Ports: dnsPorts, + LoadBalancerIP: ps.LoadBalancerIP, + }, + } + if psType == corev1.ServiceTypeLoadBalancer || psType == corev1.ServiceTypeNodePort { + svc.Spec.ExternalTrafficPolicy = ps.ExternalTrafficPolicy + } + return r.upsertService(ctx, c, svc) } func (r *BindClusterReconciler) reconcileStatefulSet(ctx context.Context, c *bindv1alpha1.BindCluster) (*appsv1.StatefulSet, error) { diff --git a/internal/controller/helpers.go b/internal/controller/helpers.go index 52dd2ca..83318fe 100644 --- a/internal/controller/helpers.go +++ b/internal/controller/helpers.go @@ -29,11 +29,20 @@ const ( ) func headlessServiceName(cluster string) string { return cluster + "-headless" } -func clientServiceName(cluster string) string { return cluster } -func primaryPodName(cluster string) string { return cluster + "-0" } -func configMapName(cluster string) string { return cluster + "-config" } -func keysSecretName(cluster string) string { return cluster + "-keys" } -func rndcSecretName(cluster string) string { return cluster + "-rndc" } +func primaryServiceName(cluster string) string { return cluster + "-primary" } + +// primaryPodSelector selects only the primary pod (ordinal 0) via the stable +// StatefulSet pod-name label, for the write Service. +func primaryPodSelector(cluster string) map[string]string { + s := commonLabels(cluster) + s["statefulset.kubernetes.io/pod-name"] = primaryPodName(cluster) + return s +} +func clientServiceName(cluster string) string { return cluster } +func primaryPodName(cluster string) string { return cluster + "-0" } +func configMapName(cluster string) string { return cluster + "-config" } +func keysSecretName(cluster string) string { return cluster + "-keys" } +func rndcSecretName(cluster string) string { return cluster + "-rndc" } // primaryAddress is the in-cluster DNS name of the primary pod (ordinal 0). func primaryAddress(cluster, namespace string) string {