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:
2026-06-29 22:45:41 +02:00
parent bb548e3c5a
commit 3844df7c23
8 changed files with 218 additions and 57 deletions
+48 -17
View File
@@ -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