Files
bind-operator/internal/bind/zonestate_test.go
T
unkin-agent f1d47c8ff7
ci/woodpecker/pr/pre-commit Pipeline was successful
ci/woodpecker/pr/test Pipeline was successful
ci/woodpecker/pr/build Pipeline was successful
Inspect zone file and journal before seeding a zone
Fixes #19
2026-09-19 22:33:48 +10:00

245 lines
7.6 KiB
Go

package bind
import (
"encoding/hex"
"strings"
"testing"
)
func journalHeader(magic string, begin, end uint32) []byte {
b := make([]byte, 32)
copy(b, magic)
put := func(off int, v uint32) {
b[off] = byte(v >> 24)
b[off+1] = byte(v >> 16)
b[off+2] = byte(v >> 8)
b[off+3] = byte(v)
}
put(16, begin)
put(24, end)
return b
}
func TestParseZoneSerial(t *testing.T) {
bindDump := `$ORIGIN .
$TTL 3600 ; 1 hour
k8s.syd1.au.unkin.net IN SOA ns1.k8s.syd1.au.unkin.net. hostmaster.k8s.syd1.au.unkin.net. (
16 ; serial
300 ; refresh (5 minutes)
60 ; retry (1 minute)
1209600 ; expire (2 weeks)
60 ; minimum (1 minute)
)
`
cases := []struct {
name string
content string
want int64
ok bool
}{
{"bind dump", bindDump, 16, true},
{"seed", renderSeedZone("example.com", "10.0.0.1", 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},
{"truncated", "@ IN SOA ns.\n", 0, false},
{"non numeric serial", "@ IN SOA ns. host. ( abc 300 )\n", 0, false},
}
for _, c := range cases {
got, ok := ParseZoneSerial(c.content)
if got != c.want || ok != c.ok {
t.Errorf("%s: ParseZoneSerial = (%d,%v) want (%d,%v)", c.name, got, ok, c.want, c.ok)
}
}
}
func TestParseJournalHeader(t *testing.T) {
begin, end, ok := parseJournalHeader(journalHeader(";BIND LOG V9.2\n", 10, 16))
if !ok || begin != 10 || end != 16 {
t.Errorf("V9.2 header = (%d,%d,%v) want (10,16,true)", begin, end, ok)
}
if _, _, ok := parseJournalHeader(journalHeader("not a journal\n", 10, 16)); ok {
t.Error("bad magic should not parse")
}
if _, _, ok := parseJournalHeader([]byte(";BIND LOG V9.2\n")); ok {
t.Error("truncated header should not parse")
}
}
func TestParseZoneDiskState(t *testing.T) {
out := strings.Join([]string{
"zonefile=1",
"head<<",
"@ IN SOA ns. host. ( 5 300 60 1209600 60 )",
">>head",
"journal=1",
"jnl=" + hex.EncodeToString(journalHeader(";BIND LOG V9.2\n", 3, 8)),
"",
}, "\n")
st := parseZoneDiskState(out)
if !st.ZoneFile || !st.ZoneSerialOK || st.ZoneSerial != 5 {
t.Errorf("zone file state wrong: %+v", st)
}
if !st.Journal || !st.JournalOK || st.JournalBegin != 3 || st.JournalEnd != 8 {
t.Errorf("journal state wrong: %+v", st)
}
empty := parseZoneDiskState("zonefile=0\njournal=0\n")
if empty.ZoneFile || empty.Journal {
t.Errorf("empty state wrong: %+v", empty)
}
}
// applyPlan models what the pod filesystem looks like after the plan runs, so
// a second PlanSeed can be checked for idempotence.
func applyPlan(st ZoneDiskState, p SeedPlan) ZoneDiskState {
if p.QuarantineJournal {
st.Journal, st.JournalBegin, st.JournalEnd, st.JournalOK = false, 0, 0, false
}
if p.QuarantineZoneFile {
st.ZoneFile, st.ZoneSerial, st.ZoneSerialOK = false, 0, false
}
if p.WriteSeed {
st.ZoneFile, st.ZoneSerial, st.ZoneSerialOK = true, p.Serial, true
}
return st
}
func TestPlanSeedFreshInstall(t *testing.T) {
p := PlanSeed(ZoneDiskState{})
if !p.WriteSeed || p.Serial != 1 {
t.Fatalf("fresh install should seed at serial 1, got %+v", p)
}
if p.QuarantineZoneFile || p.QuarantineJournal {
t.Errorf("fresh install should quarantine nothing, got %+v", p)
}
}
func TestPlanSeedOrphanJournal(t *testing.T) {
st := ZoneDiskState{Journal: true, JournalBegin: 10, JournalEnd: 16, JournalOK: true}
p := PlanSeed(st)
if !p.WriteSeed {
t.Fatalf("orphan journal should still seed, got %+v", p)
}
if !p.QuarantineJournal || p.QuarantineZoneFile {
t.Errorf("only the journal should be quarantined, got %+v", p)
}
if p.Serial <= 16 {
t.Errorf("seed serial %d must exceed the journal end serial 16", p.Serial)
}
if p.QuarantineSuffix != ".orphaned-16" {
t.Errorf("quarantine suffix should be deterministic, got %q", p.QuarantineSuffix)
}
}
func TestPlanSeedFileRegressedBehindJournal(t *testing.T) {
// The production failure: a skeleton at serial 1 left next to a journal at
// serial 16, which BIND refuses to replay ("out of range").
st := ZoneDiskState{
ZoneFile: true, ZoneSerial: 1, ZoneSerialOK: true,
Journal: true, JournalBegin: 10, JournalEnd: 16, JournalOK: true,
}
p := PlanSeed(st)
if !p.WriteSeed || p.Serial <= 16 {
t.Fatalf("reseed must land above the journal end serial, got %+v", p)
}
if !p.QuarantineJournal || !p.QuarantineZoneFile {
t.Errorf("the unloadable pair should both be moved aside, got %+v", p)
}
after := applyPlan(st, p)
if after.Journal {
t.Error("journal should be gone after quarantine")
}
if next := PlanSeed(after); next != (SeedPlan{}) {
t.Errorf("second reconcile should be a no-op, got %+v", next)
}
if after.ZoneSerial != p.Serial {
t.Errorf("second reconcile changed the serial: %d want %d", after.ZoneSerial, p.Serial)
}
}
func TestPlanSeedLeavesHealthyZoneAlone(t *testing.T) {
cases := []struct {
name string
st ZoneDiskState
}{
{"file and covering journal", ZoneDiskState{
ZoneFile: true, ZoneSerial: 16, ZoneSerialOK: true,
Journal: true, JournalBegin: 10, JournalEnd: 20, JournalOK: true,
}},
{"file at journal end", ZoneDiskState{
ZoneFile: true, ZoneSerial: 20, ZoneSerialOK: true,
Journal: true, JournalBegin: 10, JournalEnd: 20, JournalOK: true,
}},
{"file without journal", ZoneDiskState{ZoneFile: true, ZoneSerial: 16, ZoneSerialOK: true}},
{"unreadable journal header", ZoneDiskState{
ZoneFile: true, ZoneSerial: 16, ZoneSerialOK: true, Journal: true,
}},
{"unparsable zone file", ZoneDiskState{ZoneFile: true}},
}
for _, c := range cases {
if p := PlanSeed(c.st); p != (SeedPlan{}) {
t.Errorf("%s: live data must not be touched, got %+v", c.name, p)
}
}
}
func TestPlanSeedStaleJournalBehindFile(t *testing.T) {
st := ZoneDiskState{
ZoneFile: true, ZoneSerial: 30, ZoneSerialOK: true,
Journal: true, JournalBegin: 10, JournalEnd: 16, JournalOK: true,
}
p := PlanSeed(st)
if p.WriteSeed || p.QuarantineZoneFile {
t.Fatalf("a file ahead of its journal is live data, got %+v", p)
}
if !p.QuarantineJournal {
t.Errorf("the unreplayable journal should be moved aside, got %+v", p)
}
if next := PlanSeed(applyPlan(st, p)); next != (SeedPlan{}) {
t.Errorf("second reconcile should be a no-op, got %+v", next)
}
}
func TestPlanSeedFreshInstallIdempotent(t *testing.T) {
st := ZoneDiskState{}
p := PlanSeed(st)
after := applyPlan(st, p)
if next := PlanSeed(after); next != (SeedPlan{}) {
t.Fatalf("second reconcile of a fresh zone should be a no-op, got %+v", next)
}
if after.ZoneSerial != 1 {
t.Errorf("serial reset on second reconcile: %d", after.ZoneSerial)
}
}
func TestPlanSeedSerialWrap(t *testing.T) {
st := ZoneDiskState{Journal: true, JournalBegin: 1 << 31, JournalEnd: 1<<32 - 1, JournalOK: true}
if got := PlanSeed(st).Serial; got != 1 {
t.Errorf("serial after wrap = %d want 1", got)
}
}
func TestSeedZoneRoundTripsThroughParser(t *testing.T) {
content := renderSeedZone("200.18.198.in-addr.arpa", "198.18.200.8", 17)
got, ok := ParseZoneSerial(content)
if !ok || got != 17 {
t.Fatalf("seed zone serial = (%d,%v) want (17,true)", got, ok)
}
}
func TestQuarantinePathsAreSuffixed(t *testing.T) {
path := ZoneFilePath("example.com")
if JournalPath(path) != path+".jnl" {
t.Fatalf("journal path = %q", JournalPath(path))
}
cmd := moveAside(JournalPath(path), ".orphaned-16")
if !strings.Contains(cmd, "'"+path+".jnl.orphaned-16'") {
t.Errorf("quarantine command should rename, not delete: %s", cmd)
}
if strings.Contains(cmd, "rm ") {
t.Errorf("quarantine must never delete: %s", cmd)
}
}