test(e2e): one-off vocabulary — service checks skip run containers

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 <[email protected]>
This commit is contained in:
Nicolas De Loof
2026-08-18 16:49:56 +02:00
committed by Nicolas De loof
parent 695a2a759a
commit a725a8ae04
4 changed files with 158 additions and 68 deletions
+80 -10
View File
@@ -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")
}
+38 -10
View File
@@ -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 {
+31 -48
View File
@@ -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) {
@@ -0,0 +1,9 @@
services:
foo:
image: alpine
init: true
command: sleep infinity
bar:
image: alpine
init: true
command: sleep infinity