596 lines
20 KiB
Go
596 lines
20 KiB
Go
package bind
|
|
|
|
import (
|
|
"encoding/hex"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
)
|
|
|
|
// probeOut frames body the way zoneStateProbe does, so the parser is exercised
|
|
// on realistic input.
|
|
func probeOut(body ...string) string {
|
|
body = append(body, probeOrphanOpen, probeOrphanShut)
|
|
return strings.Join(append(append([]string{probeBegin}, body...), probeEnd, ""), "\n")
|
|
}
|
|
|
|
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", 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},
|
|
{"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 := probeOut(
|
|
"zonefile=1",
|
|
probeHeadOpen,
|
|
"@ IN SOA ns. host. ( 5 300 60 1209600 60 )",
|
|
probeHeadShut,
|
|
"journal=1",
|
|
"jnl="+hex.EncodeToString(journalHeader(";BIND LOG V9.2\n", 3, 8)),
|
|
)
|
|
st, ok := parseZoneDiskState(out)
|
|
if !ok {
|
|
t.Fatalf("well-formed probe rejected: %q", 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, ok := parseZoneDiskState(probeOut("zonefile=0", "journal=0"))
|
|
if !ok || empty.ZoneFile || empty.Journal {
|
|
t.Errorf("empty state wrong: %+v (ok=%v)", empty, ok)
|
|
}
|
|
}
|
|
|
|
// A probe that returns nothing useful must not be read as "fresh install": the
|
|
// shell exits 0 after its last printf and stderr is dropped on success, so a
|
|
// missing tool or a truncated stream is otherwise invisible.
|
|
func TestParseZoneDiskStateRejectsDegradedProbe(t *testing.T) {
|
|
live := probeHeadOpen + "\n@ IN SOA ns. host. ( 5 300 60 1209600 60 )\n" + probeHeadShut
|
|
cases := map[string]string{
|
|
"empty output": "",
|
|
"whitespace only": "\n\n",
|
|
"no framing": "zonefile=0\njournal=0\n",
|
|
"no terminator": probeBegin + "\nzonefile=0\njournal=0\n",
|
|
"cut before zone file": probeBegin + "\n",
|
|
"cut mid head": probeBegin + "\nzonefile=1\n" + probeHeadOpen + "\n@ IN SOA ns. host. ( 5",
|
|
"cut after head": probeBegin + "\nzonefile=1\n" + live + "\n",
|
|
"no zone declaration": probeOut("journal=0"),
|
|
"no journal branch": probeOut("zonefile=1", live),
|
|
"journal without hex": probeOut("zonefile=0", "journal=1"),
|
|
"duplicate zone decl": probeOut("zonefile=0", "zonefile=1", "journal=0"),
|
|
"header without file": probeOut("zonefile=0", "journal=0", "jnl=00"),
|
|
"no orphan block": strings.Join(
|
|
[]string{probeBegin, "zonefile=0", "journal=0", probeEnd, ""}, "\n"),
|
|
"orphan block unterminated": strings.Join(
|
|
[]string{probeBegin, "zonefile=0", "journal=0", probeOrphanOpen, probeEnd, ""}, "\n"),
|
|
"duplicate orphan block": strings.Join(
|
|
[]string{probeBegin, "zonefile=0", "journal=0", probeOrphanOpen, probeOrphanShut,
|
|
probeOrphanOpen, probeOrphanShut, probeEnd, ""}, "\n"),
|
|
}
|
|
for name, out := range cases {
|
|
// The zero state is a legitimate fresh install, so rejection has to
|
|
// happen here: ZoneDiskState turns it into an error and nothing plans.
|
|
if st, ok := parseZoneDiskState(out); ok {
|
|
t.Errorf("%s: degraded probe accepted as %+v", name, st)
|
|
}
|
|
}
|
|
}
|
|
|
|
// 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 h, known := highestSerial(st); !known || h != 1<<32-1 {
|
|
t.Fatalf("highestSerial = (%d,%v) want (%d,true): the wrap branch is not being reached", h, known, int64(1)<<32-1)
|
|
}
|
|
p := PlanSeed(st)
|
|
if p.Serial != 1 {
|
|
t.Fatalf("serial after wrap = %d want 1", p.Serial)
|
|
}
|
|
if !serialLT(st.JournalEnd, p.Serial) {
|
|
t.Errorf("wrapped serial %d must still sort after journal end %d", p.Serial, st.JournalEnd)
|
|
}
|
|
}
|
|
|
|
// Serials above 2^31 must not be flattened to 1: RFC 1982 comparison against a
|
|
// zero placeholder reads them as older, and secondaries reject the regression.
|
|
func TestPlanSeedHighSerialJournal(t *testing.T) {
|
|
st := ZoneDiskState{Journal: true, JournalBegin: 1<<31 - 10, JournalEnd: 1 << 31, JournalOK: true}
|
|
p := PlanSeed(st)
|
|
if !p.WriteSeed {
|
|
t.Fatalf("orphan journal should still seed, got %+v", p)
|
|
}
|
|
if p.Serial != 1<<31+1 {
|
|
t.Errorf("seed serial = %d want %d", p.Serial, int64(1)<<31+1)
|
|
}
|
|
if !serialLT(st.JournalEnd, p.Serial) {
|
|
t.Errorf("seed serial %d must sort after journal end %d", p.Serial, st.JournalEnd)
|
|
}
|
|
if p.QuarantineSuffix != ".orphaned-2147483648" {
|
|
t.Errorf("quarantine suffix = %q", p.QuarantineSuffix)
|
|
}
|
|
}
|
|
|
|
// An orphan journal whose header will not parse (no od, EACCES, short read)
|
|
// hides how far the zone had advanced, so quarantining it and reseeding at 1
|
|
// would regress live data.
|
|
func TestPlanSeedBlocksOnUnreadableOrphanJournal(t *testing.T) {
|
|
p := PlanSeed(ZoneDiskState{Journal: true})
|
|
if p.Blocked == "" {
|
|
t.Fatalf("an unreadable orphan journal must block, got %+v", p)
|
|
}
|
|
if p.WriteSeed || p.QuarantineJournal || p.QuarantineZoneFile {
|
|
t.Errorf("a blocked plan must touch nothing, got %+v", p)
|
|
}
|
|
}
|
|
|
|
func TestSeedZoneRoundTripsThroughParser(t *testing.T) {
|
|
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)
|
|
}
|
|
}
|
|
|
|
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)
|
|
}
|
|
}
|
|
|
|
// A repeat incident computes the same suffix, so the rename must not overwrite
|
|
// the copy preserved by the previous one.
|
|
func TestMoveAsidePreservesEarlierQuarantine(t *testing.T) {
|
|
sh, err := exec.LookPath("sh")
|
|
if err != nil {
|
|
t.Skipf("no POSIX shell: %v", err)
|
|
}
|
|
dir := t.TempDir()
|
|
path := filepath.Join(dir, "db.example.com")
|
|
|
|
for _, content := range []string{"first", "second"} {
|
|
if err := os.WriteFile(path, []byte(content), 0o600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
out, err := exec.Command(sh, "-c", moveAside(path, ".orphaned-16")).CombinedOutput()
|
|
if err != nil {
|
|
t.Fatalf("moveAside(%s): %v (%s)", content, err, out)
|
|
}
|
|
}
|
|
|
|
if _, err := os.Stat(path); err == nil {
|
|
t.Error("the quarantined file should have been renamed away")
|
|
}
|
|
entries, err := os.ReadDir(dir)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
found := map[string]bool{}
|
|
for _, e := range entries {
|
|
b, err := os.ReadFile(filepath.Join(dir, e.Name()))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
found[string(b)] = true
|
|
}
|
|
for _, want := range []string{"first", "second"} {
|
|
if !found[want] {
|
|
t.Errorf("quarantine destroyed %q: %v", want, found)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestParseJournalHeaderRejectsPaddingGarbage(t *testing.T) {
|
|
h := journalHeader(";BIND LOG V9\n", 10, 16)
|
|
h[15] = 'x'
|
|
if _, _, ok := parseJournalHeader(h); ok {
|
|
t.Error("format field must match all 16 bytes")
|
|
}
|
|
}
|
|
|
|
func TestShellQuoteEscapesQuotes(t *testing.T) {
|
|
sh, err := exec.LookPath("sh")
|
|
if err != nil {
|
|
t.Skipf("no POSIX shell: %v", err)
|
|
}
|
|
evil := `a'; touch pwned; echo '`
|
|
out, err := exec.Command(sh, "-c", "printf %s "+shellQuote(evil)).Output()
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if string(out) != evil {
|
|
t.Errorf("shellQuote round trip = %q want %q", out, evil)
|
|
}
|
|
}
|
|
|
|
// The probe is shell, so run it and check the parser agrees with what is
|
|
// actually on disk; a syntax slip or a missing field would otherwise only
|
|
// surface as a seed over live data.
|
|
func TestZoneStateProbeRoundTrip(t *testing.T) {
|
|
sh, err := exec.LookPath("sh")
|
|
if err != nil {
|
|
t.Skipf("no POSIX shell: %v", err)
|
|
}
|
|
for _, tool := range []string{"head", "od", "tr"} {
|
|
if _, err := exec.LookPath(tool); err != nil {
|
|
t.Skipf("probe needs %s: %v", tool, err)
|
|
}
|
|
}
|
|
|
|
cases := []struct {
|
|
name string
|
|
zone string
|
|
jnl []byte
|
|
orphans []string
|
|
want ZoneDiskState
|
|
}{
|
|
{name: "fresh install"},
|
|
{
|
|
name: "zone file only",
|
|
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", nil, 12),
|
|
jnl: journalHeader(";BIND LOG V9.2\n", 10, 16),
|
|
want: ZoneDiskState{
|
|
ZoneFile: true, ZoneSerial: 12, ZoneSerialOK: true,
|
|
Journal: true, JournalBegin: 10, JournalEnd: 16, JournalOK: true,
|
|
},
|
|
},
|
|
{
|
|
name: "orphan journal",
|
|
jnl: journalHeader(";BIND LOG V9.2\n", 10, 16),
|
|
want: ZoneDiskState{Journal: true, JournalBegin: 10, JournalEnd: 16, JournalOK: true},
|
|
},
|
|
{
|
|
name: "journal with unreadable header",
|
|
jnl: []byte("garbage"),
|
|
want: ZoneDiskState{Journal: true},
|
|
},
|
|
{
|
|
name: "quarantine evidence only",
|
|
orphans: []string{".orphaned-16", ".jnl.orphaned-16"},
|
|
want: ZoneDiskState{OrphanSerial: 16, OrphanSerialOK: true},
|
|
},
|
|
{
|
|
name: "repeat quarantine keeps the highest serial",
|
|
orphans: []string{".orphaned-16", ".orphaned-30", ".orphaned-30.1"},
|
|
want: ZoneDiskState{OrphanSerial: 30, OrphanSerialOK: true},
|
|
},
|
|
{
|
|
name: "live zone beside old quarantine evidence",
|
|
zone: renderSeedZone("example.com", "10.0.0.1", nil, 42),
|
|
orphans: []string{".orphaned-16"},
|
|
want: ZoneDiskState{
|
|
ZoneFile: true, ZoneSerial: 42, ZoneSerialOK: true,
|
|
OrphanSerial: 16, OrphanSerialOK: true,
|
|
},
|
|
},
|
|
}
|
|
for _, c := range cases {
|
|
path := filepath.Join(t.TempDir(), "db.example.com")
|
|
if c.zone != "" {
|
|
if err := os.WriteFile(path, []byte(c.zone), 0o600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
if c.jnl != nil {
|
|
if err := os.WriteFile(JournalPath(path), c.jnl, 0o600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
for _, suffix := range c.orphans {
|
|
if err := os.WriteFile(path+suffix, []byte("preserved"), 0o600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
out, err := exec.Command(sh, "-c", zoneStateProbe(path)).Output()
|
|
if err != nil {
|
|
t.Fatalf("%s: probe failed: %v", c.name, err)
|
|
}
|
|
got, ok := parseZoneDiskState(string(out))
|
|
if !ok {
|
|
t.Errorf("%s: probe output rejected: %q", c.name, out)
|
|
continue
|
|
}
|
|
if got != c.want {
|
|
t.Errorf("%s: state = %+v want %+v (out %q)", c.name, got, c.want, out)
|
|
}
|
|
}
|
|
}
|
|
|
|
// applyPlanInterrupted models the plan being cut off between the quarantine
|
|
// renames and the new zone file landing: the PVC holds no zone data at all, and
|
|
// the .orphaned-<serial> siblings are the only record of how far it had got.
|
|
func applyPlanInterrupted(st ZoneDiskState, p SeedPlan) ZoneDiskState {
|
|
h, known := highestSerial(st)
|
|
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 known && (p.QuarantineJournal || p.QuarantineZoneFile) {
|
|
st.OrphanSerial, st.OrphanSerialOK = h, true
|
|
}
|
|
return st
|
|
}
|
|
|
|
// A reconcile that quarantined and then failed to write leaves a directory that
|
|
// looks fresh. Seeding it at 1 loads cleanly but every secondary holding the
|
|
// old serial refuses the transfer, so the zone goes permanently stale.
|
|
func TestPlanSeedInterruptedTransitionDoesNotRegressSerial(t *testing.T) {
|
|
st := ZoneDiskState{
|
|
ZoneFile: true, ZoneSerial: 1, ZoneSerialOK: true,
|
|
Journal: true, JournalBegin: 10, JournalEnd: 16, JournalOK: true,
|
|
}
|
|
first := PlanSeed(st)
|
|
if !first.WriteSeed {
|
|
t.Fatalf("a file behind its journal should be reseeded, got %+v", first)
|
|
}
|
|
|
|
after := applyPlanInterrupted(st, first)
|
|
if after.ZoneFile || after.Journal {
|
|
t.Fatalf("the interrupted state should hold no zone data, got %+v", after)
|
|
}
|
|
|
|
retry := PlanSeed(after)
|
|
if !retry.WriteSeed {
|
|
t.Fatalf("a zone with nothing on disk must still be seeded, got %+v", retry)
|
|
}
|
|
if !serialLT(16, retry.Serial) {
|
|
t.Errorf("reseed at %d regressed below the quarantined serial 16", retry.Serial)
|
|
}
|
|
if retry.Serial != first.Serial {
|
|
t.Errorf("retry seeded at %d, the interrupted attempt planned %d", retry.Serial, first.Serial)
|
|
}
|
|
if retry.QuarantineZoneFile || retry.QuarantineJournal {
|
|
t.Errorf("there is nothing left to quarantine, got %+v", retry)
|
|
}
|
|
if next := PlanSeed(applyPlan(after, retry)); next != (SeedPlan{}) {
|
|
t.Errorf("third reconcile should be a no-op, got %+v", next)
|
|
}
|
|
}
|
|
|
|
// Quarantine evidence is a floor, never a trigger: it must not disturb a zone
|
|
// that is healthy now, and it must not unblock an unjudgeable journal.
|
|
func TestPlanSeedOrphanEvidenceDoesNotDisturbLiveData(t *testing.T) {
|
|
healthy := ZoneDiskState{
|
|
ZoneFile: true, ZoneSerial: 20, ZoneSerialOK: true,
|
|
Journal: true, JournalBegin: 10, JournalEnd: 20, JournalOK: true,
|
|
OrphanSerial: 99, OrphanSerialOK: true,
|
|
}
|
|
if p := PlanSeed(healthy); p != (SeedPlan{}) {
|
|
t.Errorf("a healthy zone must not be touched, got %+v", p)
|
|
}
|
|
blocked := ZoneDiskState{Journal: true, OrphanSerial: 99, OrphanSerialOK: true}
|
|
if p := PlanSeed(blocked); p.Blocked == "" {
|
|
t.Errorf("an unreadable orphan journal must still block, got %+v", p)
|
|
}
|
|
}
|
|
|
|
func TestParseOrphanSerial(t *testing.T) {
|
|
base := ZoneFilePath("example.com")
|
|
cases := []struct {
|
|
name string
|
|
want int64
|
|
ok bool
|
|
}{
|
|
{base + ".orphaned-16", 16, true},
|
|
{JournalPath(base) + ".orphaned-16", 16, true},
|
|
{base + ".orphaned-16.3", 16, true},
|
|
{base + ".orphaned-4294967295", 4294967295, true},
|
|
{base + ".orphaned-", 0, false},
|
|
{base + ".orphaned-abc", 0, false},
|
|
{base + ".orphaned-4294967296", 0, false},
|
|
{base, 0, false},
|
|
{base + ".jnl", 0, false},
|
|
}
|
|
for _, c := range cases {
|
|
got, ok := parseOrphanSerial(c.name)
|
|
if got != c.want || ok != c.ok {
|
|
t.Errorf("parseOrphanSerial(%q) = (%d,%v) want (%d,%v)", c.name, got, ok, c.want, c.ok)
|
|
}
|
|
}
|
|
if _, ok := orphanSerial(nil); ok {
|
|
t.Error("no siblings means no recorded serial, not serial 0")
|
|
}
|
|
// Serial 0 is legitimate and must not read as "nothing found".
|
|
if h, ok := orphanSerial([]string{base + ".orphaned-0"}); !ok || h != 0 {
|
|
t.Errorf("orphanSerial = (%d,%v) want (0,true)", h, ok)
|
|
}
|
|
}
|