d5e08607c1
Dynamically-updated primary zones were only reaching the secondary pods on the hardcoded 1h SOA refresh: the operator emitted no NOTIFY, and a zone's only apex NS is the primary itself, so default 'notify yes' reached no one. Queries load-balanced across the serve VIP hit stale secondaries and returned NXDOMAIN (negatively cached downstream for the 300s SOA minimum), so records flapped for up to an hour after every update. Add 'notify explicit' + 'also-notify' with the secondary pod IPs to primary zone stanzas so an update NOTIFYs the secondaries for an immediate IXFR. Applied via modzone, so existing zones pick it up on the next reconcile. Also shorten the seed SOA refresh/retry/minimum as a fallback for missed NOTIFYs and to shrink stale-NXDOMAIN negative caching.
281 lines
11 KiB
Go
281 lines
11 KiB
Go
package controller
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"strings"
|
|
|
|
"k8s.io/apimachinery/pkg/runtime"
|
|
ctrl "sigs.k8s.io/controller-runtime"
|
|
"sigs.k8s.io/controller-runtime/pkg/client"
|
|
"sigs.k8s.io/controller-runtime/pkg/controller/controllerutil"
|
|
"sigs.k8s.io/controller-runtime/pkg/log"
|
|
|
|
bindv1alpha1 "git.unkin.net/unkin/bind-operator/api/v1alpha1"
|
|
"git.unkin.net/unkin/bind-operator/internal/bind"
|
|
)
|
|
|
|
// BindZoneReconciler provisions zones on a cluster primary via rndc addzone and
|
|
// seeds records via dynamic update.
|
|
type BindZoneReconciler struct {
|
|
client.Client
|
|
Scheme *runtime.Scheme
|
|
Exec *bind.Executor
|
|
}
|
|
|
|
// +kubebuilder:rbac:groups=bind.unkin.net,resources=bindzones,verbs=get;list;watch;create;update;patch;delete
|
|
// +kubebuilder:rbac:groups=bind.unkin.net,resources=bindzones/status,verbs=get;update;patch
|
|
// +kubebuilder:rbac:groups=bind.unkin.net,resources=bindcatalogzones;bindtsigkeys,verbs=get;list;watch
|
|
|
|
func (r *BindZoneReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) {
|
|
logger := log.FromContext(ctx)
|
|
|
|
var zone bindv1alpha1.BindZone
|
|
if err := r.Get(ctx, req.NamespacedName, &zone); err != nil {
|
|
return ctrl.Result{}, client.IgnoreNotFound(err)
|
|
}
|
|
|
|
// Forward zones are pure configuration rendered into named.conf by the
|
|
// BindCluster controller (on every pod), not added dynamically to the
|
|
// primary. Nothing to do here beyond reporting readiness.
|
|
if zone.Spec.Type == bindv1alpha1.ZoneForward {
|
|
zone.Status.Phase = "Ready"
|
|
zone.Status.ObservedGeneration = zone.Generation
|
|
setReady(&zone.Status.Conditions, zone.Generation, true, "Configured", "forward zone rendered into named.conf")
|
|
if err := r.Status().Update(ctx, &zone); err != nil {
|
|
return ctrl.Result{}, err
|
|
}
|
|
return ctrl.Result{}, nil
|
|
}
|
|
|
|
cluster, err := getCluster(ctx, r.Client, zone.Namespace, zone.Spec.ClusterRef)
|
|
if err != nil {
|
|
return r.setPhase(ctx, &zone, "Error", "ClusterMissing", err.Error())
|
|
}
|
|
primaryPod := primaryPodName(cluster.Name)
|
|
|
|
// Handle deletion via finalizer: remove the zone from the primary and catalog.
|
|
if !zone.DeletionTimestamp.IsZero() {
|
|
if controllerutil.ContainsFinalizer(&zone, finalizer) {
|
|
if primaryReady(ctx, r.Client, cluster) && r.Exec != nil {
|
|
_ = r.Exec.DelZone(ctx, zone.Namespace, primaryPod, zone.Spec.ZoneName, zone.Spec.ViewRef)
|
|
r.deregisterCatalog(ctx, &zone, cluster, primaryPod)
|
|
}
|
|
controllerutil.RemoveFinalizer(&zone, finalizer)
|
|
if err := r.Update(ctx, &zone); err != nil {
|
|
return ctrl.Result{}, err
|
|
}
|
|
}
|
|
return ctrl.Result{}, nil
|
|
}
|
|
|
|
if !controllerutil.ContainsFinalizer(&zone, finalizer) {
|
|
controllerutil.AddFinalizer(&zone, finalizer)
|
|
if err := r.Update(ctx, &zone); err != nil {
|
|
return ctrl.Result{}, err
|
|
}
|
|
}
|
|
|
|
if !primaryReady(ctx, r.Client, cluster) || r.Exec == nil {
|
|
return r.setPhase(ctx, &zone, "Pending", "PrimaryNotReady", "waiting for cluster primary to be ready")
|
|
}
|
|
|
|
// Primary zones replicated to secondaries (catalog members) get an
|
|
// also-notify pointing at the secondary pods, so a dynamic update NOTIFYs
|
|
// them immediately rather than waiting for the SOA refresh.
|
|
var notifyTargets []string
|
|
if isPrimaryType(zone.Spec.Type) && catalogEnabled(&zone) {
|
|
notifyTargets = secondaryPodIPs(ctx, r.Client, cluster)
|
|
}
|
|
|
|
zoneConfig, err := r.buildZoneConfig(ctx, &zone, r.zoneTransferKeyRef(ctx, &zone, cluster), notifyTargets)
|
|
if err != nil {
|
|
return r.setPhase(ctx, &zone, "Error", "ConfigError", err.Error())
|
|
}
|
|
|
|
created := !r.Exec.ZoneExists(ctx, zone.Namespace, primaryPod, zone.Spec.ZoneName, zone.Spec.ViewRef)
|
|
if created && (zone.Spec.Type == bindv1alpha1.ZonePrimary || zone.Spec.Type == "") {
|
|
primaryIP := primaryPodIP(ctx, r.Client, cluster)
|
|
if primaryIP == "" {
|
|
return r.setPhase(ctx, &zone, "Pending", "PrimaryNoIP", "waiting for primary pod IP")
|
|
}
|
|
if err := r.Exec.WriteSeedZone(ctx, zone.Namespace, primaryPod, zone.Spec.ZoneName, bind.ZoneFilePath(zone.Spec.ZoneName), primaryIP, 1); err != nil {
|
|
return r.setPhase(ctx, &zone, "Error", "SeedFailed", err.Error())
|
|
}
|
|
}
|
|
if err := r.Exec.AddZone(ctx, zone.Namespace, primaryPod, zone.Spec.ZoneName, zone.Spec.ViewRef, zoneConfig); err != nil {
|
|
return r.setPhase(ctx, &zone, "Error", "AddZoneFailed", err.Error())
|
|
}
|
|
|
|
// Seed static records (primary zones only).
|
|
recordCount := 0
|
|
if isPrimaryType(zone.Spec.Type) && len(zone.Spec.Records) > 0 {
|
|
creds, err := r.zoneUpdateCreds(ctx, &zone)
|
|
if err != nil {
|
|
return r.setPhase(ctx, &zone, "Error", "NoUpdateKey", err.Error())
|
|
}
|
|
updates := recordsToUpdates(zone.Spec.ZoneName, zone.Spec.Records, zone.Spec.DefaultTTL)
|
|
if err := r.Exec.NSUpdate(ctx, zone.Namespace, primaryPod, zone.Spec.ZoneName, creds, updates); err != nil {
|
|
return r.setPhase(ctx, &zone, "Error", "RecordUpdateFailed", err.Error())
|
|
}
|
|
recordCount = len(updates)
|
|
}
|
|
|
|
// Register in the catalog so secondaries auto-provision.
|
|
if catalogEnabled(&zone) {
|
|
r.registerCatalog(ctx, &zone, cluster, primaryPod)
|
|
}
|
|
|
|
serial, _ := r.Exec.ZoneSerial(ctx, zone.Namespace, primaryPod, zone.Spec.ZoneName, zone.Spec.ViewRef)
|
|
zone.Status.Phase = "Ready"
|
|
zone.Status.Serial = serial
|
|
zone.Status.RecordCount = int32(recordCount)
|
|
zone.Status.Signed = zone.Spec.DNSSECPolicyRef != ""
|
|
zone.Status.ObservedGeneration = zone.Generation
|
|
setReady(&zone.Status.Conditions, zone.Generation, true, "Provisioned", "zone provisioned on primary")
|
|
if err := r.Status().Update(ctx, &zone); err != nil {
|
|
return ctrl.Result{}, err
|
|
}
|
|
logger.Info("zone reconciled", "zone", zone.Spec.ZoneName, "serial", serial)
|
|
return ctrl.Result{RequeueAfter: requeueLong}, nil
|
|
}
|
|
|
|
// buildZoneConfig renders the inner clause passed to rndc addzone/modzone.
|
|
// transferKey, when set, is the catalog transfer TSIG key name; catalog member
|
|
// primary zones must allow AXFR with it so secondaries can pull them.
|
|
func (r *BindZoneReconciler) buildZoneConfig(ctx context.Context, zone *bindv1alpha1.BindZone, transferKey string, notifyTargets []string) (string, error) {
|
|
zType := zone.Spec.Type
|
|
if zType == "" {
|
|
zType = bindv1alpha1.ZonePrimary
|
|
}
|
|
var parts []string
|
|
switch zType {
|
|
case bindv1alpha1.ZonePrimary:
|
|
parts = append(parts, "type primary", fmt.Sprintf("file \"%s\"", bind.ZoneFilePath(zone.Spec.ZoneName)))
|
|
if zone.Spec.DynamicUpdate && zone.Spec.UpdateKeyRef != "" {
|
|
parts = append(parts, fmt.Sprintf("allow-update { key \"%s\"; }", updateKeyName(ctx, r.Client, zone)))
|
|
}
|
|
switch {
|
|
case len(zone.Spec.AllowTransfer) > 0:
|
|
parts = append(parts, fmt.Sprintf("allow-transfer { %s }", matchListInline(zone.Spec.AllowTransfer)))
|
|
case transferKey != "":
|
|
// Catalog member: permit key-authenticated AXFR from secondaries.
|
|
parts = append(parts, fmt.Sprintf("allow-transfer { key \"%s\"; }", transferKey))
|
|
}
|
|
// NOTIFY only the secondaries we know about (their apex NS is the primary
|
|
// itself, so default `notify yes` would reach no one). `notify explicit`
|
|
// keeps NOTIFY off the query-serving VIP and scoped to the pod IPs.
|
|
if len(notifyTargets) > 0 {
|
|
parts = append(parts, "notify explicit", fmt.Sprintf("also-notify { %s }", terminateInline(notifyTargets)))
|
|
}
|
|
if zone.Spec.DNSSECPolicyRef != "" {
|
|
parts = append(parts, fmt.Sprintf("dnssec-policy \"%s\"", zone.Spec.DNSSECPolicyRef), "inline-signing yes")
|
|
}
|
|
case bindv1alpha1.ZoneSecondary:
|
|
parts = append(parts, "type secondary", fmt.Sprintf("file \"%s\"", bind.ZoneFilePath(zone.Spec.ZoneName)))
|
|
if len(zone.Spec.Primaries) > 0 {
|
|
parts = append(parts, fmt.Sprintf("primaries { %s }", terminateInline(zone.Spec.Primaries)))
|
|
}
|
|
case bindv1alpha1.ZoneForward:
|
|
parts = append(parts, "type forward", "forward only")
|
|
if len(zone.Spec.Forwarders) > 0 {
|
|
parts = append(parts, fmt.Sprintf("forwarders { %s }", terminateInline(zone.Spec.Forwarders)))
|
|
}
|
|
case bindv1alpha1.ZoneStub:
|
|
parts = append(parts, "type stub", fmt.Sprintf("file \"%s\"", bind.ZoneFilePath(zone.Spec.ZoneName)))
|
|
if len(zone.Spec.Primaries) > 0 {
|
|
parts = append(parts, fmt.Sprintf("primaries { %s }", terminateInline(zone.Spec.Primaries)))
|
|
}
|
|
}
|
|
return "{ " + strings.Join(parts, "; ") + "; };", nil
|
|
}
|
|
|
|
func (r *BindZoneReconciler) zoneUpdateCreds(ctx context.Context, zone *bindv1alpha1.BindZone) (bind.TSIGCreds, error) {
|
|
keyRef := zone.Spec.UpdateKeyRef
|
|
if keyRef == "" {
|
|
keyRef = zone.Spec.TransferKeyRef
|
|
}
|
|
if keyRef == "" {
|
|
// Fall back to local (non-TSIG) update when the zone allows it; most
|
|
// seeded primaries permit localhost updates.
|
|
return bind.TSIGCreds{}, fmt.Errorf("records require spec.updateKeyRef")
|
|
}
|
|
return resolveTSIG(ctx, r.Client, zone.Namespace, keyRef)
|
|
}
|
|
|
|
func (r *BindZoneReconciler) registerCatalog(ctx context.Context, zone *bindv1alpha1.BindZone, cluster *bindv1alpha1.BindCluster, primaryPod string) {
|
|
logger := log.FromContext(ctx)
|
|
catalog, creds, ok := r.catalogFor(ctx, zone, cluster)
|
|
if !ok {
|
|
return
|
|
}
|
|
if err := r.Exec.AddCatalogMember(ctx, zone.Namespace, primaryPod, catalog.Spec.ZoneName, zone.Spec.ZoneName, creds); err != nil {
|
|
logger.V(1).Info("catalog register failed", "zone", zone.Spec.ZoneName, "err", err.Error())
|
|
}
|
|
}
|
|
|
|
func (r *BindZoneReconciler) deregisterCatalog(ctx context.Context, zone *bindv1alpha1.BindZone, cluster *bindv1alpha1.BindCluster, primaryPod string) {
|
|
catalog, creds, ok := r.catalogFor(ctx, zone, cluster)
|
|
if !ok {
|
|
return
|
|
}
|
|
_ = r.Exec.RemoveCatalogMember(ctx, zone.Namespace, primaryPod, catalog.Spec.ZoneName, zone.Spec.ZoneName, creds)
|
|
}
|
|
|
|
// zoneTransferKeyRef returns the catalog transfer TSIG key name that a catalog
|
|
// member primary zone must allow AXFR with, so secondaries (which present that
|
|
// key) can pull it. Returns "" for non-member zones, non-primary zones, or when
|
|
// the cluster has no catalog.
|
|
func (r *BindZoneReconciler) zoneTransferKeyRef(ctx context.Context, zone *bindv1alpha1.BindZone, cluster *bindv1alpha1.BindCluster) string {
|
|
if !isPrimaryType(zone.Spec.Type) || !catalogEnabled(zone) {
|
|
return ""
|
|
}
|
|
var catalogs bindv1alpha1.BindCatalogZoneList
|
|
if err := r.List(ctx, &catalogs, client.InNamespace(zone.Namespace)); err != nil {
|
|
return ""
|
|
}
|
|
for i := range catalogs.Items {
|
|
if catalogs.Items[i].Spec.ClusterRef == cluster.Name {
|
|
return catalogs.Items[i].Spec.TransferKeyRef
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func (r *BindZoneReconciler) catalogFor(ctx context.Context, zone *bindv1alpha1.BindZone, cluster *bindv1alpha1.BindCluster) (*bindv1alpha1.BindCatalogZone, bind.TSIGCreds, bool) {
|
|
var catalogs bindv1alpha1.BindCatalogZoneList
|
|
if err := r.List(ctx, &catalogs, client.InNamespace(zone.Namespace)); err != nil {
|
|
return nil, bind.TSIGCreds{}, false
|
|
}
|
|
for i := range catalogs.Items {
|
|
if catalogs.Items[i].Spec.ClusterRef == cluster.Name {
|
|
cat := &catalogs.Items[i]
|
|
creds, err := resolveTSIG(ctx, r.Client, zone.Namespace, cat.Spec.TransferKeyRef)
|
|
if err != nil {
|
|
return nil, bind.TSIGCreds{}, false
|
|
}
|
|
return cat, creds, true
|
|
}
|
|
}
|
|
return nil, bind.TSIGCreds{}, false
|
|
}
|
|
|
|
func (r *BindZoneReconciler) setPhase(ctx context.Context, zone *bindv1alpha1.BindZone, phase, reason, msg string) (ctrl.Result, error) {
|
|
zone.Status.Phase = phase
|
|
zone.Status.ObservedGeneration = zone.Generation
|
|
setReady(&zone.Status.Conditions, zone.Generation, phase == "Ready", reason, msg)
|
|
if err := r.Status().Update(ctx, zone); err != nil {
|
|
return ctrl.Result{}, err
|
|
}
|
|
if phase == "Error" || phase == "Pending" {
|
|
return ctrl.Result{RequeueAfter: requeueShort}, nil
|
|
}
|
|
return ctrl.Result{}, nil
|
|
}
|
|
|
|
func (r *BindZoneReconciler) SetupWithManager(mgr ctrl.Manager) error {
|
|
return ctrl.NewControllerManagedBy(mgr).
|
|
For(&bindv1alpha1.BindZone{}).
|
|
Complete(r)
|
|
}
|