diff --git a/internal/bind/render.go b/internal/bind/render.go index 86ccc29..b3c7a0f 100644 --- a/internal/bind/render.go +++ b/internal/bind/render.go @@ -206,6 +206,9 @@ func catalogZonesClause(in RenderInput, isPrimary bool, indent string) string { if len(primaries) == 0 && in.PrimaryAddress != "" { primaries = []string{in.PrimaryAddress} } + if len(primaries) == 0 { + return "" + } var b strings.Builder b.WriteString(indent + "catalog-zones {\n") b.WriteString(fmt.Sprintf("%s zone \"%s\" default-primaries { %s };\n", indent, in.Catalog.Spec.ZoneName, terminate(primaries))) @@ -227,6 +230,12 @@ func renderCatalogZoneDecl(in RenderInput, isPrimary bool, indent string) string if len(primaries) == 0 && in.PrimaryAddress != "" { primaries = []string{in.PrimaryAddress} } + if len(primaries) == 0 { + // Primary IP not known yet; omit the secondary catalog zone rather than + // emit an invalid empty primaries list. A Pod-triggered reconcile renders + // it once the primary pod has an IP. + return "" + } var b strings.Builder b.WriteString(fmt.Sprintf("%szone \"%s\" {\n", indent, cat.Spec.ZoneName)) b.WriteString(indent + " type secondary;\n") diff --git a/internal/bind/render_test.go b/internal/bind/render_test.go index 30faf78..a4ca843 100644 --- a/internal/bind/render_test.go +++ b/internal/bind/render_test.go @@ -53,6 +53,33 @@ func TestRenderCatalogOnSecondaryOnly(t *testing.T) { } } +func TestRenderCatalogOmittedWhenPrimaryIPUnknown(t *testing.T) { + // Primary IP not known yet and no explicit default-primaries: the secondary + // must not emit a catalog-zones / secondary catalog zone with an empty + // primaries list (which BIND rejects at config load). + in := RenderInput{ + Cluster: newCluster(bindv1alpha1.ModeAuthoritative), + Catalog: &bindv1alpha1.BindCatalogZone{Spec: bindv1alpha1.BindCatalogZoneSpec{ZoneName: "catalog.internal"}}, + PrimaryAddress: "", + } + _, secondary := RenderNamedConf(in) + if strings.Contains(secondary, "catalog-zones") || strings.Contains(secondary, "primaries {") { + t.Fatalf("secondary must omit catalog primaries when the primary IP is unknown:\n%s", secondary) + } +} + +func TestRenderCatalogUsesPrimaryIP(t *testing.T) { + in := RenderInput{ + Cluster: newCluster(bindv1alpha1.ModeAuthoritative), + Catalog: &bindv1alpha1.BindCatalogZone{Spec: bindv1alpha1.BindCatalogZoneSpec{ZoneName: "catalog.internal"}}, + PrimaryAddress: "10.42.0.7", + } + _, secondary := RenderNamedConf(in) + if !strings.Contains(secondary, "primaries { 10.42.0.7; }") { + t.Fatalf("secondary should point primaries at the primary pod IP:\n%s", secondary) + } +} + func TestRenderACL(t *testing.T) { in := RenderInput{ Cluster: newCluster(bindv1alpha1.ModeAuthoritative), diff --git a/internal/bind/seed.go b/internal/bind/seed.go index 1c9aed9..8176b71 100644 --- a/internal/bind/seed.go +++ b/internal/bind/seed.go @@ -26,14 +26,15 @@ func (e *Executor) ZoneExists(ctx context.Context, namespace, pod, zone, view st return err == nil } -// WriteSeedZone writes a minimal loadable zone file (SOA + apex NS) to path, -// creating parent directories. It is only safe to call when creating a zone, as -// it overwrites any existing file. -func (e *Executor) WriteSeedZone(ctx context.Context, namespace, pod, zone, path, primaryNS string, serial int64) error { +// WriteSeedZone writes a minimal loadable zone file (SOA + apex NS + glue) to +// path, creating parent directories. The apex NS is the in-zone name ns1, and a +// glue A record pointing at primaryIP is included so BIND's check-integrity +// accepts the zone (an in-zone NS without an address record is a load error). +// It is only safe to call when creating a zone, as it overwrites any existing +// file. This is a placeholder that is replaced once real records are loaded. +func (e *Executor) WriteSeedZone(ctx context.Context, namespace, pod, zone, path, primaryIP string, serial int64) error { origin := dot(zone) - if primaryNS == "" { - primaryNS = "ns1." + origin - } + ns := "ns1." + origin content := fmt.Sprintf(`$TTL 3600 @ IN SOA %s hostmaster.%s ( %d ; serial @@ -42,7 +43,8 @@ func (e *Executor) WriteSeedZone(ctx context.Context, namespace, pod, zone, path 1209600 ; expire 300 ) ; minimum @ IN NS %s -`, dot(primaryNS), origin, serial, dot(primaryNS)) +ns1 IN A %s +`, ns, origin, serial, ns, primaryIP) cmd := []string{"sh", "-c", fmt.Sprintf("mkdir -p \"$(dirname '%s')\" && cat > '%s'", path, path)} if out, err := e.Exec(ctx, namespace, pod, cmd, content); err != nil { diff --git a/internal/controller/bindcatalogzone_controller.go b/internal/controller/bindcatalogzone_controller.go index a7d83a6..9f04583 100644 --- a/internal/controller/bindcatalogzone_controller.go +++ b/internal/controller/bindcatalogzone_controller.go @@ -50,7 +50,11 @@ func (r *BindCatalogZoneReconciler) Reconcile(ctx context.Context, req ctrl.Requ // Ensure the catalog zone exists on the primary. if !r.Exec.ZoneExists(ctx, catalog.Namespace, primaryPod, catalog.Spec.ZoneName, "") { - if err := r.Exec.WriteSeedZone(ctx, catalog.Namespace, primaryPod, catalog.Spec.ZoneName, bind.CatalogFilePath(catalog.Spec.ZoneName), "", 1); err != nil { + primaryIP := primaryPodIP(ctx, r.Client, cluster) + if primaryIP == "" { + return r.fail(ctx, &catalog, "PrimaryNoIP", "waiting for primary pod IP") + } + if err := r.Exec.WriteSeedZone(ctx, catalog.Namespace, primaryPod, catalog.Spec.ZoneName, bind.CatalogFilePath(catalog.Spec.ZoneName), primaryIP, 1); err != nil { return r.fail(ctx, &catalog, "SeedFailed", err.Error()) } } diff --git a/internal/controller/bindcluster_controller.go b/internal/controller/bindcluster_controller.go index 4aae406..5f379ad 100644 --- a/internal/controller/bindcluster_controller.go +++ b/internal/controller/bindcluster_controller.go @@ -160,7 +160,10 @@ func (r *BindClusterReconciler) reconcileKeysSecret(ctx context.Context, c *bind } func (r *BindClusterReconciler) reconcileConfigMap(ctx context.Context, c *bindv1alpha1.BindCluster) error { - in := bind.RenderInput{Cluster: c, PrimaryAddress: primaryAddress(c.Name, c.Namespace)} + // BIND primaries/default-primaries need the primary's IP address, not a DNS + // name, so render with pod-0's current IP (empty until it is scheduled; the + // Pod watch re-renders when it appears or changes). + in := bind.RenderInput{Cluster: c, PrimaryAddress: primaryPodIP(ctx, r.Client, c)} var acls bindv1alpha1.BindACLList if err := r.List(ctx, &acls, client.InNamespace(c.Namespace)); err == nil { @@ -387,6 +390,12 @@ func (r *BindClusterReconciler) SetupWithManager(mgr ctrl.Manager) error { Owns(&corev1.Service{}). Owns(&corev1.ConfigMap{}). Owns(&corev1.Secret{}). + Watches(&corev1.Pod{}, handler.EnqueueRequestsFromMapFunc(func(ctx context.Context, o client.Object) []reconcile.Request { + // Re-render named.conf when a cluster pod's IP appears or changes, so + // secondaries always point primaries/default-primaries at the current + // primary pod IP. + return mapToCluster(o.GetLabels()[clusterLabel], o.GetNamespace()) + })). Watches(&bindv1alpha1.BindACL{}, handler.EnqueueRequestsFromMapFunc(func(ctx context.Context, o client.Object) []reconcile.Request { return mapToCluster(o.(*bindv1alpha1.BindACL).Spec.ClusterRef, o.GetNamespace()) })). diff --git a/internal/controller/bindpolicy_controller.go b/internal/controller/bindpolicy_controller.go index 68419da..0093220 100644 --- a/internal/controller/bindpolicy_controller.go +++ b/internal/controller/bindpolicy_controller.go @@ -63,7 +63,11 @@ func (r *BindPolicyReconciler) Reconcile(ctx context.Context, req ctrl.Request) } if !r.Exec.ZoneExists(ctx, policy.Namespace, primaryPod, policy.Spec.ZoneName, policy.Spec.ViewRef) { - if err := r.Exec.WriteSeedZone(ctx, policy.Namespace, primaryPod, policy.Spec.ZoneName, bind.ZoneFilePath(policy.Spec.ZoneName), "", 1); err != nil { + primaryIP := primaryPodIP(ctx, r.Client, cluster) + if primaryIP == "" { + return r.fail(ctx, &policy, "PrimaryNoIP", "waiting for primary pod IP") + } + if err := r.Exec.WriteSeedZone(ctx, policy.Namespace, primaryPod, policy.Spec.ZoneName, bind.ZoneFilePath(policy.Spec.ZoneName), primaryIP, 1); err != nil { return r.fail(ctx, &policy, "SeedFailed", err.Error()) } } diff --git a/internal/controller/bindzone_controller.go b/internal/controller/bindzone_controller.go index 57983f5..933e2ba 100644 --- a/internal/controller/bindzone_controller.go +++ b/internal/controller/bindzone_controller.go @@ -73,8 +73,12 @@ func (r *BindZoneReconciler) Reconcile(ctx context.Context, req ctrl.Request) (c } created := !r.Exec.ZoneExists(ctx, zone.Namespace, primaryPod, zone.Spec.ZoneName, zone.Spec.ViewRef) - if created && zone.Spec.Type == bindv1alpha1.ZonePrimary || (created && zone.Spec.Type == "") { - if err := r.Exec.WriteSeedZone(ctx, zone.Namespace, primaryPod, zone.Spec.ZoneName, bind.ZoneFilePath(zone.Spec.ZoneName), "", 1); err != nil { + 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()) } } diff --git a/internal/controller/helpers.go b/internal/controller/helpers.go index 051cf12..52dd2ca 100644 --- a/internal/controller/helpers.go +++ b/internal/controller/helpers.go @@ -87,6 +87,18 @@ func primaryReady(ctx context.Context, c client.Client, cluster *bindv1alpha1.Bi return false } +// primaryPodIP returns the pod IP of a cluster's primary pod (ordinal 0), or an +// empty string if the pod has no IP yet. BIND's primaries/default-primaries +// only accept IP addresses (not hostnames), and zone seeding needs the address +// for glue, so the operator resolves the pod IP rather than using a DNS name. +func primaryPodIP(ctx context.Context, c client.Client, cluster *bindv1alpha1.BindCluster) string { + var pod corev1.Pod + if err := c.Get(ctx, client.ObjectKey{Namespace: cluster.Namespace, Name: primaryPodName(cluster.Name)}, &pod); err != nil { + return "" + } + return pod.Status.PodIP +} + // resolveTSIG reads the material of a BindTSIGKey into TSIG credentials. func resolveTSIG(ctx context.Context, c client.Client, namespace, keyRef string) (bind.TSIGCreds, error) { var creds bind.TSIGCreds