Abort the seed and probe scripts on the first failed command
Guard the staged size as a string so an unmeasurable file fails closed.
This commit is contained in:
@@ -59,7 +59,8 @@ type BindClusterSpec struct {
|
|||||||
Replicas int32 `json:"replicas,omitempty"`
|
Replicas int32 `json:"replicas,omitempty"`
|
||||||
|
|
||||||
// Image is the BIND9 container image. Must ship named, rndc, nsupdate and
|
// Image is the BIND9 container image. Must ship named, rndc, nsupdate and
|
||||||
// the POSIX tools the operator execs: sh, mkdir, dirname, head, od, tr, mv.
|
// the POSIX tools the operator execs: sh, mkdir, dirname, cat, head, od,
|
||||||
|
// tr, wc, rm, mv.
|
||||||
// +kubebuilder:default="internetsystemsconsortium/bind9:9.20"
|
// +kubebuilder:default="internetsystemsconsortium/bind9:9.20"
|
||||||
// +optional
|
// +optional
|
||||||
Image string `json:"image,omitempty"`
|
Image string `json:"image,omitempty"`
|
||||||
|
|||||||
@@ -997,7 +997,8 @@ spec:
|
|||||||
default: internetsystemsconsortium/bind9:9.20
|
default: internetsystemsconsortium/bind9:9.20
|
||||||
description: |-
|
description: |-
|
||||||
Image is the BIND9 container image. Must ship named, rndc, nsupdate and
|
Image is the BIND9 container image. Must ship named, rndc, nsupdate and
|
||||||
the POSIX tools the operator execs: sh, mkdir, dirname, head, od, tr, mv.
|
the POSIX tools the operator execs: sh, mkdir, dirname, cat, head, od,
|
||||||
|
tr, wc, rm, mv.
|
||||||
type: string
|
type: string
|
||||||
imagePullPolicy:
|
imagePullPolicy:
|
||||||
description: ImagePullPolicy for the BIND container.
|
description: ImagePullPolicy for the BIND container.
|
||||||
|
|||||||
@@ -1307,7 +1307,8 @@ spec:
|
|||||||
default: internetsystemsconsortium/bind9:9.20
|
default: internetsystemsconsortium/bind9:9.20
|
||||||
description: |-
|
description: |-
|
||||||
Image is the BIND9 container image. Must ship named, rndc, nsupdate and
|
Image is the BIND9 container image. Must ship named, rndc, nsupdate and
|
||||||
the POSIX tools the operator execs: sh, mkdir, dirname, head, od, tr, mv.
|
the POSIX tools the operator execs: sh, mkdir, dirname, cat, head, od,
|
||||||
|
tr, wc, rm, mv.
|
||||||
type: string
|
type: string
|
||||||
imagePullPolicy:
|
imagePullPolicy:
|
||||||
description: ImagePullPolicy for the BIND container.
|
description: ImagePullPolicy for the BIND container.
|
||||||
|
|||||||
@@ -83,14 +83,18 @@ const seedTempSuffix = ".seed-tmp"
|
|||||||
// installs it in that order. Doing all of it in one exec keeps an interrupted
|
// 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
|
// 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
|
// read as a fresh install and reseed at serial 1; the rename means a torn write
|
||||||
// is never visible at path.
|
// is never visible at path. shellScript aborts the run at the first failure, so
|
||||||
|
// no step can install the skeleton over data an earlier step failed to preserve.
|
||||||
func seedScript(path string, plan SeedPlan, size int) string {
|
func seedScript(path string, plan SeedPlan, size int) string {
|
||||||
q, tmp := shellQuote(path), shellQuote(path+seedTempSuffix)
|
q, tmp := shellQuote(path), shellQuote(path+seedTempSuffix)
|
||||||
cmds := []string{
|
cmds := []string{
|
||||||
fmt.Sprintf("mkdir -p \"$(dirname %s)\"", q),
|
fmt.Sprintf("mkdir -p \"$(dirname %s)\"", q),
|
||||||
fmt.Sprintf("cat > %s", tmp),
|
fmt.Sprintf("cat > %s", tmp),
|
||||||
// A stdin stream cut mid-transfer gives cat a short file and exit 0.
|
// 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),
|
fmt.Sprintf("n=$(wc -c < %s | tr -d ' \\n')", tmp),
|
||||||
|
// Compared as strings: an unmeasurable size is empty, not a number, and
|
||||||
|
// a numeric test would exit 2 there and be swallowed by the if.
|
||||||
|
fmt.Sprintf("if [ \"$n\" != '%d' ]; then rm -f %s; exit 1; fi", size, tmp),
|
||||||
}
|
}
|
||||||
if plan.QuarantineZoneFile {
|
if plan.QuarantineZoneFile {
|
||||||
cmds = append(cmds, moveAside(path, plan.QuarantineSuffix))
|
cmds = append(cmds, moveAside(path, plan.QuarantineSuffix))
|
||||||
@@ -98,7 +102,7 @@ func seedScript(path string, plan SeedPlan, size int) string {
|
|||||||
if plan.QuarantineJournal {
|
if plan.QuarantineJournal {
|
||||||
cmds = append(cmds, moveAside(JournalPath(path), plan.QuarantineSuffix))
|
cmds = append(cmds, moveAside(JournalPath(path), plan.QuarantineSuffix))
|
||||||
}
|
}
|
||||||
return strings.Join(append(cmds, fmt.Sprintf("mv -- %s %s", tmp, q)), "\n")
|
return shellScript(append(cmds, fmt.Sprintf("mv -- %s %s", tmp, q))...)
|
||||||
}
|
}
|
||||||
|
|
||||||
// AddCatalogMember registers a member zone in a catalog zone by adding the
|
// AddCatalogMember registers a member zone in a catalog zone by adding the
|
||||||
|
|||||||
@@ -39,12 +39,60 @@ func inFlightZone(t *testing.T) (dir, path string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func runSeedScript(t *testing.T, sh, path string, plan SeedPlan, content, stdin string) error {
|
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()
|
t.Helper()
|
||||||
cmd := exec.Command(sh, "-c", seedScript(path, plan, len(content)))
|
cmd := exec.Command(sh, "-c", seedScript(path, plan, len(content)))
|
||||||
cmd.Stdin = strings.NewReader(stdin)
|
cmd.Stdin = strings.NewReader(stdin)
|
||||||
|
if pathEnv != "" {
|
||||||
|
cmd.Env = append(os.Environ(), "PATH="+pathEnv)
|
||||||
|
}
|
||||||
return cmd.Run()
|
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 {
|
func probeState(t *testing.T, sh, path string) ZoneDiskState {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
out, err := exec.Command(sh, "-c", zoneStateProbe(path)).Output()
|
out, err := exec.Command(sh, "-c", zoneStateProbe(path)).Output()
|
||||||
@@ -166,3 +214,66 @@ func keys(m map[string]string) []string {
|
|||||||
}
|
}
|
||||||
return out
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -87,7 +87,9 @@ func PlanSeed(st ZoneDiskState) SeedPlan {
|
|||||||
|
|
||||||
// highestSerial reports the furthest serial on disk. The second result is false
|
// 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
|
// 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.
|
// double as "nothing found" in RFC 1982 sequence space. RFC 1982 ordering is
|
||||||
|
// not total, so the fold is order-dependent once the inputs span more than
|
||||||
|
// 2^31; a zone cannot advance that far between reconciles.
|
||||||
func highestSerial(st ZoneDiskState) (int64, bool) {
|
func highestSerial(st ZoneDiskState) (int64, bool) {
|
||||||
var h int64
|
var h int64
|
||||||
var known bool
|
var known bool
|
||||||
@@ -250,7 +252,9 @@ const (
|
|||||||
probeHeadShut = ">>head"
|
probeHeadShut = ">>head"
|
||||||
probeOrphanOpen = "orphans<<"
|
probeOrphanOpen = "orphans<<"
|
||||||
probeOrphanShut = ">>orphans"
|
probeOrphanShut = ">>orphans"
|
||||||
// quarantineMarker joins a preserved file to the serial it was holding.
|
// quarantineMarker joins a preserved file to the serial floor a reseed must
|
||||||
|
// stay above, which highestSerial may take from a sibling rather than from
|
||||||
|
// the renamed file itself.
|
||||||
quarantineMarker = ".orphaned-"
|
quarantineMarker = ".orphaned-"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -258,18 +262,20 @@ const (
|
|||||||
// record in both layouts the operator has to read.
|
// record in both layouts the operator has to read.
|
||||||
func zoneStateProbe(path string) string {
|
func zoneStateProbe(path string) string {
|
||||||
zone, jnl := shellQuote(path), shellQuote(JournalPath(path))
|
zone, jnl := shellQuote(path), shellQuote(JournalPath(path))
|
||||||
return strings.Join([]string{
|
return shellScript(
|
||||||
fmt.Sprintf("printf '%s\\n'", probeBegin),
|
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",
|
fmt.Sprintf("if [ -f %s ]; then printf 'zonefile=1\\n%s\\n'; head -c 4096 %s; printf '\\n%s\\n'; else printf 'zonefile=0\\n'; fi",
|
||||||
zone, probeHeadOpen, zone, probeHeadShut),
|
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",
|
// The hex is folded in its own assignment so a failing tr aborts the
|
||||||
|
// probe instead of reporting an unreadable journal header.
|
||||||
|
fmt.Sprintf("if [ -f %s ]; then printf 'journal=1\\n'; hdr=$(od -An -v -tx1 -N32 %s); hdr=$(printf '%%s' \"$hdr\" | tr -d ' \\n'); printf 'jnl=%%s\\n' \"$hdr\"; else printf 'journal=0\\n'; fi",
|
||||||
jnl, jnl),
|
jnl, jnl),
|
||||||
// The quarantine siblings outlive the files they replaced, so they are
|
// The quarantine siblings outlive the files they replaced, so they are
|
||||||
// the floor a reseed must stay above after an interrupted transition.
|
// 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'",
|
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),
|
probeOrphanOpen, shellQuote(path+quarantineMarker), shellQuote(JournalPath(path)+quarantineMarker), probeOrphanShut),
|
||||||
fmt.Sprintf("printf '%s\\n'", probeEnd),
|
fmt.Sprintf("printf '%s\\n'", probeEnd),
|
||||||
}, "\n")
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// parseZoneDiskState returns false unless the probe output is complete: a
|
// parseZoneDiskState returns false unless the probe output is complete: a
|
||||||
@@ -366,7 +372,7 @@ func (e *Executor) Quarantine(ctx context.Context, namespace, pod, path string,
|
|||||||
if len(cmds) == 0 {
|
if len(cmds) == 0 {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
cmd := []string{"sh", "-c", strings.Join(cmds, "\n")}
|
cmd := []string{"sh", "-c", shellScript(cmds...)}
|
||||||
if out, err := e.Exec(ctx, namespace, pod, cmd, ""); err != nil {
|
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 fmt.Errorf("quarantine zone files %s: %w (out: %s)", path, err, out)
|
||||||
}
|
}
|
||||||
@@ -386,3 +392,11 @@ func moveAside(path, suffix string) string {
|
|||||||
func shellQuote(s string) string {
|
func shellQuote(s string) string {
|
||||||
return "'" + strings.ReplaceAll(s, "'", `'\''`) + "'"
|
return "'" + strings.ReplaceAll(s, "'", `'\''`) + "'"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// shellScript joins commands into a script that stops at the first failure and
|
||||||
|
// exits non-zero. A plain script runs every line regardless of the previous
|
||||||
|
// one's status, which would let an install proceed over a failed quarantine and
|
||||||
|
// still report success to the caller.
|
||||||
|
func shellScript(cmds ...string) string {
|
||||||
|
return strings.Join(append([]string{"set -e"}, cmds...), "\n")
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user