Files
bind-operator/internal/bind/zonestate.go
T
unkin-agent 949662a334
ci/woodpecker/pr/pre-commit Pipeline was successful
ci/woodpecker/pr/test Pipeline was successful
ci/woodpecker/pr/build Pipeline was successful
Read quarantine evidence back when planning a seed
2026-09-19 23:14:26 +10:00

389 lines
12 KiB
Go

package bind
import (
"context"
"encoding/hex"
"fmt"
"strconv"
"strings"
)
// JournalPath returns the BIND journal that accompanies a zone database file.
func JournalPath(zonePath string) string { return zonePath + ".jnl" }
// ZoneDiskState is what the primary pod's filesystem holds for one zone.
// A journal is only replayable onto a zone file whose SOA serial lies within
// [JournalBegin, JournalEnd]; outside that range BIND fails the load with
// "out of range" and the zone never comes up.
type ZoneDiskState struct {
ZoneFile bool
ZoneSerial int64
ZoneSerialOK bool
Journal bool
JournalBegin int64
JournalEnd int64
JournalOK bool
// OrphanSerial is the furthest serial recorded in a quarantine sibling on
// disk. It is the only record of how far the zone had advanced once a
// transition is interrupted between the renames and the new file landing.
OrphanSerial int64
OrphanSerialOK bool
}
// SeedPlan is the decision taken before writing a skeleton zone file.
type SeedPlan struct {
WriteSeed bool
Serial int64
QuarantineZoneFile bool
QuarantineJournal bool
QuarantineSuffix string
// Blocked names the reason the disk state could not be judged safely. The
// caller must touch nothing and surface it.
Blocked string
}
// PlanSeed decides how to make a zone loadable without discarding live data.
// Nothing is ever deleted: unusable files are renamed aside so they stay
// recoverable on the PVC.
func PlanSeed(st ZoneDiskState) SeedPlan {
suffix := quarantineSuffix(st)
if !st.ZoneFile {
// The journal's serial range is the only evidence of how far the zone
// had advanced; without it a seed could regress below live data.
if st.Journal && !st.JournalOK {
return SeedPlan{Blocked: "journal present but its header could not be read"}
}
return SeedPlan{
WriteSeed: true,
Serial: nextSerial(st),
QuarantineJournal: st.Journal,
QuarantineSuffix: suffix,
}
}
if !st.Journal || !st.JournalOK || !st.ZoneSerialOK {
return SeedPlan{}
}
switch {
case serialLT(st.ZoneSerial, st.JournalBegin):
// The file has regressed behind the journal: it is the skeleton a
// previous reconcile clobbered it with. Keep both aside and reseed
// above the journal so secondaries still see a serial increase.
return SeedPlan{
WriteSeed: true,
Serial: nextSerial(st),
QuarantineZoneFile: true,
QuarantineJournal: true,
QuarantineSuffix: suffix,
}
case serialLT(st.JournalEnd, st.ZoneSerial):
return SeedPlan{QuarantineJournal: true, QuarantineSuffix: suffix}
default:
return SeedPlan{}
}
}
// highestSerial reports the furthest serial on disk. The second result is false
// when no serial could be read at all: 0 is a legitimate serial, so it cannot
// double as "nothing found" in RFC 1982 sequence space.
func highestSerial(st ZoneDiskState) (int64, bool) {
var h int64
var known bool
if st.ZoneFile && st.ZoneSerialOK {
h, known = st.ZoneSerial, true
}
if st.Journal && st.JournalOK && (!known || serialLT(h, st.JournalEnd)) {
h, known = st.JournalEnd, true
}
if st.OrphanSerialOK && (!known || serialLT(h, st.OrphanSerial)) {
h, known = st.OrphanSerial, true
}
return h, known
}
func quarantineSuffix(st ZoneDiskState) string {
h, _ := highestSerial(st)
return quarantineMarker + strconv.FormatInt(h, 10)
}
// parseOrphanSerial reads the serial back out of a name moveAside produced,
// with or without the counter it appends when the destination is taken.
func parseOrphanSerial(name string) (int64, bool) {
i := strings.LastIndex(name, quarantineMarker)
if i < 0 {
return 0, false
}
digits := name[i+len(quarantineMarker):]
n := 0
for n < len(digits) && digits[n] >= '0' && digits[n] <= '9' {
n++
}
if n == 0 {
return 0, false
}
serial, err := strconv.ParseUint(digits[:n], 10, 32)
if err != nil {
return 0, false
}
return int64(serial), true
}
func orphanSerial(names []string) (int64, bool) {
var h int64
var known bool
for _, name := range names {
s, ok := parseOrphanSerial(name)
if !ok {
continue
}
if !known || serialLT(h, s) {
h, known = s, true
}
}
return h, known
}
func nextSerial(st ZoneDiskState) int64 {
h, known := highestSerial(st)
if !known {
return 1
}
next := int64(uint32(h) + 1)
if next == 0 {
return 1
}
return next
}
// serialLT compares DNS serials in RFC 1982 sequence space.
func serialLT(a, b int64) bool {
if a == b {
return false
}
return uint32(b)-uint32(a) < 1<<31
}
// ParseZoneSerial extracts the SOA serial from the head of a zone file, in
// both the operator's seed layout and BIND's own multi-line dump layout.
func ParseZoneSerial(content string) (int64, bool) {
var tokens []string
for _, line := range strings.Split(content, "\n") {
if i := strings.IndexByte(line, ';'); i >= 0 {
line = line[:i]
}
line = strings.ReplaceAll(line, "(", " ( ")
line = strings.ReplaceAll(line, ")", " ) ")
tokens = append(tokens, strings.Fields(line)...)
}
for i, tok := range tokens {
if !strings.EqualFold(tok, "SOA") {
continue
}
rest := tokens[i+1:]
if len(rest) < 3 {
return 0, false
}
rest = rest[2:] // MNAME, RNAME
if rest[0] == "(" {
rest = rest[1:]
}
if len(rest) == 0 {
return 0, false
}
serial, err := strconv.ParseUint(rest[0], 10, 32)
if err != nil {
return 0, false
}
return int64(serial), true
}
return 0, false
}
// journalFormats are the zero-padded 16-byte format fields BIND writes and
// compares whole (lib/dns/journal.c).
var journalFormats = [][16]byte{
journalFormat(";BIND LOG V9\n"),
journalFormat(";BIND LOG V9.2\n"),
}
func journalFormat(magic string) [16]byte {
var f [16]byte
copy(f[:], magic)
return f
}
// parseJournalHeader reads the begin and end serials from a BIND journal
// header: a 16-byte format magic followed by two {serial,offset} big-endian
// pairs.
func parseJournalHeader(b []byte) (begin, end int64, ok bool) {
if len(b) < 28 {
return 0, 0, false
}
var format [16]byte
copy(format[:], b[:16])
known := false
for _, f := range journalFormats {
if format == f {
known = true
break
}
}
if !known {
return 0, 0, false
}
return int64(beUint32(b[16:20])), int64(beUint32(b[24:28])), true
}
func beUint32(b []byte) uint32 {
return uint32(b[0])<<24 | uint32(b[1])<<16 | uint32(b[2])<<8 | uint32(b[3])
}
// Probe framing. Every field is declared exactly once between the sentinels, so
// stdout that was truncated, empty or partially written is rejected rather than
// read as "fresh install".
const (
probeBegin = "zonestate-begin-v1"
probeEnd = "zonestate-end-v1"
probeHeadOpen = "head<<"
probeHeadShut = ">>head"
probeOrphanOpen = "orphans<<"
probeOrphanShut = ">>orphans"
// quarantineMarker joins a preserved file to the serial it was holding.
quarantineMarker = ".orphaned-"
)
// zoneStateProbe reads only the head of the zone file: the SOA is the first
// record in both layouts the operator has to read.
func zoneStateProbe(path string) string {
zone, jnl := shellQuote(path), shellQuote(JournalPath(path))
return strings.Join([]string{
fmt.Sprintf("printf '%s\\n'", probeBegin),
fmt.Sprintf("if [ -f %s ]; then printf 'zonefile=1\\n%s\\n'; head -c 4096 %s || exit 1; printf '\\n%s\\n'; else printf 'zonefile=0\\n'; fi",
zone, probeHeadOpen, zone, probeHeadShut),
fmt.Sprintf("if [ -f %s ]; then printf 'journal=1\\n'; hdr=$(od -An -v -tx1 -N32 %s) || exit 1; printf 'jnl=%%s\\n' \"$(printf '%%s' \"$hdr\" | tr -d ' \\n')\"; else printf 'journal=0\\n'; fi",
jnl, jnl),
// The quarantine siblings outlive the files they replaced, so they are
// the floor a reseed must stay above after an interrupted transition.
fmt.Sprintf("printf '%s\\n'\nfor f in %s* %s*; do if [ -e \"$f\" ]; then printf '%%s\\n' \"$f\"; fi; done\nprintf '%s\\n'",
probeOrphanOpen, shellQuote(path+quarantineMarker), shellQuote(JournalPath(path)+quarantineMarker), probeOrphanShut),
fmt.Sprintf("printf '%s\\n'", probeEnd),
}, "\n")
}
// parseZoneDiskState returns false unless the probe output is complete: a
// half-written or empty probe must never be mistaken for an empty filesystem.
func parseZoneDiskState(out string) (ZoneDiskState, bool) {
var st ZoneDiskState
var head, orphans []string
var sawBegin, sawEnd, inHead, headShut, inOrphans, orphansShut bool
var zoneDecls, journalDecls, headerDecls, orphanDecls int
for _, line := range strings.Split(out, "\n") {
switch {
case inHead && line == probeHeadShut:
inHead, headShut = false, true
case inHead:
head = append(head, line)
case inOrphans && line == probeOrphanShut:
inOrphans, orphansShut = false, true
case inOrphans:
if line != "" {
orphans = append(orphans, line)
}
case line == probeBegin:
sawBegin = true
case line == probeEnd:
sawEnd = true
case line == probeHeadOpen:
inHead = true
case line == probeOrphanOpen:
inOrphans, orphanDecls = true, orphanDecls+1
case line == "zonefile=1":
st.ZoneFile = true
zoneDecls++
case line == "zonefile=0":
zoneDecls++
case line == "journal=1":
st.Journal = true
journalDecls++
case line == "journal=0":
journalDecls++
case strings.HasPrefix(line, "jnl="):
headerDecls++
if raw, err := hex.DecodeString(strings.TrimPrefix(line, "jnl=")); err == nil {
st.JournalBegin, st.JournalEnd, st.JournalOK = parseJournalHeader(raw)
}
}
}
switch {
case !sawBegin || !sawEnd || inHead || inOrphans:
return ZoneDiskState{}, false
case orphanDecls != 1 || !orphansShut:
return ZoneDiskState{}, false
case zoneDecls != 1 || journalDecls != 1:
return ZoneDiskState{}, false
case st.ZoneFile && !headShut:
return ZoneDiskState{}, false
case st.Journal && headerDecls != 1:
return ZoneDiskState{}, false
case !st.Journal && headerDecls != 0:
return ZoneDiskState{}, false
}
if st.ZoneFile {
st.ZoneSerial, st.ZoneSerialOK = ParseZoneSerial(strings.Join(head, "\n"))
}
st.OrphanSerial, st.OrphanSerialOK = orphanSerial(orphans)
return st, true
}
// ZoneDiskState inspects the zone database file and journal on the pod.
func (e *Executor) ZoneDiskState(ctx context.Context, namespace, pod, path string) (ZoneDiskState, error) {
out, err := e.Exec(ctx, namespace, pod, []string{"sh", "-c", zoneStateProbe(path)}, "")
if err != nil {
return ZoneDiskState{}, fmt.Errorf("inspect zone files %s: %w (out: %s)", path, err, out)
}
st, ok := parseZoneDiskState(out)
if !ok {
return ZoneDiskState{}, fmt.Errorf("inspect zone files %s: incomplete probe output %q", path, out)
}
return st, nil
}
// Quarantine renames the files the plan marks unusable, leaving them on the
// PVC under a suffixed name.
func (e *Executor) Quarantine(ctx context.Context, namespace, pod, path string, plan SeedPlan) error {
var cmds []string
if plan.QuarantineZoneFile {
cmds = append(cmds, moveAside(path, plan.QuarantineSuffix))
}
if plan.QuarantineJournal {
cmds = append(cmds, moveAside(JournalPath(path), plan.QuarantineSuffix))
}
if len(cmds) == 0 {
return nil
}
cmd := []string{"sh", "-c", strings.Join(cmds, "\n")}
if out, err := e.Exec(ctx, namespace, pod, cmd, ""); err != nil {
return fmt.Errorf("quarantine zone files %s: %w (out: %s)", path, err, out)
}
return nil
}
// moveAside renames path out of the way. A repeat incident can compute the same
// suffix, so the destination is numbered until it is free: a preserved copy is
// never overwritten.
func moveAside(path, suffix string) string {
src, dest := shellQuote(path), shellQuote(path+suffix)
return fmt.Sprintf("if [ -f %s ]; then d=%s; n=0; while [ -e \"$d\" ]; do n=$((n+1)); d=%s.$n; done; mv -- %s \"$d\"; fi",
src, dest, dest, src)
}
// shellQuote renders s as a single POSIX shell word.
func shellQuote(s string) string {
return "'" + strings.ReplaceAll(s, "'", `'\''`) + "'"
}