reconcile zone apex NS off the pod IP placeholder
Add BindZone.spec.nameservers and sync the apex NS RRset on every reconcile.
This commit is contained in:
@@ -0,0 +1,98 @@
|
||||
package controller
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
bindv1alpha1 "git.unkin.net/unkin/bind-operator/api/v1alpha1"
|
||||
"git.unkin.net/unkin/bind-operator/internal/bind"
|
||||
)
|
||||
|
||||
func zoneWith(spec bindv1alpha1.BindZoneSpec) *bindv1alpha1.BindZone {
|
||||
spec.ZoneName = "acme.unkin.net"
|
||||
return &bindv1alpha1.BindZone{Spec: spec}
|
||||
}
|
||||
|
||||
func TestZoneNameserversFallbackIsOutOfZone(t *testing.T) {
|
||||
cluster := &bindv1alpha1.BindCluster{}
|
||||
cluster.Name, cluster.Namespace = "auth", "bind-internal"
|
||||
got := zoneNameservers(nil, cluster)
|
||||
want := "auth-0.auth-headless.bind-internal.svc.cluster.local."
|
||||
if len(got) != 1 || got[0] != want {
|
||||
t.Fatalf("fallback = %v; want [%s]", got, want)
|
||||
}
|
||||
if got := zoneNameservers([]string{"ns1.unkin.net."}, cluster); got[0] != "ns1.unkin.net." {
|
||||
t.Fatalf("declared nameservers must win, got %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApexNSUpdatesReplacesRRsetAndDropsGlue(t *testing.T) {
|
||||
zone := zoneWith(bindv1alpha1.BindZoneSpec{
|
||||
Nameservers: []string{"ns1.unkin.net", "ns2.unkin.net."},
|
||||
DefaultTTL: 300,
|
||||
})
|
||||
got := apexNSUpdates(zone, zone.Spec.Nameservers)
|
||||
want := []bind.RecordUpdate{
|
||||
{FQDN: "acme.unkin.net.", Type: "NS", TTL: 300, Values: []string{"ns1.unkin.net.", "ns2.unkin.net."}},
|
||||
{FQDN: "ns1.acme.unkin.net.", Type: "A", Delete: true},
|
||||
}
|
||||
assertUpdates(t, got, want)
|
||||
}
|
||||
|
||||
// Without declared nameservers the apex NS still converges onto the stable
|
||||
// primary name, but the ns1 glue is left alone: it may be a real record.
|
||||
func TestApexNSUpdatesFallbackKeepsGlue(t *testing.T) {
|
||||
zone := zoneWith(bindv1alpha1.BindZoneSpec{})
|
||||
got := apexNSUpdates(zone, []string{"auth-0.auth-headless.bind-internal.svc.cluster.local."})
|
||||
want := []bind.RecordUpdate{{
|
||||
FQDN: "acme.unkin.net.",
|
||||
Type: "NS",
|
||||
TTL: 3600,
|
||||
Values: []string{"auth-0.auth-headless.bind-internal.svc.cluster.local."},
|
||||
}}
|
||||
assertUpdates(t, got, want)
|
||||
}
|
||||
|
||||
// spec.records is applied after the apex sync, so anything it owns must not be
|
||||
// touched here: the ops would be undone and the serial would churn every pass.
|
||||
func TestApexNSUpdatesYieldsToRecords(t *testing.T) {
|
||||
zone := zoneWith(bindv1alpha1.BindZoneSpec{
|
||||
Nameservers: []string{"ns1.unkin.net."},
|
||||
Records: []bindv1alpha1.Record{
|
||||
{Name: "@", Type: "ns", Values: []string{"ns.other.net."}},
|
||||
{Name: "ns1", Type: "A", Values: []string{"10.0.0.53"}},
|
||||
},
|
||||
})
|
||||
if got := apexNSUpdates(zone, zone.Spec.Nameservers); len(got) != 0 {
|
||||
t.Fatalf("expected no updates, got %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
// A declared in-zone nameserver owns the ns1 record; it is glue, not a leftover.
|
||||
func TestApexNSUpdatesKeepsDeclaredNs1Glue(t *testing.T) {
|
||||
zone := zoneWith(bindv1alpha1.BindZoneSpec{Nameservers: []string{"ns1.acme.unkin.net"}})
|
||||
got := apexNSUpdates(zone, zone.Spec.Nameservers)
|
||||
want := []bind.RecordUpdate{{FQDN: "acme.unkin.net.", Type: "NS", TTL: 3600, Values: []string{"ns1.acme.unkin.net."}}}
|
||||
assertUpdates(t, got, want)
|
||||
}
|
||||
|
||||
func assertUpdates(t *testing.T, got, want []bind.RecordUpdate) {
|
||||
t.Helper()
|
||||
if len(got) != len(want) {
|
||||
t.Fatalf("got %d updates %+v; want %d %+v", len(got), got, len(want), want)
|
||||
}
|
||||
for i := range want {
|
||||
if got[i].FQDN != want[i].FQDN || got[i].Type != want[i].Type || got[i].TTL != want[i].TTL || got[i].Delete != want[i].Delete {
|
||||
t.Errorf("update %d = %+v; want %+v", i, got[i], want[i])
|
||||
continue
|
||||
}
|
||||
if len(got[i].Values) != len(want[i].Values) {
|
||||
t.Errorf("update %d values = %v; want %v", i, got[i].Values, want[i].Values)
|
||||
continue
|
||||
}
|
||||
for j := range want[i].Values {
|
||||
if got[i].Values[j] != want[i].Values[j] {
|
||||
t.Errorf("update %d value %d = %q; want %q", i, j, got[i].Values[j], want[i].Values[j])
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -54,7 +54,7 @@ func (r *BindCatalogZoneReconciler) Reconcile(ctx context.Context, req ctrl.Requ
|
||||
if primaryIP == "" {
|
||||
return r.fail(ctx, &catalog, "PrimaryNoIP", "waiting for primary pod IP")
|
||||
}
|
||||
if err := r.Exec.EnsureSeedZone(ctx, catalog.Namespace, primaryPod, catalog.Spec.ZoneName, bind.CatalogFilePath(catalog.Spec.ZoneName), primaryIP); err != nil {
|
||||
if err := r.Exec.EnsureSeedZone(ctx, catalog.Namespace, primaryPod, catalog.Spec.ZoneName, bind.CatalogFilePath(catalog.Spec.ZoneName), primaryIP, zoneNameservers(nil, cluster)); err != nil {
|
||||
return r.fail(ctx, &catalog, "SeedFailed", err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -67,7 +67,7 @@ func (r *BindPolicyReconciler) Reconcile(ctx context.Context, req ctrl.Request)
|
||||
if primaryIP == "" {
|
||||
return r.fail(ctx, &policy, "PrimaryNoIP", "waiting for primary pod IP")
|
||||
}
|
||||
if err := r.Exec.EnsureSeedZone(ctx, policy.Namespace, primaryPod, policy.Spec.ZoneName, bind.ZoneFilePath(policy.Spec.ZoneName), primaryIP); err != nil {
|
||||
if err := r.Exec.EnsureSeedZone(ctx, policy.Namespace, primaryPod, policy.Spec.ZoneName, bind.ZoneFilePath(policy.Spec.ZoneName), primaryIP, zoneNameservers(nil, cluster)); err != nil {
|
||||
return r.fail(ctx, &policy, "SeedFailed", err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -99,6 +99,8 @@ func (r *BindZoneReconciler) Reconcile(ctx context.Context, req ctrl.Request) (c
|
||||
return r.setPhase(ctx, &zone, "Error", "ConfigError", err.Error())
|
||||
}
|
||||
|
||||
nameservers := zoneNameservers(zone.Spec.Nameservers, cluster)
|
||||
|
||||
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)
|
||||
@@ -108,7 +110,7 @@ func (r *BindZoneReconciler) Reconcile(ctx context.Context, req ctrl.Request) (c
|
||||
// The zone is absent from named's memory, but its database file and
|
||||
// journal may still be on the PVC from a previous incarnation.
|
||||
path := bind.ZoneFilePath(zone.Spec.ZoneName)
|
||||
if err := r.Exec.EnsureSeedZone(ctx, zone.Namespace, primaryPod, zone.Spec.ZoneName, path, primaryIP); err != nil {
|
||||
if err := r.Exec.EnsureSeedZone(ctx, zone.Namespace, primaryPod, zone.Spec.ZoneName, path, primaryIP, nameservers); err != nil {
|
||||
return r.setPhase(ctx, &zone, "Error", "SeedFailed", err.Error())
|
||||
}
|
||||
}
|
||||
@@ -116,18 +118,29 @@ func (r *BindZoneReconciler) Reconcile(ctx context.Context, req ctrl.Request) (c
|
||||
return r.setPhase(ctx, &zone, "Error", "AddZoneFailed", err.Error())
|
||||
}
|
||||
|
||||
// Seed static records (primary zones only).
|
||||
// Sync the apex NS on every pass, not only at seed time, so an existing zone
|
||||
// converges off the seed placeholder. Best-effort: a zone that permits no
|
||||
// dynamic update keeps what it was seeded with rather than failing to
|
||||
// reconcile.
|
||||
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())
|
||||
if isPrimaryType(zone.Spec.Type) {
|
||||
creds, credErr := r.zoneUpdateCreds(ctx, &zone)
|
||||
if apex := apexNSUpdates(&zone, nameservers); len(apex) > 0 && credErr == nil {
|
||||
if err := r.Exec.NSUpdate(ctx, zone.Namespace, primaryPod, zone.Spec.ZoneName, creds, apex); err != nil {
|
||||
logger.V(1).Info("apex NS sync failed", "zone", zone.Spec.ZoneName, "err", 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())
|
||||
// Seed static records.
|
||||
if len(zone.Spec.Records) > 0 {
|
||||
if credErr != nil {
|
||||
return r.setPhase(ctx, &zone, "Error", "NoUpdateKey", credErr.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)
|
||||
}
|
||||
recordCount = len(updates)
|
||||
}
|
||||
|
||||
// Register in the catalog so secondaries auto-provision.
|
||||
|
||||
@@ -114,3 +114,58 @@ func alsoNotifyList(addrs []string, key string) string {
|
||||
}
|
||||
return strings.Join(parts, " ")
|
||||
}
|
||||
|
||||
// absolute qualifies a nameserver name. Unlike record owner names, a
|
||||
// spec.nameservers entry is always a full domain name, never relative to the
|
||||
// zone: an in-zone nameserver is spelled out in full.
|
||||
func absolute(name string) string { return strings.TrimSuffix(name, ".") + "." }
|
||||
|
||||
// zoneNameservers resolves the names to publish in a zone's apex NS RRset: the
|
||||
// declared nameservers, else the primary's stable in-cluster DNS name. The
|
||||
// fallback is deliberately out-of-zone, so no pod IP is needed as glue.
|
||||
func zoneNameservers(declared []string, cluster *bindv1alpha1.BindCluster) []string {
|
||||
if len(declared) > 0 {
|
||||
return declared
|
||||
}
|
||||
return []string{primaryAddress(cluster.Name, cluster.Namespace) + "."}
|
||||
}
|
||||
|
||||
// apexNSUpdates returns the dynamic-update ops that keep a zone's apex NS RRset
|
||||
// equal to nameservers, plus removal of the seed's ns1 glue once the zone
|
||||
// declares its own nameservers. Ops colliding with a spec.records entry are
|
||||
// dropped: records are applied afterwards and would re-add them, and the churn
|
||||
// would bump the serial on every reconcile.
|
||||
func apexNSUpdates(zone *bindv1alpha1.BindZone, nameservers []string) []bind.RecordUpdate {
|
||||
owns := func(name, typ string) bool {
|
||||
for _, rec := range zone.Spec.Records {
|
||||
if strings.EqualFold(rec.Type, typ) && fqdn(rec.Name, zone.Spec.ZoneName) == fqdn(name, zone.Spec.ZoneName) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
ttl := zone.Spec.DefaultTTL
|
||||
if ttl <= 0 {
|
||||
ttl = 3600
|
||||
}
|
||||
var updates []bind.RecordUpdate
|
||||
if !owns("@", "NS") {
|
||||
values := make([]string, 0, len(nameservers))
|
||||
for _, ns := range nameservers {
|
||||
values = append(values, absolute(ns))
|
||||
}
|
||||
updates = append(updates, bind.RecordUpdate{FQDN: fqdn("@", zone.Spec.ZoneName), Type: "NS", TTL: ttl, Values: values})
|
||||
}
|
||||
// The seed's placeholder glue pins a pod IP that goes stale on the first
|
||||
// reschedule; drop it once the zone names its real nameservers.
|
||||
if glue := fqdn("ns1", zone.Spec.ZoneName); len(zone.Spec.Nameservers) > 0 && !owns("ns1", "A") {
|
||||
published := false
|
||||
for _, ns := range nameservers {
|
||||
published = published || absolute(ns) == glue
|
||||
}
|
||||
if !published {
|
||||
updates = append(updates, bind.RecordUpdate{FQDN: glue, Type: "A", Delete: true})
|
||||
}
|
||||
}
|
||||
return updates
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user