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:
@@ -64,6 +64,15 @@ 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 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.
|
// Records are static record sets seeded into a primary zone.
|
||||||
// +optional
|
// +optional
|
||||||
Records []Record `json:"records,omitempty"`
|
Records []Record `json:"records,omitempty"`
|
||||||
|
|||||||
@@ -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,17 @@ 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 apex NS is
|
||||||
|
the primary's stable in-cluster DNS name.
|
||||||
|
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.
|
||||||
|
|||||||
@@ -2760,6 +2760,17 @@ 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 apex NS is
|
||||||
|
the primary's stable in-cluster DNS name.
|
||||||
|
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.
|
||||||
|
|||||||
@@ -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"]
|
||||||
|
|||||||
+41
-12
@@ -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)
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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 == "" {
|
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, zoneNameservers(nil, cluster)); err != nil {
|
||||||
return r.fail(ctx, &catalog, "SeedFailed", err.Error())
|
return r.fail(ctx, &catalog, "SeedFailed", err.Error())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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, zoneNameservers(nil, cluster)); err != nil {
|
||||||
return r.fail(ctx, &policy, "SeedFailed", err.Error())
|
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())
|
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)
|
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,29 @@ 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).
|
// 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
|
recordCount := 0
|
||||||
if isPrimaryType(zone.Spec.Type) && len(zone.Spec.Records) > 0 {
|
if isPrimaryType(zone.Spec.Type) {
|
||||||
creds, err := r.zoneUpdateCreds(ctx, &zone)
|
creds, credErr := r.zoneUpdateCreds(ctx, &zone)
|
||||||
if err != nil {
|
if apex := apexNSUpdates(&zone, nameservers); len(apex) > 0 && credErr == nil {
|
||||||
return r.setPhase(ctx, &zone, "Error", "NoUpdateKey", err.Error())
|
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)
|
// Seed static records.
|
||||||
if err := r.Exec.NSUpdate(ctx, zone.Namespace, primaryPod, zone.Spec.ZoneName, creds, updates); err != nil {
|
if len(zone.Spec.Records) > 0 {
|
||||||
return r.setPhase(ctx, &zone, "Error", "RecordUpdateFailed", err.Error())
|
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.
|
// Register in the catalog so secondaries auto-provision.
|
||||||
|
|||||||
@@ -114,3 +114,58 @@ 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
|
||||||
|
// 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