mirror of
https://github.com/docker/compose.git
synced 2026-10-12 02:13:08 +00:00
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:
committed by
Nicolas De loof
parent
695a2a759a
commit
a725a8ae04
+80
-10
@@ -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
@@ -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
@@ -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
|
||||
Reference in New Issue
Block a user