reconcile zone apex NS off the pod IP placeholder
ci/woodpecker/pr/pre-commit Pipeline was successful
ci/woodpecker/pr/test Pipeline was successful
ci/woodpecker/pr/build Pipeline was successful

Add BindZone.spec.nameservers and sync the apex NS RRset on every reconcile.
This commit is contained in:
2026-09-26 18:27:11 +10:00
parent 03df5d56b4
commit 4a41cbc427
14 changed files with 346 additions and 39 deletions
+9
View File
@@ -64,6 +64,15 @@ type BindZoneSpec struct {
// +optional
DefaultTTL int32 `json:"defaultTTL,omitempty"`
// Nameservers are the names published in the zone's apex NS RRset, kept in
// sync on every reconcile. Each entry is a full domain name, never relative
// to the zone. Prefer out-of-zone names glued by the parent: an
// in-zone name needs an address record in the zone, and the seed can only
// supply the primary pod's (unstable) IP for it. When empty the apex NS is
// the primary's stable in-cluster DNS name.
// +optional
Nameservers []string `json:"nameservers,omitempty"`
// Records are static record sets seeded into a primary zone.
// +optional
Records []Record `json:"records,omitempty"`
+5
View File
@@ -970,6 +970,11 @@ func (in *BindZoneList) DeepCopyObject() runtime.Object {
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
func (in *BindZoneSpec) DeepCopyInto(out *BindZoneSpec) {
*out = *in
if in.Nameservers != nil {
in, out := &in.Nameservers, &out.Nameservers
*out = make([]string, len(*in))
copy(*out, *in)
}
if in.Records != nil {
in, out := &in.Records, &out.Records
*out = make([]Record, len(*in))
@@ -94,6 +94,17 @@ spec:
items:
type: string
type: array
nameservers:
description: |-
Nameservers are the names published in the zone's apex NS RRset, kept in
sync on every reconcile. Each entry is a full domain name, never relative
to the zone. Prefer out-of-zone names glued by the parent: an
in-zone name needs an address record in the zone, and the seed can only
supply the primary pod's (unstable) IP for it. When empty the apex NS is
the primary's stable in-cluster DNS name.
items:
type: string
type: array
primaries:
description: Primaries lists source servers for a secondary/stub-type
zone.
+11
View File
@@ -2760,6 +2760,17 @@ spec:
items:
type: string
type: array
nameservers:
description: |-
Nameservers are the names published in the zone's apex NS RRset, kept in
sync on every reconcile. Each entry is a full domain name, never relative
to the zone. Prefer out-of-zone names glued by the parent: an
in-zone name needs an address record in the zone, and the seed can only
supply the primary pod's (unstable) IP for it. When empty the apex NS is
the primary's stable in-cluster DNS name.
items:
type: string
type: array
primaries:
description: Primaries lists source servers for a secondary/stub-type
zone.
+4 -3
View File
@@ -71,10 +71,11 @@ spec:
- key transfer-key
updateKeyRef: transfer-key
dynamicUpdate: true
# Published apex NS, kept in sync on every reconcile. Full names only; an
# in-zone name (as here) needs its address record below.
nameservers:
- ns1.internal.example.com.
records:
- name: "@"
type: NS
values: ["ns1.internal.example.com."]
- name: ns1
type: A
values: ["10.0.0.53"]
+41 -12
View File
@@ -26,36 +26,65 @@ func (e *Executor) ZoneExists(ctx context.Context, namespace, pod, zone, view st
return err == nil
}
// renderSeedZone renders a minimal loadable zone (SOA + apex NS + glue). 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).
func renderSeedZone(zone, primaryIP string, serial int64) string {
// renderSeedZone renders a minimal loadable zone (SOA + apex NS). nameservers
// are the names published in the apex NS RRset; when empty the in-zone name ns1
// is used. A glue A pointing at primaryIP is emitted only for a nameserver that
// falls inside the zone, because BIND refuses to load a zone whose in-zone NS
// has no address record. Out-of-zone nameservers therefore keep pod IPs out of
// the zone file entirely.
func renderSeedZone(zone, primaryIP string, nameservers []string, serial int64) string {
origin := dot(zone)
ns := "ns1." + origin
ns := make([]string, 0, len(nameservers))
for _, n := range nameservers {
ns = append(ns, dot(n))
}
if len(ns) == 0 {
ns = []string{"ns1." + origin}
}
// Short refresh/retry so a secondary that misses a NOTIFY (e.g. its pod IP
// changed and the primary's also-notify was briefly stale) still converges
// in minutes, not the hour a 3600s refresh would impose. minimum is the
// negative-cache TTL: keep it low so a stale-secondary NXDOMAIN does not
// stick in downstream resolvers for long. NOTIFY (also-notify on the
// primary) remains the fast path; these are the fallback.
return fmt.Sprintf(`$TTL 3600
var b strings.Builder
fmt.Fprintf(&b, `$TTL 3600
@ IN SOA %s hostmaster.%s (
%d ; serial
300 ; refresh
60 ; retry
1209600 ; expire
60 ) ; minimum
@ IN NS %s
ns1 IN A %s
`, ns, origin, serial, ns, primaryIP)
`, ns[0], origin, serial)
for _, n := range ns {
fmt.Fprintf(&b, "@ IN NS %s\n", n)
}
for _, n := range ns {
if owner, ok := InZoneOwner(n, zone); ok {
fmt.Fprintf(&b, "%s IN A %s\n", owner, primaryIP)
}
}
return b.String()
}
// InZoneOwner reports whether name sits inside zone, and if so returns its owner
// name relative to the apex ("@" for the apex itself).
func InZoneOwner(name, zone string) (string, bool) {
name, origin := dot(name), dot(zone)
switch {
case name == origin:
return "@", true
case strings.HasSuffix(name, "."+origin):
return strings.TrimSuffix(name, "."+origin), true
}
return "", false
}
// EnsureSeedZone makes path loadable without discarding live data: it probes
// the zone file and journal, moves aside whatever cannot load, and writes a
// skeleton only when there is nothing to preserve. Every caller that needs a
// zone database file on disk goes through here.
func (e *Executor) EnsureSeedZone(ctx context.Context, namespace, pod, zone, path, primaryIP string) error {
func (e *Executor) EnsureSeedZone(ctx context.Context, namespace, pod, zone, path, primaryIP string, nameservers []string) error {
state, err := e.ZoneDiskState(ctx, namespace, pod, path)
if err != nil {
return err
@@ -67,7 +96,7 @@ func (e *Executor) EnsureSeedZone(ctx context.Context, namespace, pod, zone, pat
if !plan.WriteSeed {
return e.Quarantine(ctx, namespace, pod, path, plan)
}
content := renderSeedZone(zone, primaryIP, plan.Serial)
content := renderSeedZone(zone, primaryIP, nameservers, plan.Serial)
cmd := []string{"sh", "-c", seedScript(path, plan, len(content))}
if out, err := e.Exec(ctx, namespace, pod, cmd, content); err != nil {
return fmt.Errorf("seed zone %s: %w (out: %s)", zone, err, out)
+75
View File
@@ -0,0 +1,75 @@
package bind
import (
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
)
// checkZone runs named-checkzone with full integrity checking, the same check
// named applies when loading a primary zone. Skips where the tool is absent.
func checkZone(t *testing.T, zone, content string) {
t.Helper()
bin, err := exec.LookPath("named-checkzone")
if err != nil {
t.Skip("named-checkzone not installed")
}
path := filepath.Join(t.TempDir(), "db")
if err := os.WriteFile(path, []byte(content), 0o600); err != nil {
t.Fatal(err)
}
if out, err := exec.Command(bin, "-i", "full", zone, path).CombinedOutput(); err != nil {
t.Fatalf("zone not loadable: %v\n%s\n%s", err, out, content)
}
}
func TestRenderSeedZoneDeclaredNameservers(t *testing.T) {
got := renderSeedZone("acme.unkin.net", "10.42.6.38", []string{"ns1.unkin.net", "ns2.unkin.net."}, 7)
if strings.Contains(got, "10.42.6.38") {
t.Errorf("declared nameservers must not pull a pod IP into the zone:\n%s", got)
}
for _, want := range []string{"@ IN NS ns1.unkin.net.\n", "@ IN NS ns2.unkin.net.\n", "SOA ns1.unkin.net. hostmaster.acme.unkin.net."} {
if !strings.Contains(got, want) {
t.Errorf("missing %q in:\n%s", want, got)
}
}
checkZone(t, "acme.unkin.net", got)
}
// An in-zone nameserver has no address anywhere else, so the seed must glue it
// or named refuses to load the zone.
func TestRenderSeedZoneInZoneNameserverGetsGlue(t *testing.T) {
got := renderSeedZone("example.com", "10.0.0.1", []string{"ns.example.com"}, 1)
if !strings.Contains(got, "ns IN A 10.0.0.1\n") {
t.Errorf("in-zone nameserver needs glue:\n%s", got)
}
checkZone(t, "example.com", got)
}
func TestRenderSeedZoneFallbackLoads(t *testing.T) {
got := renderSeedZone("example.com", "10.0.0.1", nil, 1)
if !strings.Contains(got, "@ IN NS ns1.example.com.\n") || !strings.Contains(got, "ns1 IN A 10.0.0.1\n") {
t.Errorf("fallback seed changed shape:\n%s", got)
}
checkZone(t, "example.com", got)
}
func TestInZoneOwner(t *testing.T) {
cases := []struct {
name, zone, owner string
in bool
}{
{"ns1.example.com.", "example.com", "ns1", true},
{"example.com", "example.com.", "@", true},
{"ns1.unkin.net", "acme.unkin.net", "", false},
{"notexample.com", "example.com", "", false},
}
for _, c := range cases {
owner, in := InZoneOwner(c.name, c.zone)
if in != c.in || owner != c.owner {
t.Errorf("InZoneOwner(%q, %q) = %q, %v; want %q, %v", c.name, c.zone, owner, in, c.owner, c.in)
}
}
}
+7 -7
View File
@@ -29,7 +29,7 @@ func inFlightZone(t *testing.T) (dir, path string) {
t.Helper()
dir = t.TempDir()
path = filepath.Join(dir, "db.example.com")
if err := os.WriteFile(path, []byte(renderSeedZone("example.com", "10.0.0.1", 1)), 0o600); err != nil {
if err := os.WriteFile(path, []byte(renderSeedZone("example.com", "10.0.0.1", nil, 1)), 0o600); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(JournalPath(path), journalHeader(";BIND LOG V9.2\n", 10, 16), 0o600); err != nil {
@@ -134,7 +134,7 @@ func TestSeedScriptInterruptedWriteLeavesDiskUntouched(t *testing.T) {
if !plan.WriteSeed || !plan.QuarantineZoneFile || !plan.QuarantineJournal {
t.Fatalf("expected a reseed over both files, got %+v", plan)
}
content := renderSeedZone("example.com", "10.0.0.1", plan.Serial)
content := renderSeedZone("example.com", "10.0.0.1", nil, plan.Serial)
if err := runSeedScript(t, sh, path, plan, content, content[:len(content)/2]); err == nil {
t.Fatal("a truncated seed write must fail rather than install a torn zone file")
@@ -165,7 +165,7 @@ func TestSeedScriptInstallsOverQuarantinedFiles(t *testing.T) {
dir, path := inFlightZone(t)
plan := PlanSeed(probeState(t, sh, path))
content := renderSeedZone("example.com", "10.0.0.1", plan.Serial)
content := renderSeedZone("example.com", "10.0.0.1", nil, plan.Serial)
if err := runSeedScript(t, sh, path, plan, content, content); err != nil {
t.Fatalf("seed script: %v", err)
}
@@ -198,7 +198,7 @@ func TestSeedScriptFreshInstall(t *testing.T) {
path := filepath.Join(t.TempDir(), "zones", "db.example.com")
plan := PlanSeed(ZoneDiskState{})
content := renderSeedZone("example.com", "10.0.0.1", plan.Serial)
content := renderSeedZone("example.com", "10.0.0.1", nil, plan.Serial)
if err := runSeedScript(t, sh, path, plan, content, content); err != nil {
t.Fatalf("seed script: %v", err)
}
@@ -225,7 +225,7 @@ func TestSeedScriptFailedQuarantineAbortsInstall(t *testing.T) {
exec `+realTool(t, "mv")+` "$@"`)
plan := PlanSeed(probeState(t, sh, path))
content := renderSeedZone("example.com", "10.0.0.1", plan.Serial)
content := renderSeedZone("example.com", "10.0.0.1", nil, plan.Serial)
if err := runSeedScriptWithPath(t, sh, path, plan, content, content, pathEnv); err == nil {
t.Fatal("a failed quarantine must fail the seed")
}
@@ -240,7 +240,7 @@ func TestSeedScriptUnmeasurableStagingAbortsInstall(t *testing.T) {
pathEnv := shimPath(t, "wc", "exit 127")
plan := PlanSeed(probeState(t, sh, path))
content := renderSeedZone("example.com", "10.0.0.1", plan.Serial)
content := renderSeedZone("example.com", "10.0.0.1", nil, plan.Serial)
if err := runSeedScriptWithPath(t, sh, path, plan, content, content, pathEnv); err == nil {
t.Fatal("an unmeasurable staging file must fail the seed")
}
@@ -256,7 +256,7 @@ func TestSeedScriptFailedMkdirAbortsInstall(t *testing.T) {
pathEnv := shimPath(t, "mkdir", "exit 1")
plan := PlanSeed(probeState(t, sh, path))
content := renderSeedZone("example.com", "10.0.0.1", plan.Serial)
content := renderSeedZone("example.com", "10.0.0.1", nil, plan.Serial)
if err := runSeedScriptWithPath(t, sh, path, plan, content, content, pathEnv); err == nil {
t.Fatal("a failed mkdir must fail the seed")
}
+5 -5
View File
@@ -48,7 +48,7 @@ k8s.syd1.au.unkin.net IN SOA ns1.k8s.syd1.au.unkin.net. hostmaster.k8s.syd1.au.u
ok bool
}{
{"bind dump", bindDump, 16, true},
{"seed", renderSeedZone("example.com", "10.0.0.1", 42), 42, true},
{"seed", renderSeedZone("example.com", "10.0.0.1", nil, 42), 42, true},
{"single line", "@ IN SOA ns1.example.com. hostmaster.example.com. 7 300 60 1209600 60\n", 7, true},
{"glued paren", "@ IN SOA ns. host. (9 300 60 1209600 60)\n", 9, true},
{"no soa", "$TTL 3600\nwww IN A 192.0.2.1\n", 0, false},
@@ -308,7 +308,7 @@ func TestPlanSeedBlocksOnUnreadableOrphanJournal(t *testing.T) {
}
func TestSeedZoneRoundTripsThroughParser(t *testing.T) {
content := renderSeedZone("200.18.198.in-addr.arpa", "198.18.200.8", 17)
content := renderSeedZone("200.18.198.in-addr.arpa", "198.18.200.8", nil, 17)
got, ok := ParseZoneSerial(content)
if !ok || got != 17 {
t.Fatalf("seed zone serial = (%d,%v) want (17,true)", got, ok)
@@ -418,12 +418,12 @@ func TestZoneStateProbeRoundTrip(t *testing.T) {
{name: "fresh install"},
{
name: "zone file only",
zone: renderSeedZone("example.com", "10.0.0.1", 42),
zone: renderSeedZone("example.com", "10.0.0.1", nil, 42),
want: ZoneDiskState{ZoneFile: true, ZoneSerial: 42, ZoneSerialOK: true},
},
{
name: "zone file and journal",
zone: renderSeedZone("example.com", "10.0.0.1", 12),
zone: renderSeedZone("example.com", "10.0.0.1", nil, 12),
jnl: journalHeader(";BIND LOG V9.2\n", 10, 16),
want: ZoneDiskState{
ZoneFile: true, ZoneSerial: 12, ZoneSerialOK: true,
@@ -452,7 +452,7 @@ func TestZoneStateProbeRoundTrip(t *testing.T) {
},
{
name: "live zone beside old quarantine evidence",
zone: renderSeedZone("example.com", "10.0.0.1", 42),
zone: renderSeedZone("example.com", "10.0.0.1", nil, 42),
orphans: []string{".orphaned-16"},
want: ZoneDiskState{
ZoneFile: true, ZoneSerial: 42, ZoneSerialOK: true,
+98
View File
@@ -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())
}
}
+1 -1
View File
@@ -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())
}
}
+23 -10
View File
@@ -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.
+55
View File
@@ -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
}