32a2b7a9ae
P0 Health-gated rollback (standalone auto-update path): capture the previous image id + reference + healthcheck before the recreate, then poll the new container's health over a configurable window. On healthy proceed (and only then clean up the old image); on unhealthy/exit/timeout re-tag the old image back onto the original reference and Recreate (no pull) to restore it, reusing Recreate's config preservation. The decision is a pure decideRollback() helper. P1 Per-endpoint enable: ContainerAutomationDisabled flag on Endpoint (zero value participates, no migration churn), checked by both daemons; settable via the endpoint update API. UI control deferred (see report). P2 Notifier seam: minimal Notifier interface + logNotifier, emitting structured updated/rollback/update-failed/heal-restarted events from the daemon. Settings: RollbackOnFailure + RollbackTimeout (default 120s) added to ContainerAutomation.AutoUpdate, wired through defaults/migration/golden, settings_update validation, the AutoUpdatePanel and the TS types. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
122 lines
3.8 KiB
Go
122 lines
3.8 KiB
Go
package containerautomation
|
|
|
|
import (
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/docker/docker/api/types/container"
|
|
)
|
|
|
|
func TestDecideRollback(t *testing.T) {
|
|
now := time.Date(2026, 6, 28, 12, 0, 0, 0, time.UTC)
|
|
deadline := now.Add(120 * time.Second)
|
|
|
|
tests := []struct {
|
|
name string
|
|
health containerHealth
|
|
at time.Time
|
|
want rollbackOutcome
|
|
}{
|
|
{
|
|
name: "healthy within the window accepts the update",
|
|
health: containerHealth{Running: true, Status: string(container.Healthy)},
|
|
at: now.Add(10 * time.Second),
|
|
want: rollbackHealthy,
|
|
},
|
|
{
|
|
name: "unhealthy triggers an immediate rollback",
|
|
health: containerHealth{Running: true, Status: string(container.Unhealthy)},
|
|
at: now.Add(10 * time.Second),
|
|
want: rollbackTrigger,
|
|
},
|
|
{
|
|
name: "still starting before the deadline keeps polling",
|
|
health: containerHealth{Running: true, Status: string(container.Starting)},
|
|
at: now.Add(10 * time.Second),
|
|
want: rollbackContinue,
|
|
},
|
|
{
|
|
name: "still starting past the deadline rolls back",
|
|
health: containerHealth{Running: true, Status: string(container.Starting)},
|
|
at: now.Add(121 * time.Second),
|
|
want: rollbackTrigger,
|
|
},
|
|
{
|
|
name: "starting exactly at the deadline rolls back",
|
|
health: containerHealth{Running: true, Status: string(container.Starting)},
|
|
at: deadline,
|
|
want: rollbackTrigger,
|
|
},
|
|
{
|
|
name: "exited container rolls back even before the deadline",
|
|
health: containerHealth{Running: false, Status: string(container.Starting)},
|
|
at: now.Add(5 * time.Second),
|
|
want: rollbackTrigger,
|
|
},
|
|
{
|
|
name: "unhealthy wins over a stopped state",
|
|
health: containerHealth{Running: false, Status: string(container.Unhealthy)},
|
|
at: now.Add(5 * time.Second),
|
|
want: rollbackTrigger,
|
|
},
|
|
{
|
|
name: "healthy wins even past the deadline",
|
|
health: containerHealth{Running: true, Status: string(container.Healthy)},
|
|
at: now.Add(200 * time.Second),
|
|
want: rollbackHealthy,
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
if got := decideRollback(tt.health, tt.at, deadline); got != tt.want {
|
|
t.Errorf("decideRollback() = %v, want %v", got, tt.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestHasHealthGate(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
hc *container.HealthConfig
|
|
want bool
|
|
}{
|
|
{name: "nil config has no gate", hc: nil, want: false},
|
|
{name: "empty test inherits, no usable gate", hc: &container.HealthConfig{Test: nil}, want: false},
|
|
{name: "explicit NONE disables the gate", hc: &container.HealthConfig{Test: []string{"NONE"}}, want: false},
|
|
{name: "CMD healthcheck yields a gate", hc: &container.HealthConfig{Test: []string{"CMD", "curl", "-f", "localhost"}}, want: true},
|
|
{name: "CMD-SHELL healthcheck yields a gate", hc: &container.HealthConfig{Test: []string{"CMD-SHELL", "exit 0"}}, want: true},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
if got := hasHealthGate(tt.hc); got != tt.want {
|
|
t.Errorf("hasHealthGate() = %v, want %v", got, tt.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestParseRollbackTimeout(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
raw string
|
|
want time.Duration
|
|
}{
|
|
{name: "valid duration", raw: "90s", want: 90 * time.Second},
|
|
{name: "empty falls back to default", raw: "", want: defaultRollbackTimeout},
|
|
{name: "unparseable falls back to default", raw: "nope", want: defaultRollbackTimeout},
|
|
{name: "zero falls back to default", raw: "0s", want: defaultRollbackTimeout},
|
|
{name: "negative falls back to default", raw: "-5s", want: defaultRollbackTimeout},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
if got := parseRollbackTimeout(tt.raw); got != tt.want {
|
|
t.Errorf("parseRollbackTimeout(%q) = %v, want %v", tt.raw, got, tt.want)
|
|
}
|
|
})
|
|
}
|
|
}
|