diff --git a/pkg/e2e/restart_test.go b/pkg/e2e/restart_test.go index c9df28cb2..7ea2b4bfe 100644 --- a/pkg/e2e/restart_test.go +++ b/pkg/e2e/restart_test.go @@ -34,34 +34,24 @@ func assertServiceStatus(t *testing.T, projectName, service, status string, ps s } func TestRestart(t *testing.T) { - c := NewParallelCLI(t) - const projectName = "e2e-restart" - - t.Run("Up a project", func(t *testing.T) { - // This is just to ensure the containers do NOT exist - c.RunDockerComposeCmd(t, "--project-name", projectName, "down") - - res := c.RunDockerComposeCmd(t, "-f", "./fixtures/restart-test/compose.yaml", "--project-name", projectName, "up", "-d") - assert.Assert(t, strings.Contains(res.Combined(), "Container e2e-restart-restart-1 Started"), res.Combined()) - - c.WaitForCmdResult(t, c.NewDockerComposeCmd(t, "--project-name", projectName, "ps", "-a", "--format", - "json"), - StdoutContains(`"State":"exited"`), 10*time.Second, 1*time.Second) - - res = c.RunDockerComposeCmd(t, "--project-name", projectName, "ps", "-a") - assertServiceStatus(t, projectName, "restart", "Exited", res.Stdout()) - - c.RunDockerComposeCmd(t, "-f", "./fixtures/restart-test/compose.yaml", "--project-name", projectName, "restart") - - // Give the same time but it must NOT exit - time.Sleep(time.Second) - - res = c.RunDockerComposeCmd(t, "--project-name", projectName, "ps") - assertServiceStatus(t, projectName, "restart", "Up", res.Stdout()) - - // Clean up - c.RunDockerComposeCmd(t, "--project-name", projectName, "down") - }) + // the service's first run creates a lock file and exits at once; any + // later run of the same container finds the lock and sleeps forever, so + // staying up after `restart` proves the same container was restarted + NewScenario(t, "restart must bring an exited service back up, restarting the same container"). + Compose(` +services: + app: + image: alpine + init: true + command: ash -c "if [[ -f /tmp/restart.lock ]] ; then sleep infinity; else touch /tmp/restart.lock; fi" +`). + Step("up starts the service, whose first run exits at once", + ComposeCmd("up", "-d"), + Eventually(ServiceState("app", "exited"), 10*time.Second)). + Step("restart brings the service back up, reusing the container", + ComposeCmd("restart"), + Eventually(ServiceState("app", "running"), 10*time.Second), + NotRecreated("app")) } func TestRestartWithDependencies(t *testing.T) { diff --git a/pkg/e2e/scenario.go b/pkg/e2e/scenario.go index 46b1a28a5..c13b5697a 100644 --- a/pkg/e2e/scenario.go +++ b/pkg/e2e/scenario.go @@ -21,6 +21,7 @@ import ( "fmt" "os" "path/filepath" + "runtime" "slices" "strconv" "strings" @@ -29,6 +30,7 @@ import ( "unicode" "gotest.tools/v3/icmd" + "gotest.tools/v3/poll" ) // Scenario is a thin declarative layer over the e2e CLI helpers: a test reads @@ -65,6 +67,7 @@ type stepRecord struct { type containerState struct { ID string Name string + State string Labels map[string]string } @@ -259,8 +262,11 @@ func (s *Scenario) snapshot() snapshot { return snap } var containers []struct { - ID string `json:"Id"` - Name string `json:"Name"` + ID string `json:"Id"` + Name string `json:"Name"` + State struct { + Status string `json:"Status"` + } `json:"State"` Config struct { Labels map[string]string `json:"Labels"` } `json:"Config"` @@ -273,6 +279,7 @@ func (s *Scenario) snapshot() snapshot { snap[service] = append(snap[service], containerState{ ID: c.ID, Name: strings.TrimPrefix(c.Name, "/"), + State: c.State.Status, Labels: c.Config.Labels, }) } @@ -416,6 +423,15 @@ type CheckContext struct { curr snapshot } +// refresh re-observes the project state, so subsequent checks of the same +// step see the latest state rather than the one captured right after the +// command returned. +func (ctx *CheckContext) refresh() { + s := ctx.scenario + ctx.curr = s.snapshot() + s.snaps[len(s.snaps)-1] = ctx.curr +} + // Check is a named observable expected to hold after a step. type Check struct { name string @@ -450,6 +466,75 @@ func OutputNotContains(sub string) Check { } } +// Eventually retries a state-based check until it holds or the timeout +// expires, re-observing the project state between attempts. Output-based +// checks are not meaningful here: the step's output never changes. +// Polling is delegated to gotest.tools/v3/poll, the same engine the rest of +// the e2e framework uses. +func Eventually(check Check, timeout time.Duration) Check { + return Check{ + name: fmt.Sprintf("%s within %s", check.name, timeout), + fn: func(ctx *CheckContext) error { + first := true + capture := &pollCapture{} + done := make(chan struct{}) + // poll.WaitOn reports timeout through TestingT.Fatalf and relies + // on it halting execution; run it in a goroutine so pollCapture + // can Goexit and hand the error back to the scenario report + // instead of failing the test on the spot. + go func() { + defer close(done) + poll.WaitOn(capture, func(poll.LogT) poll.Result { + if !first { + ctx.refresh() + } + first = false + if err := check.fn(ctx); err != nil { + return poll.Continue("%v", err) + } + return poll.Success() + }, poll.WithDelay(500*time.Millisecond), poll.WithTimeout(timeout)) + }() + <-done + return capture.err + }, + } +} + +// pollCapture is a poll.TestingT that records the failure instead of failing +// the test, so Eventually can feed it to the scenario failure report. +type pollCapture struct { + err error +} + +func (c *pollCapture) Log(args ...any) {} +func (c *pollCapture) Logf(format string, args ...any) {} + +func (c *pollCapture) Fatalf(format string, args ...any) { + c.err = fmt.Errorf(format, args...) + runtime.Goexit() +} + +// ServiceState expects every container of the service to be in the given +// state (running, exited, restarting, …). +func ServiceState(service, state string) Check { + return Check{ + name: fmt.Sprintf("service %q is %s", service, state), + fn: func(ctx *CheckContext) error { + containers := ctx.curr[service] + if len(containers) == 0 { + return fmt.Errorf("service has no container") + } + for _, c := range containers { + if c.State != state { + return fmt.Errorf("container %s is %s", c.Name, c.State) + } + } + return nil + }, + } +} + // NotRecreated expects the services' containers to be exactly the ones that // existed before the step (same container IDs). func NotRecreated(services ...string) Check {