Read quarantine evidence back when planning a seed
This commit is contained in:
@@ -12,6 +12,7 @@ import (
|
||||
// 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")
|
||||
}
|
||||
|
||||
@@ -119,6 +120,13 @@ func TestParseZoneDiskStateRejectsDegradedProbe(t *testing.T) {
|
||||
"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
|
||||
@@ -401,10 +409,11 @@ func TestZoneStateProbeRoundTrip(t *testing.T) {
|
||||
}
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
zone string
|
||||
jnl []byte
|
||||
want ZoneDiskState
|
||||
name string
|
||||
zone string
|
||||
jnl []byte
|
||||
orphans []string
|
||||
want ZoneDiskState
|
||||
}{
|
||||
{name: "fresh install"},
|
||||
{
|
||||
@@ -431,6 +440,25 @@ func TestZoneStateProbeRoundTrip(t *testing.T) {
|
||||
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", 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")
|
||||
@@ -444,6 +472,11 @@ func TestZoneStateProbeRoundTrip(t *testing.T) {
|
||||
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)
|
||||
@@ -458,3 +491,105 @@ func TestZoneStateProbeRoundTrip(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user