61324ae89a
v0.2.5 (PR #14) added an options-scope allow-notify enumerating the primary pod IP on secondaries. Options-scope config feeds the config-hash annotation that rolls the StatefulSet, so any config change rolled the pods, the primary came back on a new pod IP, the operator re-rendered with the new IP, the hash changed, the pods rolled again — an infinite roll loop across every BindCluster. The prod deployment was reverted to v0.2.4. Replace the pod-IP allow-notify with TSIG-authenticated NOTIFY: - Secondaries render `allow-notify { key "<name>"; };` — a static key element with NO IPs. It depends only on the key name, so pod-IP churn can never change the render, the config-hash, or trigger a restart. - The primary signs its outgoing NOTIFYs: the zone-scope also-notify entries (already enumerating replica pod IPs, applied via rndc addzone/modzone with NO restart) now carry `key "<name>"`. - Key choice: reuse the cluster's catalog transfer TSIG key (TransferKeyRef). Secondaries already present it for AXFR and it is in keys.conf on every pod, so no new key plumbing is needed. Add a permanent regression guard for the loop class: - controller: reconcile the ConfigMap with the primary pod on two different IPs and assert the config-hash is byte-identical. - render: render restart-scoped input and assert no pod IP appears in allow-notify; RenderInput no longer has any pod-IP field. Zone-scope also-notify (rndc, no restart) legitimately still lists pod IPs; only restart-scoped config must be pod-IP-independent.
293 lines
12 KiB
Go
293 lines
12 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)
|
|
}
|
|
|
|
// 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 "<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)
|
|
}
|