280 lines
9.3 KiB
Go
280 lines
9.3 KiB
Go
package bind
|
|
|
|
import (
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
)
|
|
|
|
func requireShell(t *testing.T, tools ...string) string {
|
|
t.Helper()
|
|
sh, err := exec.LookPath("sh")
|
|
if err != nil {
|
|
t.Skipf("no POSIX shell: %v", err)
|
|
}
|
|
for _, tool := range tools {
|
|
if _, err := exec.LookPath(tool); err != nil {
|
|
t.Skipf("seed script needs %s: %v", tool, err)
|
|
}
|
|
}
|
|
return sh
|
|
}
|
|
|
|
// inFlightZone lays out the state the operator actually hit in production: a
|
|
// zone file a previous reconcile clobbered back to serial 1, with the journal
|
|
// that carries the live records up to 16.
|
|
func inFlightZone(t *testing.T) (dir, path string) {
|
|
t.Helper()
|
|
dir = t.TempDir()
|
|
path = filepath.Join(dir, "db.example.com")
|
|
if err := os.WriteFile(path, []byte(renderSeedZone("example.com", "10.0.0.1", 1)), 0o600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := os.WriteFile(JournalPath(path), journalHeader(";BIND LOG V9.2\n", 10, 16), 0o600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return dir, path
|
|
}
|
|
|
|
func runSeedScript(t *testing.T, sh, path string, plan SeedPlan, content, stdin string) error {
|
|
t.Helper()
|
|
return runSeedScriptWithPath(t, sh, path, plan, content, stdin, "")
|
|
}
|
|
|
|
func runSeedScriptWithPath(t *testing.T, sh, path string, plan SeedPlan, content, stdin, pathEnv string) error {
|
|
t.Helper()
|
|
cmd := exec.Command(sh, "-c", seedScript(path, plan, len(content)))
|
|
cmd.Stdin = strings.NewReader(stdin)
|
|
if pathEnv != "" {
|
|
cmd.Env = append(os.Environ(), "PATH="+pathEnv)
|
|
}
|
|
return cmd.Run()
|
|
}
|
|
|
|
// shimPath puts a stand-in for tool at the front of a PATH, so the generated
|
|
// script meets a failing command where a real image would meet a working one.
|
|
func shimPath(t *testing.T, tool, body string) string {
|
|
t.Helper()
|
|
dir := t.TempDir()
|
|
if err := os.WriteFile(filepath.Join(dir, tool), []byte("#!/bin/sh\n"+body+"\n"), 0o755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return dir + string(os.PathListSeparator) + os.Getenv("PATH")
|
|
}
|
|
|
|
func realTool(t *testing.T, tool string) string {
|
|
t.Helper()
|
|
p, err := exec.LookPath(tool)
|
|
if err != nil {
|
|
t.Skipf("need %s: %v", tool, err)
|
|
}
|
|
return p
|
|
}
|
|
|
|
// assertZoneUntouched checks the in-flight layout survived a failed run whole:
|
|
// live serial 1 still at path, journal still there, nothing quarantined and no
|
|
// staging file left behind.
|
|
func assertZoneUntouched(t *testing.T, dir, path string) {
|
|
t.Helper()
|
|
found := siblings(t, dir)
|
|
serial, ok := ParseZoneSerial(found[filepath.Base(path)])
|
|
if !ok || serial != 1 {
|
|
t.Errorf("zone file was replaced by a failed run: serial = (%d,%v)", serial, ok)
|
|
}
|
|
if _, ok := found[filepath.Base(JournalPath(path))]; !ok {
|
|
t.Error("the journal was lost by a failed run")
|
|
}
|
|
for name := range found {
|
|
if strings.Contains(name, quarantineMarker) {
|
|
t.Errorf("a failed run must not leave a quarantined file: %s", name)
|
|
}
|
|
}
|
|
}
|
|
|
|
func probeState(t *testing.T, sh, path string) ZoneDiskState {
|
|
t.Helper()
|
|
out, err := exec.Command(sh, "-c", zoneStateProbe(path)).Output()
|
|
if err != nil {
|
|
t.Fatalf("probe failed: %v", err)
|
|
}
|
|
st, ok := parseZoneDiskState(string(out))
|
|
if !ok {
|
|
t.Fatalf("probe output rejected: %q", out)
|
|
}
|
|
return st
|
|
}
|
|
|
|
func siblings(t *testing.T, dir string) map[string]string {
|
|
t.Helper()
|
|
entries, err := os.ReadDir(dir)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
found := map[string]string{}
|
|
for _, e := range entries {
|
|
b, err := os.ReadFile(filepath.Join(dir, e.Name()))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
found[e.Name()] = string(b)
|
|
}
|
|
return found
|
|
}
|
|
|
|
// A stdin stream cut mid-transfer gives cat a short file and exits 0. The seed
|
|
// must refuse to install it, and must not have quarantined anything on the way:
|
|
// a run that stopped here has to leave the zone exactly as it found it.
|
|
func TestSeedScriptInterruptedWriteLeavesDiskUntouched(t *testing.T) {
|
|
sh := requireShell(t, "wc", "tr", "mv", "rm", "mkdir", "dirname")
|
|
dir, path := inFlightZone(t)
|
|
|
|
plan := PlanSeed(probeState(t, sh, path))
|
|
if !plan.WriteSeed || !plan.QuarantineZoneFile || !plan.QuarantineJournal {
|
|
t.Fatalf("expected a reseed over both files, got %+v", plan)
|
|
}
|
|
content := renderSeedZone("example.com", "10.0.0.1", plan.Serial)
|
|
|
|
if err := runSeedScript(t, sh, path, plan, content, content[:len(content)/2]); err == nil {
|
|
t.Fatal("a truncated seed write must fail rather than install a torn zone file")
|
|
}
|
|
|
|
serial, ok := ParseZoneSerial(siblings(t, dir)[filepath.Base(path)])
|
|
if !ok || serial != 1 {
|
|
t.Errorf("the zone file was damaged by the interrupted seed: serial = (%d,%v)", serial, ok)
|
|
}
|
|
for name := range siblings(t, dir) {
|
|
if strings.Contains(name, quarantineMarker) {
|
|
t.Errorf("nothing should have been quarantined before the write landed: %s", name)
|
|
}
|
|
if strings.HasSuffix(name, seedTempSuffix) {
|
|
t.Errorf("the staging file should have been cleaned up: %s", name)
|
|
}
|
|
}
|
|
|
|
// The retry must still see the journal and reseed above it, not at 1.
|
|
retry := PlanSeed(probeState(t, sh, path))
|
|
if retry != plan {
|
|
t.Errorf("retry planned %+v, want the original %+v", retry, plan)
|
|
}
|
|
}
|
|
|
|
func TestSeedScriptInstallsOverQuarantinedFiles(t *testing.T) {
|
|
sh := requireShell(t, "wc", "tr", "mv", "rm", "mkdir", "dirname")
|
|
dir, path := inFlightZone(t)
|
|
|
|
plan := PlanSeed(probeState(t, sh, path))
|
|
content := renderSeedZone("example.com", "10.0.0.1", plan.Serial)
|
|
if err := runSeedScript(t, sh, path, plan, content, content); err != nil {
|
|
t.Fatalf("seed script: %v", err)
|
|
}
|
|
|
|
st := probeState(t, sh, path)
|
|
if !st.ZoneFile || st.ZoneSerial != plan.Serial {
|
|
t.Errorf("installed state = %+v want serial %d", st, plan.Serial)
|
|
}
|
|
if st.Journal {
|
|
t.Error("the unreplayable journal should have been moved aside")
|
|
}
|
|
found := siblings(t, dir)
|
|
for _, want := range []string{"db.example.com.orphaned-16", "db.example.com.jnl.orphaned-16"} {
|
|
if _, ok := found[want]; !ok {
|
|
t.Errorf("missing preserved file %s: %v", want, keys(found))
|
|
}
|
|
}
|
|
if _, ok := found[filepath.Base(path)+seedTempSuffix]; ok {
|
|
t.Error("the staging file should have been renamed into place")
|
|
}
|
|
if next := PlanSeed(st); next != (SeedPlan{}) {
|
|
t.Errorf("second reconcile should be a no-op, got %+v", next)
|
|
}
|
|
}
|
|
|
|
// The seed has to work on a PVC that has never held this zone, directories
|
|
// included.
|
|
func TestSeedScriptFreshInstall(t *testing.T) {
|
|
sh := requireShell(t, "wc", "tr", "mv", "rm", "mkdir", "dirname")
|
|
path := filepath.Join(t.TempDir(), "zones", "db.example.com")
|
|
|
|
plan := PlanSeed(ZoneDiskState{})
|
|
content := renderSeedZone("example.com", "10.0.0.1", plan.Serial)
|
|
if err := runSeedScript(t, sh, path, plan, content, content); err != nil {
|
|
t.Fatalf("seed script: %v", err)
|
|
}
|
|
if st := probeState(t, sh, path); !st.ZoneFile || st.ZoneSerial != 1 {
|
|
t.Errorf("fresh install state = %+v want serial 1", st)
|
|
}
|
|
}
|
|
|
|
func keys(m map[string]string) []string {
|
|
out := make([]string, 0, len(m))
|
|
for k := range m {
|
|
out = append(out, k)
|
|
}
|
|
return out
|
|
}
|
|
|
|
// A rename that fails leaves live data where it is, so the install must not
|
|
// happen: the skeleton beside a higher-serial journal is the production failure
|
|
// this seed exists to avoid.
|
|
func TestSeedScriptFailedQuarantineAbortsInstall(t *testing.T) {
|
|
sh := requireShell(t, "wc", "tr", "mv", "rm", "mkdir", "dirname")
|
|
dir, path := inFlightZone(t)
|
|
pathEnv := shimPath(t, "mv", `case "$*" in *`+quarantineMarker+`*) exit 1;; esac
|
|
exec `+realTool(t, "mv")+` "$@"`)
|
|
|
|
plan := PlanSeed(probeState(t, sh, path))
|
|
content := renderSeedZone("example.com", "10.0.0.1", plan.Serial)
|
|
if err := runSeedScriptWithPath(t, sh, path, plan, content, content, pathEnv); err == nil {
|
|
t.Fatal("a failed quarantine must fail the seed")
|
|
}
|
|
assertZoneUntouched(t, dir, path)
|
|
}
|
|
|
|
// The size guard has to fail closed: an image without wc cannot measure the
|
|
// staged file, and an unverified file must never be installed.
|
|
func TestSeedScriptUnmeasurableStagingAbortsInstall(t *testing.T) {
|
|
sh := requireShell(t, "wc", "tr", "mv", "rm", "mkdir", "dirname")
|
|
dir, path := inFlightZone(t)
|
|
pathEnv := shimPath(t, "wc", "exit 127")
|
|
|
|
plan := PlanSeed(probeState(t, sh, path))
|
|
content := renderSeedZone("example.com", "10.0.0.1", plan.Serial)
|
|
if err := runSeedScriptWithPath(t, sh, path, plan, content, content, pathEnv); err == nil {
|
|
t.Fatal("an unmeasurable staging file must fail the seed")
|
|
}
|
|
assertZoneUntouched(t, dir, path)
|
|
if _, ok := siblings(t, dir)[filepath.Base(path)+seedTempSuffix]; ok {
|
|
t.Error("the staging file should have been cleaned up")
|
|
}
|
|
}
|
|
|
|
func TestSeedScriptFailedMkdirAbortsInstall(t *testing.T) {
|
|
sh := requireShell(t, "wc", "tr", "mv", "rm", "mkdir", "dirname")
|
|
dir, path := inFlightZone(t)
|
|
pathEnv := shimPath(t, "mkdir", "exit 1")
|
|
|
|
plan := PlanSeed(probeState(t, sh, path))
|
|
content := renderSeedZone("example.com", "10.0.0.1", plan.Serial)
|
|
if err := runSeedScriptWithPath(t, sh, path, plan, content, content, pathEnv); err == nil {
|
|
t.Fatal("a failed mkdir must fail the seed")
|
|
}
|
|
assertZoneUntouched(t, dir, path)
|
|
}
|
|
|
|
// The probe decides whether there is anything to preserve, so a tool it cannot
|
|
// run must be an error rather than a state that reads as "nothing readable".
|
|
func TestZoneStateProbeFailedToolIsAnError(t *testing.T) {
|
|
sh := requireShell(t, "head", "od", "tr")
|
|
_, path := inFlightZone(t)
|
|
pathEnv := shimPath(t, "tr", "exit 127")
|
|
|
|
cmd := exec.Command(sh, "-c", zoneStateProbe(path))
|
|
cmd.Env = append(os.Environ(), "PATH="+pathEnv)
|
|
out, err := cmd.Output()
|
|
if err == nil {
|
|
t.Fatalf("probe reported success without reading the journal header: %q", out)
|
|
}
|
|
}
|