controllerswap: F1 verify hardening — reject crash-looping no-healthcheck image v0.47.0
controllerHealthy reads RestartCount (running&&rc>0 -> not ok) + signals needsDwell for no-healthcheck; verify requires verifyDwell(=3) consecutive ok polls for a no-healthcheck image (real healthcheck trusted immediately). Closes the F1 hole (alpine crash-loop passed the point-in-time check). Red-proof + dwell + real-image tests. No sudoers/orchestration change. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Pg8ANF97SEeKYSN5Jxw3qJ
This commit is contained in:
@@ -10,6 +10,7 @@ import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
@@ -65,6 +66,8 @@ type ControllerSwapper struct {
|
||||
logger *slog.Logger
|
||||
verifyTimeout time.Duration
|
||||
verifyInterval time.Duration
|
||||
verifyDwell int // F1: consecutive ok polls required to accept a NO-healthcheck image (a real
|
||||
// healthcheck passes without dwell). Catches a slow crash-loop that hasn't bumped RestartCount yet.
|
||||
}
|
||||
|
||||
// NewControllerSwapper builds a swapper. stateDir holds controller-swap-<vmid>.json (default
|
||||
@@ -82,6 +85,7 @@ func NewControllerSwapper(exec GuestExecutor, stateDir string, logger *slog.Logg
|
||||
logger: logger,
|
||||
verifyTimeout: 90 * time.Second,
|
||||
verifyInterval: 3 * time.Second,
|
||||
verifyDwell: 3,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -152,40 +156,67 @@ func (c *ControllerSwapper) restartBootstrap(ctx context.Context, vmid int) erro
|
||||
return err
|
||||
}
|
||||
|
||||
// controllerHealthy reports running + (healthy or no healthcheck) + the running image == want.
|
||||
// Returns (ok, stillStarting): stillStarting=true means keep polling.
|
||||
func (c *ControllerSwapper) controllerHealthy(ctx context.Context, vmid int, want string) (ok, starting bool) {
|
||||
// controllerHealthy is a pure point-in-time predicate: running + RestartCount==0 + (healthy OR no
|
||||
// healthcheck) + the running image == want. Returns (ok, stillStarting, needsDwell):
|
||||
// - stillStarting=true → keep polling (container absent, not-yet-running, wrong image, unhealthy, or
|
||||
// already-restarted).
|
||||
// - needsDwell=true → ok BUT the image has NO healthcheck, so the caller must confirm it stays ok for
|
||||
// verifyDwell consecutive polls before trusting it (F1: a no-healthcheck crash-loop can flicker
|
||||
// Running for one instant). A real `healthy` result is trusted immediately (Docker already gated it).
|
||||
//
|
||||
// RestartCount>0 means the process has already crashed+restarted → not stably up, regardless of
|
||||
// healthcheck presence (the F1 hole: alpine flickered Running between restarts and passed).
|
||||
func (c *ControllerSwapper) controllerHealthy(ctx context.Context, vmid int, want string) (ok, starting, needsDwell bool) {
|
||||
out, err := c.exec.GuestExec(ctx, vmid, "docker", "inspect", "-f",
|
||||
"{{.State.Running}}|{{if .State.Health}}{{.State.Health.Status}}{{else}}none{{end}}|{{.Config.Image}}", controllerContainer)
|
||||
"{{.State.Running}}|{{if .State.Health}}{{.State.Health.Status}}{{else}}none{{end}}|{{.Config.Image}}|{{.RestartCount}}", controllerContainer)
|
||||
if err != nil {
|
||||
return false, true // container not there yet (rm -f window) → keep polling
|
||||
return false, true, false // container not there yet (rm -f window) → keep polling
|
||||
}
|
||||
f := strings.SplitN(strings.TrimSpace(out), "|", 3)
|
||||
if len(f) != 3 {
|
||||
return false, true
|
||||
f := strings.SplitN(strings.TrimSpace(out), "|", 4)
|
||||
if len(f) != 4 {
|
||||
return false, true, false
|
||||
}
|
||||
running, health, image := f[0] == "true", f[1], f[2]
|
||||
running, health, image, restartStr := f[0] == "true", f[1], f[2], f[3]
|
||||
if !running {
|
||||
return false, true
|
||||
return false, true, false
|
||||
}
|
||||
if image != want {
|
||||
return false, true // bootstrap may not have re-run yet
|
||||
return false, true, false // bootstrap may not have re-run yet
|
||||
}
|
||||
if rc, err := strconv.Atoi(strings.TrimSpace(restartStr)); err == nil && rc > 0 {
|
||||
return false, true, false // already crash-restarted → not stably up
|
||||
}
|
||||
switch health {
|
||||
case "healthy", "none":
|
||||
return true, false
|
||||
case "healthy":
|
||||
return true, false, false // Docker gated it → trust immediately
|
||||
case "none":
|
||||
return true, false, true // no healthcheck → ok, but require the dwell
|
||||
case "starting":
|
||||
return false, true
|
||||
return false, true, false
|
||||
default: // unhealthy
|
||||
return false, true
|
||||
return false, true, false
|
||||
}
|
||||
}
|
||||
|
||||
func (c *ControllerSwapper) verify(ctx context.Context, vmid int, want string) bool {
|
||||
deadline := time.Now().Add(c.verifyTimeout)
|
||||
consecutiveOK := 0
|
||||
dwell := c.verifyDwell
|
||||
if dwell < 1 {
|
||||
dwell = 1
|
||||
}
|
||||
for {
|
||||
if ok, _ := c.controllerHealthy(ctx, vmid, want); ok {
|
||||
return true
|
||||
ok, _, needsDwell := c.controllerHealthy(ctx, vmid, want)
|
||||
switch {
|
||||
case ok && !needsDwell:
|
||||
return true // real healthcheck passed → done
|
||||
case ok: // no-healthcheck image: require verifyDwell consecutive ok polls
|
||||
consecutiveOK++
|
||||
if consecutiveOK >= dwell {
|
||||
return true
|
||||
}
|
||||
default:
|
||||
consecutiveOK = 0 // any not-ok resets the dwell (a crash between polls)
|
||||
}
|
||||
if time.Now().After(deadline) || ctx.Err() != nil {
|
||||
return false
|
||||
|
||||
Reference in New Issue
Block a user