mirror of
https://github.com/docker/compose.git
synced 2026-10-12 02:13:08 +00:00
test(e2e): Eventually check, TestRestart as polling demonstrator
Add Eventually(check, timeout) to the scenario layer: it retries a state-based check, re-observing the project containers between attempts. ServiceState(service, state) joins the check library, backed by a State field in the per-step snapshots. TestRestart is converted as the demonstrator: it replaces a WaitForCmdResult polling `ps --format json` for a JSON fragment and a bare time.Sleep with two declarative expectations, and now also locks the real invariant — restart must reuse the same container — via NotRecreated, which the lock-file trick of the original fixture was proving only indirectly. Signed-off-by: Nicolas De Loof <[email protected]>
This commit is contained in:
+18
-28
@@ -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) {
|
||||
|
||||
+87
-2
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user