Files
portainer/api/containerautomation/rollback_test.go
T
claude code agent 32a2b7a9ae feat(automation): health-gated rollback + per-endpoint + notify hook (#12, epic #3 M5)
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>
2026-06-29 10:41:55 +03:00

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)
}
})
}
}