Files
bind-operator/internal/bind/seed.go
T
unkin-agent 6730b8bcb2 Stage and install the seed zone file in one exec
Quarantine and write were separate round-trips, so a failure between them
left the PVC with no zone data and the next reconcile reseeded at serial 1.
The write also truncated the destination in place, leaving a torn file that
PlanSeed refuses to touch.
2026-09-19 23:14:26 +10:00

125 lines
4.8 KiB
Go

package bind
import (
"context"
"fmt"
"strings"
)
// ZoneFilePath returns the on-pod path of a zone database file.
func ZoneFilePath(zone string) string {
return fmt.Sprintf("%s/zones/db.%s", DataDir, strings.TrimSuffix(zone, "."))
}
// CatalogFilePath returns the on-pod path of a catalog zone database file.
func CatalogFilePath(zone string) string {
return fmt.Sprintf("%s/catalog/db.%s", DataDir, strings.TrimSuffix(zone, "."))
}
// ZoneExists reports whether a zone is currently loaded on the pod.
func (e *Executor) ZoneExists(ctx context.Context, namespace, pod, zone, view string) bool {
args := []string{"zonestatus", zone}
if view != "" {
args = append(args, "in", view)
}
_, err := e.Rndc(ctx, namespace, pod, args...)
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 {
origin := dot(zone)
ns := "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
@ 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)
}
// 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 {
state, err := e.ZoneDiskState(ctx, namespace, pod, path)
if err != nil {
return err
}
plan := PlanSeed(state)
if plan.Blocked != "" {
return fmt.Errorf("seed zone %s: %s", zone, plan.Blocked)
}
if !plan.WriteSeed {
return e.Quarantine(ctx, namespace, pod, path, plan)
}
content := renderSeedZone(zone, primaryIP, 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)
}
return nil
}
// seedTempSuffix names the staging file, a sibling of the zone file so the
// install is a same-filesystem rename.
const seedTempSuffix = ".seed-tmp"
// seedScript stages the skeleton, checks it arrived whole, then quarantines and
// installs it in that order. Doing all of it in one exec keeps an interrupted
// seed from leaving the PVC with no zone data, which the next reconcile would
// read as a fresh install and reseed at serial 1; the rename means a torn write
// is never visible at path.
func seedScript(path string, plan SeedPlan, size int) string {
q, tmp := shellQuote(path), shellQuote(path+seedTempSuffix)
cmds := []string{
fmt.Sprintf("mkdir -p \"$(dirname %s)\"", q),
fmt.Sprintf("cat > %s", tmp),
// A stdin stream cut mid-transfer gives cat a short file and exit 0.
fmt.Sprintf("if [ \"$(wc -c < %s | tr -d ' \\n')\" -ne %d ]; then rm -f %s; exit 1; fi", tmp, size, tmp),
}
if plan.QuarantineZoneFile {
cmds = append(cmds, moveAside(path, plan.QuarantineSuffix))
}
if plan.QuarantineJournal {
cmds = append(cmds, moveAside(JournalPath(path), plan.QuarantineSuffix))
}
return strings.Join(append(cmds, fmt.Sprintf("mv -- %s %s", tmp, q)), "\n")
}
// AddCatalogMember registers a member zone in a catalog zone by adding the
// catalog PTR record, so secondaries auto-provision it.
func (e *Executor) AddCatalogMember(ctx context.Context, namespace, pod, catalogZone, memberZone string, creds TSIGCreds) error {
hash := catalogHash(memberZone)
owner := fmt.Sprintf("%s.zones.%s", hash, dot(catalogZone))
updates := []RecordUpdate{{
FQDN: owner,
Type: "PTR",
TTL: 3600,
Values: []string{dot(memberZone)},
}}
return e.NSUpdate(ctx, namespace, pod, catalogZone, creds, updates)
}
// RemoveCatalogMember deregisters a member zone from a catalog zone.
func (e *Executor) RemoveCatalogMember(ctx context.Context, namespace, pod, catalogZone, memberZone string, creds TSIGCreds) error {
hash := catalogHash(memberZone)
owner := fmt.Sprintf("%s.zones.%s", hash, dot(catalogZone))
updates := []RecordUpdate{{FQDN: owner, Type: "PTR", Delete: true}}
return e.NSUpdate(ctx, namespace, pod, catalogZone, creds, updates)
}