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:
+41
-12
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
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")
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user