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) } // The catalog transfer TSIG key doubles as the intra-cluster NOTIFY key: the // primary signs its also-notify NOTIFYs with it and secondaries accept them // via `allow-notify { key ""; }`. Keying the NOTIFYs is what lets the // secondary's allow-notify be a static key element (no pod IPs), so pod-IP // churn never re-renders restart-scoped config (the v0.2.5 roll loop). transferKey := r.zoneTransferKeyRef(ctx, &zone, cluster) zoneConfig, err := r.buildZoneConfig(ctx, &zone, transferKey, notifyTargets, transferKey) 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. notifyKey, // when set, is the TSIG key each also-notify entry is signed with, so // secondaries can accept the NOTIFYs by key rather than by (churning) pod IP. func (r *BindZoneReconciler) buildZoneConfig(ctx context.Context, zone *bindv1alpha1.BindZone, transferKey string, notifyTargets []string, notifyKey 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. Each // entry is signed with notifyKey so secondaries can admit the NOTIFY by key // (`allow-notify { key ... }`) instead of by pod IP. This zone config is // applied via rndc addzone/modzone — no pod restart — so listing pod IPs // here is safe; only *restart-scoped* config must never depend on pod IPs. if len(notifyTargets) > 0 { parts = append(parts, "notify explicit", fmt.Sprintf("also-notify { %s }", alsoNotifyList(notifyTargets, notifyKey))) } 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) }