5 Commits

Author SHA1 Message Date
unkin-agent cc714dc2b4 apply records before the apex NS, so in-zone glue exists first
ci/woodpecker/pr/pre-commit Pipeline was successful
ci/woodpecker/pr/test Pipeline was successful
ci/woodpecker/pr/build Pipeline was successful
named rejects an apex NS pointing at an in-zone name with no address record,
so the glue has to land in an earlier transaction.
2026-09-26 19:41:53 +10:00
unkin-agent 08d46ccce0 read the live apex NS with a signed query, retract nothing when unreadable
ci/woodpecker/pr/pre-commit Pipeline was successful
ci/woodpecker/pr/test Pipeline was successful
ci/woodpecker/pr/build Pipeline was successful
An unsigned localhost query is REFUSED for a zone behind a key-matched view and
answers empty with exit 0; signing it with the update creds reaches the view.
2026-09-26 19:25:06 +10:00
unkin-agent dc57ac1b2d retract apex NS from recorded state, not a live query
ci/woodpecker/pr/pre-commit Pipeline was successful
ci/woodpecker/pr/test Pipeline was successful
ci/woodpecker/pr/build Pipeline was successful
An unsigned localhost query silently returns nothing for a zone behind a
BindView, which would strand the placeholder. Record what was published instead.
2026-09-26 19:06:47 +10:00
unkin-agent e4ed6c8052 converge apex NS per record, not by RRset replace
ci/woodpecker/pr/pre-commit Pipeline was successful
ci/woodpecker/pr/test Pipeline was successful
ci/woodpecker/pr/build Pipeline was successful
BIND ignores an RRset-wide delete of apex NS, so the previous replace only
appended to the seed placeholder.
2026-09-26 18:50:55 +10:00
unkin-agent 4a41cbc427 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.
2026-09-26 18:27:11 +10:00
17 changed files with 629 additions and 53 deletions
+11
View File
@@ -64,6 +64,17 @@ type BindZoneSpec struct {
// +optional // +optional
DefaultTTL int32 `json:"defaultTTL,omitempty"` 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 operator
// leaves the apex NS alone and a newly seeded zone gets the primary's stable
// in-cluster DNS name; clearing the field later does not retract what it
// published.
// +optional
Nameservers []string `json:"nameservers,omitempty"`
// Records are static record sets seeded into a primary zone. // Records are static record sets seeded into a primary zone.
// +optional // +optional
Records []Record `json:"records,omitempty"` 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. // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
func (in *BindZoneSpec) DeepCopyInto(out *BindZoneSpec) { func (in *BindZoneSpec) DeepCopyInto(out *BindZoneSpec) {
*out = *in *out = *in
if in.Nameservers != nil {
in, out := &in.Nameservers, &out.Nameservers
*out = make([]string, len(*in))
copy(*out, *in)
}
if in.Records != nil { if in.Records != nil {
in, out := &in.Records, &out.Records in, out := &in.Records, &out.Records
*out = make([]Record, len(*in)) *out = make([]Record, len(*in))
@@ -94,6 +94,19 @@ spec:
items: items:
type: string type: string
type: array 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 operator
leaves the apex NS alone and a newly seeded zone gets the primary's stable
in-cluster DNS name; clearing the field later does not retract what it
published.
items:
type: string
type: array
primaries: primaries:
description: Primaries lists source servers for a secondary/stub-type description: Primaries lists source servers for a secondary/stub-type
zone. zone.
+13
View File
@@ -2760,6 +2760,19 @@ spec:
items: items:
type: string type: string
type: array 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 operator
leaves the apex NS alone and a newly seeded zone gets the primary's stable
in-cluster DNS name; clearing the field later does not retract what it
published.
items:
type: string
type: array
primaries: primaries:
description: Primaries lists source servers for a secondary/stub-type description: Primaries lists source servers for a secondary/stub-type
zone. zone.
+4 -3
View File
@@ -71,10 +71,11 @@ spec:
- key transfer-key - key transfer-key
updateKeyRef: transfer-key updateKeyRef: transfer-key
dynamicUpdate: true 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: records:
- name: "@"
type: NS
values: ["ns1.internal.example.com."]
- name: ns1 - name: ns1
type: A type: A
values: ["10.0.0.53"] values: ["10.0.0.53"]
+1
View File
@@ -23,6 +23,7 @@ const (
NamedBin = "/usr/sbin/named" NamedBin = "/usr/sbin/named"
RndcBin = "/usr/sbin/rndc" RndcBin = "/usr/sbin/rndc"
NsupdateBin = "/usr/bin/nsupdate" NsupdateBin = "/usr/bin/nsupdate"
DigBin = "/usr/bin/dig"
) )
// Config file paths derived from ConfigDir. // Config file paths derived from ConfigDir.
+69 -17
View File
@@ -19,35 +19,87 @@ type RecordUpdate struct {
Type string // RR type Type string // RR type
TTL int32 // record TTL TTL int32 // record TTL
Values []string // RDATA entries Values []string // RDATA entries
Delete bool // when true, delete the RRset instead of replacing it Delete bool // when true, delete instead of add
// PerValue operates on individual records rather than the whole RRset: adds
// leave existing records in place, deletes remove only the listed Values.
// Required at a zone apex, where BIND silently ignores an RRset-wide delete
// of NS or SOA and would turn a replace into an append.
PerValue bool
} }
// NSUpdate applies a set of record changes to zone by executing nsupdate on the // NSUpdate applies a set of record changes to zone by executing nsupdate on the
// primary pod, targeting the local server and authenticating with creds. All // primary pod, targeting the local server and authenticating with creds. All
// changes are sent in a single atomic transaction. // changes are sent in a single atomic transaction.
func (e *Executor) NSUpdate(ctx context.Context, namespace, pod, zone string, creds TSIGCreds, updates []RecordUpdate) error { func (e *Executor) NSUpdate(ctx context.Context, namespace, pod, zone string, creds TSIGCreds, updates []RecordUpdate) error {
var b strings.Builder
b.WriteString("server 127.0.0.1\n")
b.WriteString(fmt.Sprintf("zone %s\n", dot(zone)))
for _, u := range updates {
// Replace semantics: clear the RRset first, then add the desired values.
b.WriteString(fmt.Sprintf("update delete %s %s\n", dot(u.FQDN), u.Type))
if u.Delete {
continue
}
for _, v := range u.Values {
b.WriteString(fmt.Sprintf("update add %s %d %s %s\n", dot(u.FQDN), u.TTL, u.Type, v))
}
}
b.WriteString("send\n")
cmd := []string{NsupdateBin, "-y", fmt.Sprintf("%s:%s:%s", creds.Algorithm, creds.Name, creds.Secret)} cmd := []string{NsupdateBin, "-y", fmt.Sprintf("%s:%s:%s", creds.Algorithm, creds.Name, creds.Secret)}
if out, err := e.Exec(ctx, namespace, pod, cmd, b.String()); err != nil { if out, err := e.Exec(ctx, namespace, pod, cmd, nsupdateScript(zone, updates)); err != nil {
return fmt.Errorf("nsupdate zone %s: %w (out: %s)", zone, err, out) return fmt.Errorf("nsupdate zone %s: %w (out: %s)", zone, err, out)
} }
return nil return nil
} }
// ApexNS returns the zone's currently published apex NS names, so the operator
// can converge the RRset rather than append to it. The query is TSIG-signed with
// the same creds as an update: a zone behind a view whose match-clients is a key
// is unreachable to an unsigned query, which named answers REFUSED (with an empty
// body and a zero exit status), and the caller must not read that as "no NS".
func (e *Executor) ApexNS(ctx context.Context, namespace, pod, zone string, creds TSIGCreds) ([]string, error) {
cmd := []string{
DigBin, "-y", fmt.Sprintf("%s:%s:%s", creds.Algorithm, creds.Name, creds.Secret),
"+short", "+time=5", "+tries=1", "@127.0.0.1", dot(zone), "NS",
}
out, err := e.Exec(ctx, namespace, pod, cmd, "")
if err != nil {
return nil, fmt.Errorf("query apex NS of %s: %w (out: %s)", zone, err, out)
}
return parseDigNames(out), nil
}
// parseDigNames picks the answers out of `dig +short` output: one fully-qualified
// name per line. Anything without a trailing dot is not a name, and dig prefixes
// its diagnostics (a missing or mismatched TSIG key among them) with ';'.
func parseDigNames(out string) []string {
var names []string
for _, line := range strings.Split(out, "\n") {
line = strings.TrimSpace(line)
if strings.HasPrefix(line, ";") || !strings.HasSuffix(line, ".") {
continue
}
names = append(names, line)
}
return names
}
// nsupdateScript renders the nsupdate input for a set of changes.
func nsupdateScript(zone string, updates []RecordUpdate) string {
var b strings.Builder
b.WriteString("server 127.0.0.1\n")
fmt.Fprintf(&b, "zone %s\n", dot(zone))
for _, u := range updates {
switch {
case u.PerValue && u.Delete:
for _, v := range u.Values {
fmt.Fprintf(&b, "update delete %s %s %s\n", dot(u.FQDN), u.Type, v)
}
case u.PerValue:
for _, v := range u.Values {
fmt.Fprintf(&b, "update add %s %d %s %s\n", dot(u.FQDN), u.TTL, u.Type, v)
}
default:
// Replace semantics: clear the RRset first, then add the values.
fmt.Fprintf(&b, "update delete %s %s\n", dot(u.FQDN), u.Type)
if u.Delete {
continue
}
for _, v := range u.Values {
fmt.Fprintf(&b, "update add %s %d %s %s\n", dot(u.FQDN), u.TTL, u.Type, v)
}
}
}
b.WriteString("send\n")
return b.String()
}
// dot ensures a name is fully qualified with a trailing dot. // dot ensures a name is fully qualified with a trailing dot.
func dot(name string) string { func dot(name string) string {
if name == "" || name == "@" { if name == "" || name == "@" {
+64
View File
@@ -0,0 +1,64 @@
package bind
import (
"strings"
"testing"
)
func TestNSUpdateScriptReplaceSemantics(t *testing.T) {
got := nsupdateScript("acme.unkin.net", []RecordUpdate{
{FQDN: "www", Type: "A", TTL: 60, Values: []string{"10.0.0.1", "10.0.0.2"}},
{FQDN: "old.acme.unkin.net.", Type: "TXT", Delete: true},
})
want := `server 127.0.0.1
zone acme.unkin.net.
update delete www. A
update add www. 60 A 10.0.0.1
update add www. 60 A 10.0.0.2
update delete old.acme.unkin.net. TXT
send
`
if got != want {
t.Errorf("got:\n%s\nwant:\n%s", got, want)
}
}
// At the apex BIND ignores an RRset-wide delete of NS, so the apex sync must add
// the new names and delete the old ones record by record, adds first: named
// refuses to leave an apex with no NS record.
func TestNSUpdateScriptPerValueApexNS(t *testing.T) {
got := nsupdateScript("acme.unkin.net", []RecordUpdate{
{FQDN: "acme.unkin.net.", Type: "NS", TTL: 60, Values: []string{"acme-ns1.unkin.net."}, PerValue: true},
{FQDN: "acme.unkin.net.", Type: "NS", Values: []string{"ns1.acme.unkin.net."}, PerValue: true, Delete: true},
{FQDN: "ns1.acme.unkin.net.", Type: "A", Delete: true},
})
want := `server 127.0.0.1
zone acme.unkin.net.
update add acme.unkin.net. 60 NS acme-ns1.unkin.net.
update delete acme.unkin.net. NS ns1.acme.unkin.net.
update delete ns1.acme.unkin.net. A
send
`
if got != want {
t.Errorf("got:\n%s\nwant:\n%s", got, want)
}
}
func TestParseDigNames(t *testing.T) {
cases := []struct {
name, out, want string
}{
{"answers", "a.ns.unkin.net.\nb.ns.unkin.net.\n", "a.ns.unkin.net.,b.ns.unkin.net."},
{"REFUSED, SERVFAIL and NXDOMAIN all answer empty", "", ""},
// A zone behind a key-matched view answers an unsigned query REFUSED, and
// dig reports the key problem on a ';' line that happens to end in a dot.
{"dig diagnostics are not answers", ";; WARNING -- TSIG key was not used.\n", ""},
{"relative or partial lines are not names", "10.0.0.1\nns1\n", ""},
{"whitespace is trimmed", " ns1.unkin.net. \n\n", "ns1.unkin.net."},
}
for _, c := range cases {
if got := strings.Join(parseDigNames(c.out), ","); got != c.want {
t.Errorf("%s: parseDigNames(%q) = %q; want %q", c.name, c.out, got, c.want)
}
}
}
+41 -12
View File
@@ -26,36 +26,65 @@ func (e *Executor) ZoneExists(ctx context.Context, namespace, pod, zone, view st
return err == nil return err == nil
} }
// renderSeedZone renders a minimal loadable zone (SOA + apex NS + glue). The // renderSeedZone renders a minimal loadable zone (SOA + apex NS). nameservers
// apex NS is the in-zone name ns1, and a glue A record pointing at primaryIP is // are the names published in the apex NS RRset; when empty the in-zone name ns1
// included so BIND's check-integrity accepts the zone (an in-zone NS without an // is used. A glue A pointing at primaryIP is emitted only for a nameserver that
// address record is a load error). // falls inside the zone, because BIND refuses to load a zone whose in-zone NS
func renderSeedZone(zone, primaryIP string, serial int64) string { // 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) 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 // 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 // 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 // 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 // negative-cache TTL: keep it low so a stale-secondary NXDOMAIN does not
// stick in downstream resolvers for long. NOTIFY (also-notify on the // stick in downstream resolvers for long. NOTIFY (also-notify on the
// primary) remains the fast path; these are the fallback. // 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 ( @ IN SOA %s hostmaster.%s (
%d ; serial %d ; serial
300 ; refresh 300 ; refresh
60 ; retry 60 ; retry
1209600 ; expire 1209600 ; expire
60 ) ; minimum 60 ) ; minimum
@ IN NS %s `, ns[0], origin, serial)
ns1 IN A %s for _, n := range ns {
`, ns, origin, serial, ns, primaryIP) 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 // EnsureSeedZone makes path loadable without discarding live data: it probes
// the zone file and journal, moves aside whatever cannot load, and writes a // 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 // skeleton only when there is nothing to preserve. Every caller that needs a
// zone database file on disk goes through here. // 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) state, err := e.ZoneDiskState(ctx, namespace, pod, path)
if err != nil { if err != nil {
return err return err
@@ -67,7 +96,7 @@ func (e *Executor) EnsureSeedZone(ctx context.Context, namespace, pod, zone, pat
if !plan.WriteSeed { if !plan.WriteSeed {
return e.Quarantine(ctx, namespace, pod, path, plan) 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))} cmd := []string{"sh", "-c", seedScript(path, plan, len(content))}
if out, err := e.Exec(ctx, namespace, pod, cmd, content); err != nil { if out, err := e.Exec(ctx, namespace, pod, cmd, content); err != nil {
return fmt.Errorf("seed zone %s: %w (out: %s)", zone, err, out) 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() t.Helper()
dir = t.TempDir() dir = t.TempDir()
path = filepath.Join(dir, "db.example.com") 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) t.Fatal(err)
} }
if err := os.WriteFile(JournalPath(path), journalHeader(";BIND LOG V9.2\n", 10, 16), 0o600); err != nil { 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 { if !plan.WriteSeed || !plan.QuarantineZoneFile || !plan.QuarantineJournal {
t.Fatalf("expected a reseed over both files, got %+v", plan) 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 { 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") 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) dir, path := inFlightZone(t)
plan := PlanSeed(probeState(t, sh, path)) 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 { if err := runSeedScript(t, sh, path, plan, content, content); err != nil {
t.Fatalf("seed script: %v", err) t.Fatalf("seed script: %v", err)
} }
@@ -198,7 +198,7 @@ func TestSeedScriptFreshInstall(t *testing.T) {
path := filepath.Join(t.TempDir(), "zones", "db.example.com") path := filepath.Join(t.TempDir(), "zones", "db.example.com")
plan := PlanSeed(ZoneDiskState{}) 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 { if err := runSeedScript(t, sh, path, plan, content, content); err != nil {
t.Fatalf("seed script: %v", err) t.Fatalf("seed script: %v", err)
} }
@@ -225,7 +225,7 @@ func TestSeedScriptFailedQuarantineAbortsInstall(t *testing.T) {
exec `+realTool(t, "mv")+` "$@"`) exec `+realTool(t, "mv")+` "$@"`)
plan := PlanSeed(probeState(t, sh, path)) 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 { if err := runSeedScriptWithPath(t, sh, path, plan, content, content, pathEnv); err == nil {
t.Fatal("a failed quarantine must fail the seed") t.Fatal("a failed quarantine must fail the seed")
} }
@@ -240,7 +240,7 @@ func TestSeedScriptUnmeasurableStagingAbortsInstall(t *testing.T) {
pathEnv := shimPath(t, "wc", "exit 127") pathEnv := shimPath(t, "wc", "exit 127")
plan := PlanSeed(probeState(t, sh, path)) 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 { if err := runSeedScriptWithPath(t, sh, path, plan, content, content, pathEnv); err == nil {
t.Fatal("an unmeasurable staging file must fail the seed") 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") pathEnv := shimPath(t, "mkdir", "exit 1")
plan := PlanSeed(probeState(t, sh, path)) 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 { if err := runSeedScriptWithPath(t, sh, path, plan, content, content, pathEnv); err == nil {
t.Fatal("a failed mkdir must fail the seed") 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 ok bool
}{ }{
{"bind dump", bindDump, 16, true}, {"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}, {"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}, {"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}, {"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) { 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) got, ok := ParseZoneSerial(content)
if !ok || got != 17 { if !ok || got != 17 {
t.Fatalf("seed zone serial = (%d,%v) want (17,true)", got, ok) 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: "fresh install"},
{ {
name: "zone file only", 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}, want: ZoneDiskState{ZoneFile: true, ZoneSerial: 42, ZoneSerialOK: true},
}, },
{ {
name: "zone file and journal", 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), jnl: journalHeader(";BIND LOG V9.2\n", 10, 16),
want: ZoneDiskState{ want: ZoneDiskState{
ZoneFile: true, ZoneSerial: 12, ZoneSerialOK: true, ZoneFile: true, ZoneSerial: 12, ZoneSerialOK: true,
@@ -452,7 +452,7 @@ func TestZoneStateProbeRoundTrip(t *testing.T) {
}, },
{ {
name: "live zone beside old quarantine evidence", 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"}, orphans: []string{".orphaned-16"},
want: ZoneDiskState{ want: ZoneDiskState{
ZoneFile: true, ZoneSerial: 42, ZoneSerialOK: true, ZoneFile: true, ZoneSerial: 42, ZoneSerialOK: true,
+165
View File
@@ -0,0 +1,165 @@
package controller
import (
"strings"
"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 testCluster() *bindv1alpha1.BindCluster {
c := &bindv1alpha1.BindCluster{}
c.Name, c.Namespace = "auth", "bind-internal"
return c
}
const stableNS = "auth-0.auth-headless.bind-internal.svc.cluster.local."
func TestZoneNameservers(t *testing.T) {
ttl60 := int32(60)
cases := []struct {
name string
spec bindv1alpha1.BindZoneSpec
want []string
ttl int32
declared bool
}{
{"fallback is out-of-zone, so it needs no glue", bindv1alpha1.BindZoneSpec{}, []string{stableNS}, 3600, false},
{"declared wins", bindv1alpha1.BindZoneSpec{Nameservers: []string{"ns1.unkin.net"}}, []string{"ns1.unkin.net."}, 3600, true},
{"spec.defaultTTL applies", bindv1alpha1.BindZoneSpec{Nameservers: []string{"ns1.unkin.net."}, DefaultTTL: 60}, []string{"ns1.unkin.net."}, 60, true},
// An apex NS in spec.records cannot converge on its own: BIND ignores an
// RRset-wide delete at the apex, so it has to go through the apex path.
{"apex NS in records counts as declared", bindv1alpha1.BindZoneSpec{Records: []bindv1alpha1.Record{
{Name: "@", Type: "ns", Values: []string{"a.ns.unkin.net.", "b.ns.unkin.net."}},
{Name: "www", Type: "A", Values: []string{"10.0.0.1"}},
}}, []string{"a.ns.unkin.net.", "b.ns.unkin.net."}, 3600, true},
// A TTL on the apex NS record itself must survive the fold.
{"record TTL beats the zone default", bindv1alpha1.BindZoneSpec{DefaultTTL: 3600, Records: []bindv1alpha1.Record{
{Name: "@", Type: "NS", TTL: &ttl60, Values: []string{"a.ns.unkin.net."}},
}}, []string{"a.ns.unkin.net."}, 60, true},
{"spec.nameservers beats records", bindv1alpha1.BindZoneSpec{
Nameservers: []string{"ns1.unkin.net."},
Records: []bindv1alpha1.Record{{Name: "@", Type: "NS", Values: []string{"other.unkin.net."}}},
}, []string{"ns1.unkin.net."}, 3600, true},
}
for _, c := range cases {
got, ttl, declared := zoneNameservers(zoneWith(c.spec), testCluster())
if declared != c.declared || ttl != c.ttl || strings.Join(got, ",") != strings.Join(c.want, ",") {
t.Errorf("%s: got %v/%d/%v; want %v/%d/%v", c.name, got, ttl, declared, c.want, c.ttl, c.declared)
}
}
}
// A query that cannot see the zone returns nothing, which must not be read as an
// empty apex: retracting blind means deleting the last NS record, which named
// rejects, leaving the zone stuck.
func TestApexNSUpdatesUnreadableLiveSetIsAdditive(t *testing.T) {
zone := zoneWith(bindv1alpha1.BindZoneSpec{Nameservers: []string{"ns1.unkin.net."}})
got := apexNSUpdates(zone, []string{"ns1.unkin.net."}, nil, 3600)
assertUpdates(t, got, []bind.RecordUpdate{
{FQDN: "acme.unkin.net.", Type: "NS", TTL: 3600, Values: []string{"ns1.unkin.net."}, PerValue: true},
})
}
// The apex NS RRset must be converged per record: an RRset-wide delete at the
// apex is ignored by BIND, which would leave the placeholder published alongside
// the real nameservers. Retracting an in-zone name takes its glue with it.
func TestApexNSUpdatesConverges(t *testing.T) {
zone := zoneWith(bindv1alpha1.BindZoneSpec{Nameservers: []string{"ns1.unkin.net."}, DefaultTTL: 60})
got := apexNSUpdates(zone, []string{"ns1.unkin.net."}, []string{"ns1.acme.unkin.net."}, 60)
assertUpdates(t, got, []bind.RecordUpdate{
{FQDN: "acme.unkin.net.", Type: "NS", TTL: 60, Values: []string{"ns1.unkin.net."}, PerValue: true},
{FQDN: "acme.unkin.net.", Type: "NS", Values: []string{"ns1.acme.unkin.net."}, PerValue: true, Delete: true},
{FQDN: "ns1.acme.unkin.net.", Type: "A", Delete: true},
})
}
// Glue retirement is not special-cased to the name ns1: the seed glues every
// declared in-zone nameserver.
func TestApexNSUpdatesRetiresAnyInZoneGlue(t *testing.T) {
zone := zoneWith(bindv1alpha1.BindZoneSpec{Nameservers: []string{"a.ns.unkin.net."}})
got := apexNSUpdates(zone, []string{"a.ns.unkin.net."}, []string{"dns.acme.unkin.net."}, 3600)
assertUpdates(t, got, []bind.RecordUpdate{
{FQDN: "acme.unkin.net.", Type: "NS", TTL: 3600, Values: []string{"a.ns.unkin.net."}, PerValue: true},
{FQDN: "acme.unkin.net.", Type: "NS", Values: []string{"dns.acme.unkin.net."}, PerValue: true, Delete: true},
{FQDN: "dns.acme.unkin.net.", Type: "A", Delete: true},
})
}
func TestApexNSUpdatesNoopWhenConverged(t *testing.T) {
zone := zoneWith(bindv1alpha1.BindZoneSpec{Nameservers: []string{"ns1.unkin.net"}})
// Case differs: DNS names compare case-insensitively, so this is converged.
if got := apexNSUpdates(zone, []string{"ns1.unkin.net."}, []string{"NS1.Unkin.Net."}, 3600); len(got) != 0 {
t.Fatalf("expected no updates, got %+v", got)
}
}
// Changing the declared set replaces only what changed, keeping the overlap.
func TestApexNSUpdatesPartialChange(t *testing.T) {
zone := zoneWith(bindv1alpha1.BindZoneSpec{Nameservers: []string{"a.ns.unkin.net.", "c.ns.unkin.net."}})
got := apexNSUpdates(zone, []string{"a.ns.unkin.net.", "c.ns.unkin.net."}, []string{"a.ns.unkin.net.", "b.ns.unkin.net."}, 3600)
assertUpdates(t, got, []bind.RecordUpdate{
{FQDN: "acme.unkin.net.", Type: "NS", TTL: 3600, Values: []string{"c.ns.unkin.net."}, PerValue: true},
{FQDN: "acme.unkin.net.", Type: "NS", Values: []string{"b.ns.unkin.net."}, PerValue: true, Delete: true},
})
}
// A declared in-zone nameserver owns its glue; removing it would fail named's
// post-update nameserver sanity check.
func TestApexNSUpdatesKeepsNeededGlue(t *testing.T) {
zone := zoneWith(bindv1alpha1.BindZoneSpec{Nameservers: []string{"ns1.acme.unkin.net."}})
if got := apexNSUpdates(zone, []string{"ns1.acme.unkin.net."}, []string{"ns1.acme.unkin.net."}, 3600); len(got) != 0 {
t.Fatalf("expected no updates, got %+v", got)
}
}
// spec.records owning the ns1 address means the glue is real data, not the seed
// placeholder.
func TestApexNSUpdatesLeavesRecordOwnedGlue(t *testing.T) {
zone := zoneWith(bindv1alpha1.BindZoneSpec{
Nameservers: []string{"ns1.unkin.net."},
Records: []bindv1alpha1.Record{{Name: "ns1", Type: "a", Values: []string{"10.0.0.53"}}},
})
got := apexNSUpdates(zone, []string{"ns1.unkin.net."}, []string{"ns1.acme.unkin.net."}, 3600)
assertUpdates(t, got, []bind.RecordUpdate{
{FQDN: "acme.unkin.net.", Type: "NS", TTL: 3600, Values: []string{"ns1.unkin.net."}, PerValue: true},
{FQDN: "acme.unkin.net.", Type: "NS", Values: []string{"ns1.acme.unkin.net."}, PerValue: true, Delete: true},
})
}
// The apex NS is converged by the apex path, so it must not also be emitted as a
// record: an RRset-wide delete there is ignored and the add would append.
func TestRecordsToUpdatesSkipsApexNS(t *testing.T) {
records := []bindv1alpha1.Record{
{Name: "@", Type: "NS", Values: []string{"a.ns.unkin.net."}},
{Name: "sub", Type: "NS", Values: []string{"d.ns.unkin.net."}},
{Name: "@", Type: "MX", Values: []string{"10 mail.unkin.net."}},
}
got := recordsToUpdates("acme.unkin.net", records, 3600)
if len(got) != 2 || got[0].FQDN != "sub.acme.unkin.net." || got[1].Type != "MX" {
t.Fatalf("got %+v", got)
}
}
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 {
g, w := got[i], want[i]
if g.FQDN != w.FQDN || g.Type != w.Type || g.TTL != w.TTL || g.Delete != w.Delete || g.PerValue != w.PerValue {
t.Errorf("update %d = %+v; want %+v", i, g, w)
continue
}
if strings.Join(g.Values, ",") != strings.Join(w.Values, ",") {
t.Errorf("update %d values = %v; want %v", i, g.Values, w.Values)
}
}
}
@@ -54,7 +54,7 @@ func (r *BindCatalogZoneReconciler) Reconcile(ctx context.Context, req ctrl.Requ
if primaryIP == "" { if primaryIP == "" {
return r.fail(ctx, &catalog, "PrimaryNoIP", "waiting for primary pod IP") 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, clusterNameservers(cluster)); err != nil {
return r.fail(ctx, &catalog, "SeedFailed", err.Error()) 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 == "" { if primaryIP == "" {
return r.fail(ctx, &policy, "PrimaryNoIP", "waiting for primary pod IP") 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, clusterNameservers(cluster)); err != nil {
return r.fail(ctx, &policy, "SeedFailed", err.Error()) return r.fail(ctx, &policy, "SeedFailed", err.Error())
} }
} }
+33 -7
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()) return r.setPhase(ctx, &zone, "Error", "ConfigError", err.Error())
} }
nameservers, nsTTL, nsDeclared := zoneNameservers(&zone, cluster)
created := !r.Exec.ZoneExists(ctx, zone.Namespace, primaryPod, zone.Spec.ZoneName, zone.Spec.ViewRef) created := !r.Exec.ZoneExists(ctx, zone.Namespace, primaryPod, zone.Spec.ZoneName, zone.Spec.ViewRef)
if created && (zone.Spec.Type == bindv1alpha1.ZonePrimary || zone.Spec.Type == "") { if created && (zone.Spec.Type == bindv1alpha1.ZonePrimary || zone.Spec.Type == "") {
primaryIP := primaryPodIP(ctx, r.Client, cluster) 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 // The zone is absent from named's memory, but its database file and
// journal may still be on the PVC from a previous incarnation. // journal may still be on the PVC from a previous incarnation.
path := bind.ZoneFilePath(zone.Spec.ZoneName) 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()) return r.setPhase(ctx, &zone, "Error", "SeedFailed", err.Error())
} }
} }
@@ -116,18 +118,42 @@ func (r *BindZoneReconciler) Reconcile(ctx context.Context, req ctrl.Request) (c
return r.setPhase(ctx, &zone, "Error", "AddZoneFailed", err.Error()) return r.setPhase(ctx, &zone, "Error", "AddZoneFailed", err.Error())
} }
// Seed static records (primary zones only). // Records are applied before the apex NS: an in-zone nameserver's address
// record has to exist first, or named rejects the apex transaction with a
// post-update nameserver sanity check failure.
recordCount := 0 recordCount := 0
if isPrimaryType(zone.Spec.Type) && len(zone.Spec.Records) > 0 { records := recordsToUpdates(zone.Spec.ZoneName, zone.Spec.Records, zone.Spec.DefaultTTL)
if isPrimaryType(zone.Spec.Type) && (len(records) > 0 || nsDeclared) {
creds, err := r.zoneUpdateCreds(ctx, &zone) creds, err := r.zoneUpdateCreds(ctx, &zone)
if err != nil { if err != nil {
return r.setPhase(ctx, &zone, "Error", "NoUpdateKey", err.Error()) return r.setPhase(ctx, &zone, "Error", "NoUpdateKey", err.Error())
} }
updates := recordsToUpdates(zone.Spec.ZoneName, zone.Spec.Records, zone.Spec.DefaultTTL) if len(records) > 0 {
if err := r.Exec.NSUpdate(ctx, zone.Namespace, primaryPod, zone.Spec.ZoneName, creds, updates); err != nil { if err := r.Exec.NSUpdate(ctx, zone.Namespace, primaryPod, zone.Spec.ZoneName, creds, records); err != nil {
return r.setPhase(ctx, &zone, "Error", "RecordUpdateFailed", err.Error()) return r.setPhase(ctx, &zone, "Error", "RecordUpdateFailed", err.Error())
}
recordCount = len(records)
}
// Converge the apex NS on every pass, not only at seed time, so a zone
// seeded with the placeholder moves onto its real nameservers. Only for a
// zone that declared them: otherwise the operator would fight whoever else
// manages the RRset.
if nsDeclared {
live, err := r.Exec.ApexNS(ctx, zone.Namespace, primaryPod, zone.Spec.ZoneName, creds)
if err != nil {
return r.setPhase(ctx, &zone, "Error", "ApexNSQueryFailed", err.Error())
}
if len(live) == 0 {
logger.Info("apex NS not readable, publishing without retracting", "zone", zone.Spec.ZoneName)
}
if apex := apexNSUpdates(&zone, nameservers, live, nsTTL); len(apex) > 0 {
if err := r.Exec.NSUpdate(ctx, zone.Namespace, primaryPod, zone.Spec.ZoneName, creds, apex); err != nil {
return r.setPhase(ctx, &zone, "Error", "ApexNSSyncFailed",
fmt.Sprintf("%s (a nameserver inside the zone needs an address record here)", err))
}
logger.Info("apex NS converged", "zone", zone.Spec.ZoneName, "nameservers", nameservers)
}
} }
recordCount = len(updates)
} }
// Register in the catalog so secondaries auto-provision. // Register in the catalog so secondaries auto-provision.
+121
View File
@@ -42,6 +42,11 @@ func fqdn(name, zone string) string {
func recordsToUpdates(zone string, records []bindv1alpha1.Record, defaultTTL int32) []bind.RecordUpdate { func recordsToUpdates(zone string, records []bindv1alpha1.Record, defaultTTL int32) []bind.RecordUpdate {
updates := make([]bind.RecordUpdate, 0, len(records)) updates := make([]bind.RecordUpdate, 0, len(records))
for _, rec := range records { for _, rec := range records {
// The apex NS RRset is converged by apexNSUpdates: an RRset-wide delete
// here is ignored by BIND and would only append to the live set.
if strings.EqualFold(rec.Type, "NS") && fqdn(rec.Name, zone) == fqdn("@", zone) {
continue
}
ttl := defaultTTL ttl := defaultTTL
if rec.TTL != nil { if rec.TTL != nil {
ttl = *rec.TTL ttl = *rec.TTL
@@ -114,3 +119,119 @@ func alsoNotifyList(addrs []string, key string) string {
} }
return strings.Join(parts, " ") 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
// TTL to publish them with, and whether the zone declared them. An apex NS in
// spec.records counts as a declaration: BIND ignores an RRset-wide delete at the
// apex, so records alone can only append to what the zone was seeded with, never
// replace it. Undeclared zones fall back to the primary's stable in-cluster name,
// which is deliberately out-of-zone so no pod IP is needed as glue.
func zoneNameservers(zone *bindv1alpha1.BindZone, cluster *bindv1alpha1.BindCluster) (names []string, ttl int32, declared bool) {
ttl = zone.Spec.DefaultTTL
for _, ns := range zone.Spec.Nameservers {
names = append(names, absolute(ns))
}
for _, rec := range zone.Spec.Records {
if len(names) > 0 {
break
}
if !strings.EqualFold(rec.Type, "NS") || fqdn(rec.Name, zone.Spec.ZoneName) != fqdn("@", zone.Spec.ZoneName) {
continue
}
for _, v := range rec.Values {
names = append(names, absolute(v))
}
if rec.TTL != nil {
ttl = *rec.TTL
}
}
if ttl <= 0 {
ttl = 3600
}
if len(names) > 0 {
return names, ttl, true
}
return clusterNameservers(cluster), ttl, false
}
// apexNSUpdates moves a zone's apex NS RRset from live onto desired, and retires
// the glue of any in-zone name it retracts. Adds come first: BIND refuses to
// leave an apex with no NS record, so the replacement must exist before the old
// name goes, and deleting glue still referenced by an in-zone NS fails named's
// post-update nameserver sanity check.
//
// An empty live set means the query could not see the zone, not that the apex has
// no NS records — a primary always has one. Nothing is retracted in that case:
// retracting blind is what turns a delete into "delete the last NS", which named
// rejects outright.
// ponytail: names already published are diffed by name only, so an edit to just
// the TTL never republishes them (dig +short cannot report a TTL). Re-add the
// whole desired set each pass if TTL edits need to converge.
func apexNSUpdates(zone *bindv1alpha1.BindZone, desired, live []string, ttl int32) []bind.RecordUpdate {
apex := fqdn("@", zone.Spec.ZoneName)
add := missing(desired, live)
var updates []bind.RecordUpdate
if len(add) > 0 {
updates = append(updates, bind.RecordUpdate{FQDN: apex, Type: "NS", TTL: ttl, Values: add, PerValue: true})
}
// missing() yields nothing against an empty live set, so an unreadable RRset
// retracts nothing on its own.
del := missing(live, desired)
if len(del) == 0 {
return updates
}
updates = append(updates, bind.RecordUpdate{FQDN: apex, Type: "NS", Values: del, PerValue: true, Delete: true})
// The seed glues an in-zone nameserver to the primary pod's IP, which goes
// stale on the first reschedule. Drop that address with the name, unless
// spec.records owns it (then it is real data, not the placeholder).
for _, ns := range del {
owner, in := bind.InZoneOwner(ns, zone.Spec.ZoneName)
if in && owner != "@" && !recordsOwn(zone, owner, "A") {
updates = append(updates, bind.RecordUpdate{FQDN: fqdn(owner, zone.Spec.ZoneName), Type: "A", Delete: true})
}
}
return updates
}
// clusterNameservers is the apex NS for the operator's own internal zones
// (catalog, policy): the primary's stable in-cluster name, never a pod IP.
func clusterNameservers(cluster *bindv1alpha1.BindCluster) []string {
return []string{primaryAddress(cluster.Name, cluster.Namespace) + "."}
}
// missing returns the names in want with no match in have. DNS names compare
// case-insensitively.
func missing(want, have []string) (out []string) {
for _, w := range want {
if !containsName(have, w) {
out = append(out, w)
}
}
return out
}
func containsName(names []string, name string) bool {
for _, n := range names {
if strings.EqualFold(absolute(n), absolute(name)) {
return true
}
}
return false
}
// recordsOwn reports whether spec.records already manages an owner/type pair, in
// which case the apex sync must leave it alone.
func recordsOwn(zone *bindv1alpha1.BindZone, name, typ string) bool {
for _, rec := range zone.Spec.Records {
if strings.EqualFold(rec.Type, typ) && strings.EqualFold(fqdn(rec.Name, zone.Spec.ZoneName), fqdn(name, zone.Spec.ZoneName)) {
return true
}
}
return false
}