From a725a8ae0440f58d544fa4cc50ee58978a57da0d Mon Sep 17 00:00:00 2001 From: Nicolas De Loof Date: Mon, 17 Aug 2026 08:13:06 +0200 Subject: [PATCH] =?UTF-8?q?test(e2e):=20one-off=20vocabulary=20=E2=80=94?= =?UTF-8?q?=20service=20checks=20skip=20run=20containers?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Snapshots now record whether a container is a one-off (run) container, plus its start time. Service-facing checks (ServiceState, NotRecreated, label checks) observe only long-lived containers; three checks join the vocabulary for the one-off side: OneOffState, OneOffsUntouched (same containers, same state, same start time — catches an unwanted restart, which message matching never could) and OneOffsRemoved (errors if there was nothing to remove, rather than pass vacuously). TestStartStopWithOneOffs becomes the demonstrator: stop/start/restart act on services and leave the one-off untouched, down --remove-orphans takes it away. Signed-off-by: Nicolas De Loof --- pkg/e2e/checks.go | 90 ++++++++++++++++--- pkg/e2e/scenario.go | 48 +++++++--- pkg/e2e/start_stop_test.go | 79 +++++++--------- .../TestStartStopWithOneOffs/compose.yaml | 9 ++ 4 files changed, 158 insertions(+), 68 deletions(-) create mode 100644 pkg/e2e/testdata/TestStartStopWithOneOffs/compose.yaml diff --git a/pkg/e2e/checks.go b/pkg/e2e/checks.go index 0d2aaf00a..e7ea09fa4 100644 --- a/pkg/e2e/checks.go +++ b/pkg/e2e/checks.go @@ -149,13 +149,14 @@ func (c *pollCapture) Fatalf(format string, args ...any) { runtime.Goexit() } -// ServiceState expects every container of the service to be in the given -// state (running, exited, restarting, …). +// ServiceState expects every long-lived container of the service to be in +// the given state (running, exited, restarting, …). One-off (run) containers +// are not considered; see OneOffState. 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] + containers := ctx.curr.service(service) if len(containers) == 0 { return fmt.Errorf("service has no container") } @@ -169,8 +170,9 @@ func ServiceState(service, state string) Check { } } -// ServiceNotCreated expects the service to have no container at all, e.g. -// after an action that must leave unrelated services untouched. +// ServiceNotCreated expects the service to have no container at all — one-off +// containers included — e.g. after an action that must leave unrelated +// services untouched. func ServiceNotCreated(service string) Check { return Check{ name: fmt.Sprintf("service %q has no container", service), @@ -194,7 +196,7 @@ func NotRecreated(services ...string) Check { name: fmt.Sprintf("services %s not recreated", strings.Join(services, ", ")), fn: func(ctx *CheckContext) error { for _, service := range services { - before, after := containerIDs(ctx.prev[service]), containerIDs(ctx.curr[service]) + before, after := containerIDs(ctx.prev.service(service)), containerIDs(ctx.curr.service(service)) if len(before) == 0 { return fmt.Errorf("service %q had no container before the step", service) } @@ -216,12 +218,80 @@ func containerIDs(containers []containerState) []string { return ids } +// OneOffState expects every one-off (run) container of the service to be in +// the given state. +func OneOffState(service, state string) Check { + return Check{ + name: fmt.Sprintf("one-off of service %q is %s", service, state), + fn: func(ctx *CheckContext) error { + containers := ctx.curr.oneOffs(service) + if len(containers) == 0 { + return fmt.Errorf("service has no one-off container") + } + for _, c := range containers { + if c.State != state { + return fmt.Errorf("container %s is %s", c.Name, c.State) + } + } + return nil + }, + } +} + +// OneOffsUntouched expects the service's one-off containers to be exactly the +// ones from before the step, neither restarted nor removed: same container +// IDs, same state, same start time. +func OneOffsUntouched(service string) Check { + return Check{ + name: fmt.Sprintf("one-offs of service %q untouched", service), + fn: func(ctx *CheckContext) error { + before, after := ctx.prev.oneOffs(service), ctx.curr.oneOffs(service) + if len(before) == 0 { + return fmt.Errorf("service had no one-off container before the step") + } + if !slices.Equal(containerIDs(before), containerIDs(after)) { + return fmt.Errorf("one-off containers changed: %v -> %v", containerIDs(before), containerIDs(after)) + } + for i, b := range before { + a := after[i] + if a.State != b.State || a.StartedAt != b.StartedAt { + return fmt.Errorf("container %s was touched: %s (started %s) -> %s (started %s)", + b.Name, b.State, b.StartedAt, a.State, a.StartedAt) + } + } + return nil + }, + } +} + +// OneOffsRemoved expects the one-off containers the service had before the +// step to be gone; it errors if the service had none, rather than pass +// vacuously. +func OneOffsRemoved(service string) Check { + return Check{ + name: fmt.Sprintf("one-offs of service %q removed", service), + fn: func(ctx *CheckContext) error { + if len(ctx.prev.oneOffs(service)) == 0 { + return fmt.Errorf("service had no one-off container before the step") + } + var names []string + for _, c := range ctx.curr.oneOffs(service) { + names = append(names, c.Name) + } + if len(names) > 0 { + return fmt.Errorf("still present: %s", strings.Join(names, ", ")) + } + return nil + }, + } +} + // LabelSet expects every container of the service to carry a non-empty label. func LabelSet(service, key string) Check { return Check{ name: fmt.Sprintf("service %q has label %q set", service, key), fn: func(ctx *CheckContext) error { - containers := ctx.curr[service] + containers := ctx.curr.service(service) if len(containers) == 0 { return fmt.Errorf("service has no container") } @@ -243,7 +313,7 @@ func LabelsDistinct(key string, services ...string) Check { fn: func(ctx *CheckContext) error { seen := map[string]string{} for _, service := range services { - containers := ctx.curr[service] + containers := ctx.curr.service(service) if len(containers) == 0 { return fmt.Errorf("service %q has no container", service) } @@ -264,7 +334,7 @@ func LabelUnchanged(service, key string) Check { return Check{ name: fmt.Sprintf("service %q label %q unchanged", service, key), fn: func(ctx *CheckContext) error { - before, after := ctx.prev[service], ctx.curr[service] + before, after := ctx.prev.service(service), ctx.curr.service(service) if len(before) == 0 || len(after) == 0 { return fmt.Errorf("service has no container to compare") } @@ -282,7 +352,7 @@ func RunsOnPlatform(service, platform string) Check { return Check{ name: fmt.Sprintf("service %q container created for platform %s", service, platform), fn: func(ctx *CheckContext) error { - containers := ctx.curr[service] + containers := ctx.curr.service(service) if len(containers) == 0 { return fmt.Errorf("service has no container") } diff --git a/pkg/e2e/scenario.go b/pkg/e2e/scenario.go index d9ef43d29..8c23aa470 100644 --- a/pkg/e2e/scenario.go +++ b/pkg/e2e/scenario.go @@ -63,15 +63,40 @@ type stepRecord struct { // containerState is the per-container state captured after each step, used by // cross-step checks such as NotRecreated or LabelUnchanged. type containerState struct { - ID string - Name string - State string - Labels map[string]string + ID string + Name string + State string + StartedAt string + OneOff bool + Labels map[string]string } -// snapshot maps a service name to its containers, sorted by name. +// snapshot maps a service name to its containers — long-lived and one-off +// (run) alike — sorted by name. type snapshot map[string][]containerState +// service returns the service's long-lived containers, one-offs excluded. +func (s snapshot) service(name string) []containerState { + var containers []containerState + for _, c := range s[name] { + if !c.OneOff { + containers = append(containers, c) + } + } + return containers +} + +// oneOffs returns the service's one-off (run) containers. +func (s snapshot) oneOffs(name string) []containerState { + var containers []containerState + for _, c := range s[name] { + if c.OneOff { + containers = append(containers, c) + } + } + return containers +} + // ScenarioOption customizes a Scenario at creation time. type ScenarioOption func(*Scenario) @@ -306,7 +331,8 @@ func (s *Scenario) snapshot() snapshot { ID string `json:"Id"` Name string `json:"Name"` State struct { - Status string `json:"Status"` + Status string `json:"Status"` + StartedAt string `json:"StartedAt"` } `json:"State"` Config struct { Labels map[string]string `json:"Labels"` @@ -318,10 +344,12 @@ func (s *Scenario) snapshot() snapshot { for _, c := range containers { service := c.Config.Labels["com.docker.compose.service"] snap[service] = append(snap[service], containerState{ - ID: c.ID, - Name: strings.TrimPrefix(c.Name, "/"), - State: c.State.Status, - Labels: c.Config.Labels, + ID: c.ID, + Name: strings.TrimPrefix(c.Name, "/"), + State: c.State.Status, + StartedAt: c.State.StartedAt, + OneOff: c.Config.Labels["com.docker.compose.oneoff"] == "True", + Labels: c.Config.Labels, }) } for service := range snap { diff --git a/pkg/e2e/start_stop_test.go b/pkg/e2e/start_stop_test.go index e37a770b8..bad7fdb4a 100644 --- a/pkg/e2e/start_stop_test.go +++ b/pkg/e2e/start_stop_test.go @@ -81,54 +81,37 @@ func TestUpNoDeps(t *testing.T) { } func TestStartStopWithOneOffs(t *testing.T) { - c := NewParallelCLI(t) - const projectName = "e2e-start-stop-with-oneoffs" - - t.Run("Up", func(t *testing.T) { - res := c.RunDockerComposeCmd(t, "-f", "./fixtures/dependencies/compose.yaml", "--project-name", projectName, - "up", "-d") - assert.Assert(t, strings.Contains(res.Combined(), "Container e2e-start-stop-with-oneoffs-foo-1 Started"), res.Combined()) - assert.Assert(t, strings.Contains(res.Combined(), "Container e2e-start-stop-with-oneoffs-bar-1 Started"), res.Combined()) - }) - - t.Run("run one-off", func(t *testing.T) { - c.RunDockerComposeCmd(t, "-f", "./fixtures/dependencies/compose.yaml", "--project-name", projectName, "run", "-d", "bar", "sleep", "infinity") - res := c.RunDockerComposeCmd(t, "--project-name", projectName, "ps", "-a") - assert.Assert(t, strings.Contains(res.Combined(), "e2e-start-stop-with-oneoffs-foo-1"), res.Combined()) - assert.Assert(t, strings.Contains(res.Combined(), "e2e-start-stop-with-oneoffs-bar-1"), res.Combined()) - assert.Assert(t, strings.Contains(res.Combined(), "e2e-start-stop-with-oneoffs-bar-run"), res.Combined()) - }) - - t.Run("stop (not one-off containers)", func(t *testing.T) { - res := c.RunDockerComposeCmd(t, "--project-name", projectName, "stop") - assert.Assert(t, strings.Contains(res.Combined(), "e2e-start-stop-with-oneoffs-foo-1"), res.Combined()) - assert.Assert(t, strings.Contains(res.Combined(), "e2e-start-stop-with-oneoffs-bar-1"), res.Combined()) - assert.Assert(t, !strings.Contains(res.Combined(), "e2e_start_stop_with_oneoffs-bar-run"), res.Combined()) - - res = c.RunDockerComposeCmd(t, "--project-name", projectName, "ps", "-a", "--status", "running") - assert.Assert(t, strings.Contains(res.Combined(), "e2e-start-stop-with-oneoffs-bar-run"), res.Combined()) - }) - - t.Run("start (not one-off containers)", func(t *testing.T) { - res := c.RunDockerComposeCmd(t, "--project-name", projectName, "start") - assert.Assert(t, strings.Contains(res.Combined(), "e2e-start-stop-with-oneoffs-foo-1"), res.Combined()) - assert.Assert(t, strings.Contains(res.Combined(), "e2e-start-stop-with-oneoffs-bar-1"), res.Combined()) - assert.Assert(t, !strings.Contains(res.Combined(), "e2e-start-stop-with-oneoffs-bar-run"), res.Combined()) - }) - - t.Run("restart (not one-off containers)", func(t *testing.T) { - res := c.RunDockerComposeCmd(t, "--project-name", projectName, "restart") - assert.Assert(t, strings.Contains(res.Combined(), "e2e-start-stop-with-oneoffs-foo-1"), res.Combined()) - assert.Assert(t, strings.Contains(res.Combined(), "e2e-start-stop-with-oneoffs-bar-1"), res.Combined()) - assert.Assert(t, !strings.Contains(res.Combined(), "e2e-start-stop-with-oneoffs-bar-run"), res.Combined()) - }) - - t.Run("down", func(t *testing.T) { - c.RunDockerComposeCmd(t, "--project-name", projectName, "down", "--remove-orphans") - - res := c.RunDockerComposeCmd(t, "--project-name", projectName, "ps", "-a", "--status", "running") - assert.Assert(t, !strings.Contains(res.Combined(), "e2e-start-stop-with-oneoffs-bar"), res.Combined()) - }) + NewScenario(t, "stop, start and restart must act on services and leave one-off containers untouched"). + Step("up starts the services", + ComposeCmd("up", "-d"), + ServiceState("foo", "running"), + ServiceState("bar", "running")). + Step("run adds a one-off container alongside the service", + ComposeCmd("run", "-d", "bar", "sleep", "infinity"), + OneOffState("bar", "running"), + NotRecreated("foo", "bar")). + Step("stop halts the services but not the one-off", + ComposeCmd("stop"), + ServiceState("foo", "exited"), + ServiceState("bar", "exited"), + OneOffState("bar", "running"), + OneOffsUntouched("bar")). + Step("start resumes the services without touching the one-off", + ComposeCmd("start"), + ServiceState("foo", "running"), + ServiceState("bar", "running"), + OneOffsUntouched("bar")). + Step("restart restarts the services but not the one-off", + ComposeCmd("restart"), + ServiceState("foo", "running"), + ServiceState("bar", "running"), + NotRecreated("foo", "bar"), + OneOffsUntouched("bar")). + Step("down --remove-orphans removes the one-off as well", + ComposeCmd("down", "--remove-orphans"), + ServiceNotCreated("foo"), + ServiceNotCreated("bar"), + OneOffsRemoved("bar")) } func TestStartAlreadyRunning(t *testing.T) { diff --git a/pkg/e2e/testdata/TestStartStopWithOneOffs/compose.yaml b/pkg/e2e/testdata/TestStartStopWithOneOffs/compose.yaml new file mode 100644 index 000000000..b173f127a --- /dev/null +++ b/pkg/e2e/testdata/TestStartStopWithOneOffs/compose.yaml @@ -0,0 +1,9 @@ +services: + foo: + image: alpine + init: true + command: sleep infinity + bar: + image: alpine + init: true + command: sleep infinity