mirror of
https://github.com/docker/compose.git
synced 2026-08-30 13:31:54 +00:00
test: unit-lock the imperative start path
The imperative start engine (startService, startServiceContainer, restart, waitDependencies) is only locked by e2e tests today. Before converging it into the plan engine (#14081), pin its observable behavior with unit characterization tests: start idempotence and the silent no-op on running replicas, the "no container to start" error, pre_start gating (mixed replicas skip the hook, all-stopped runs it once on the lowest-numbered replica), the inject -> start -> post_start sequence with Started only emitted after hooks, restart's depends_on restart:true selection and its pre_stop -> ContainerRestart -> post_start order, --wait's completed_successfully condition mapping, and waitDependencies' missing/failing dependency semantics for required and optional dependencies. No production change. Part of #14074 (section C) / #14081 (lot 0). Signed-off-by: Nicolas De Loof <nicolas.deloof@gmail.com>
This commit is contained in:
parent
3a62ae63ce
commit
0d7d22585a
3 changed files with 521 additions and 0 deletions
|
|
@ -280,6 +280,48 @@ func TestWaitDependencies(t *testing.T) {
|
|||
}
|
||||
assert.NilError(t, tested.(*composeService).waitDependencies(t.Context(), &project, "", dependencies, nil, 0))
|
||||
})
|
||||
t.Run("missing required dependency is an error", func(t *testing.T) {
|
||||
project := types.Project{Name: strings.ToLower(testProject), Services: types.Services{
|
||||
"db": {Name: "db", Scale: intPtr(1)},
|
||||
}}
|
||||
dependencies := types.DependsOnConfig{
|
||||
"db": {Condition: ServiceConditionRunningOrHealthy, Required: true},
|
||||
}
|
||||
err := tested.(*composeService).waitDependencies(t.Context(), &project, "app", dependencies, nil, 0)
|
||||
assert.Error(t, err, "app is missing dependency db")
|
||||
})
|
||||
t.Run("missing optional dependency is only a warning", func(t *testing.T) {
|
||||
project := types.Project{Name: strings.ToLower(testProject), Services: types.Services{
|
||||
"db": {Name: "db", Scale: intPtr(1)},
|
||||
}}
|
||||
dependencies := types.DependsOnConfig{
|
||||
"db": {Condition: ServiceConditionRunningOrHealthy, Required: false},
|
||||
}
|
||||
assert.NilError(t, tested.(*composeService).waitDependencies(t.Context(), &project, "app", dependencies, nil, 0))
|
||||
})
|
||||
t.Run("failing optional dependency is skipped, not an error", func(t *testing.T) {
|
||||
project := types.Project{Name: strings.ToLower(testProject), Services: types.Services{
|
||||
"db": {Name: "db", Scale: intPtr(1)},
|
||||
}}
|
||||
dependencies := types.DependsOnConfig{
|
||||
"db": {Condition: types.ServiceConditionHealthy, Required: false},
|
||||
}
|
||||
containers := Containers{{
|
||||
ID: "db-ctr",
|
||||
Names: []string{"/db-ctr"},
|
||||
Labels: map[string]string{api.ServiceLabel: "db"},
|
||||
}}
|
||||
// The dependency exited: a required dependency would fail the wait,
|
||||
// an optional one is skipped after the first poll.
|
||||
apiClient.EXPECT().ContainerInspect(gomock.Any(), "db-ctr", gomock.Any()).Return(client.ContainerInspectResult{
|
||||
Container: container.InspectResponse{
|
||||
ID: "db-ctr",
|
||||
Name: "/db-ctr",
|
||||
State: &container.State{Status: container.StateExited, ExitCode: 1},
|
||||
},
|
||||
}, nil)
|
||||
assert.NilError(t, tested.(*composeService).waitDependencies(t.Context(), &project, "app", dependencies, containers, 0))
|
||||
})
|
||||
}
|
||||
|
||||
func TestIsServiceHealthy(t *testing.T) {
|
||||
|
|
|
|||
152
pkg/compose/restart_test.go
Normal file
152
pkg/compose/restart_test.go
Normal file
|
|
@ -0,0 +1,152 @@
|
|||
//go:build !windows
|
||||
|
||||
/*
|
||||
Copyright 2020 Docker Compose CLI authors
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
package compose
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net"
|
||||
"testing"
|
||||
|
||||
"github.com/compose-spec/compose-go/v2/types"
|
||||
"github.com/moby/moby/api/types/container"
|
||||
"github.com/moby/moby/client"
|
||||
"go.uber.org/mock/gomock"
|
||||
"gotest.tools/v3/assert"
|
||||
|
||||
"github.com/docker/compose/v5/pkg/api"
|
||||
)
|
||||
|
||||
// These tests characterize the restart path before the lifecycle engines
|
||||
// converge (#14081): which depends_on relations survive project preparation,
|
||||
// and the per-container sequence (pre_stop → ContainerRestart → post_start)
|
||||
// with its events. restart stays on the imperative engine but shares the
|
||||
// hook/wait primitives with the plan.
|
||||
|
||||
// TestPrepareRestartProject locks the selection semantics: only depends_on
|
||||
// relations declaring restart:true are kept, and naming services includes
|
||||
// their dependents (never their dependencies).
|
||||
func TestPrepareRestartProject(t *testing.T) {
|
||||
svc, _, _ := newStartTestService(t)
|
||||
|
||||
// A fresh project per subtest: the transforms applied by
|
||||
// prepareRestartProject share the DependsOn maps with their input.
|
||||
newProject := func() *types.Project {
|
||||
return &types.Project{
|
||||
Name: "prj",
|
||||
Services: types.Services{
|
||||
"proxy": {
|
||||
Name: "proxy",
|
||||
DependsOn: types.DependsOnConfig{
|
||||
"web": {Condition: types.ServiceConditionStarted, Restart: true, Required: true},
|
||||
},
|
||||
},
|
||||
"web": {
|
||||
Name: "web",
|
||||
DependsOn: types.DependsOnConfig{
|
||||
"db": {Condition: types.ServiceConditionStarted, Restart: false, Required: true},
|
||||
},
|
||||
},
|
||||
"db": {Name: "db"},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
t.Run("only restart:true relations survive", func(t *testing.T) {
|
||||
prepared, err := svc.prepareRestartProject(t.Context(), nil, "prj", api.RestartOptions{Project: newProject()})
|
||||
assert.NilError(t, err)
|
||||
_, hasWebDep := prepared.Services["proxy"].DependsOn["web"]
|
||||
assert.Assert(t, hasWebDep)
|
||||
_, hasDBDep := prepared.Services["web"].DependsOn["db"]
|
||||
assert.Assert(t, !hasDBDep)
|
||||
})
|
||||
|
||||
t.Run("naming a service includes its dependents, not its dependencies", func(t *testing.T) {
|
||||
prepared, err := svc.prepareRestartProject(t.Context(), nil, "prj", api.RestartOptions{
|
||||
Project: newProject(),
|
||||
Services: []string{"web"},
|
||||
})
|
||||
assert.NilError(t, err)
|
||||
names := prepared.ServiceNames()
|
||||
assert.Assert(t, len(names) == 2, "got %v", names)
|
||||
assert.Assert(t, prepared.Services["web"].Name == "web")
|
||||
// proxy depends on web with restart:true → restarted alongside
|
||||
assert.Assert(t, prepared.Services["proxy"].Name == "proxy")
|
||||
})
|
||||
|
||||
t.Run("no-deps drops the dependents", func(t *testing.T) {
|
||||
prepared, err := svc.prepareRestartProject(t.Context(), nil, "prj", api.RestartOptions{
|
||||
Project: newProject(),
|
||||
Services: []string{"web"},
|
||||
NoDeps: true,
|
||||
})
|
||||
assert.NilError(t, err)
|
||||
assert.DeepEqual(t, prepared.ServiceNames(), []string{"web"})
|
||||
})
|
||||
}
|
||||
|
||||
// TestRestartContainer_Order locks the per-container restart sequence:
|
||||
// pre_stop hooks → atomic ContainerRestart (not stop+start) → post_start
|
||||
// hooks, with the Restarting event first and — a long-standing quirk — a
|
||||
// final event that says Started, not Restarted.
|
||||
func TestRestartContainer_Order(t *testing.T) {
|
||||
svc, apiClient, rec := newStartTestService(t)
|
||||
|
||||
service := types.ServiceConfig{
|
||||
Name: "web",
|
||||
PreStop: []types.ServiceHook{{Command: types.ShellCommand{"quiesce"}}},
|
||||
PostStart: []types.ServiceHook{{Command: types.ShellCommand{"warmup"}}},
|
||||
}
|
||||
ctr := serviceContainer("web", 1, container.StateRunning)
|
||||
|
||||
// pre_stop exec
|
||||
preStop := apiClient.EXPECT().ExecCreate(gomock.Any(), ctr.ID, gomock.Any()).
|
||||
Return(client.ExecCreateResult{ID: "exec-stop"}, nil)
|
||||
conn1s, conn1c := net.Pipe()
|
||||
_ = conn1s.Close()
|
||||
preStopAttach := apiClient.EXPECT().ExecAttach(gomock.Any(), "exec-stop", gomock.Any()).
|
||||
Return(client.ExecAttachResult{HijackedResponse: client.NewHijackedResponse(conn1c, "")}, nil).
|
||||
After(preStop)
|
||||
preStopInspect := apiClient.EXPECT().ExecInspect(gomock.Any(), "exec-stop", gomock.Any()).
|
||||
Return(client.ExecInspectResult{ExitCode: 0}, nil).After(preStopAttach)
|
||||
|
||||
restart := apiClient.EXPECT().ContainerRestart(gomock.Any(), ctr.ID, gomock.Any()).
|
||||
DoAndReturn(func(context.Context, string, client.ContainerRestartOptions) (client.ContainerRestartResult, error) {
|
||||
assert.Assert(t, rec.contains("Container prj-web-1: Restarting"))
|
||||
return client.ContainerRestartResult{}, nil
|
||||
}).After(preStopInspect)
|
||||
|
||||
// post_start exec, after the restart
|
||||
postStart := apiClient.EXPECT().ExecCreate(gomock.Any(), ctr.ID, gomock.Any()).
|
||||
Return(client.ExecCreateResult{ID: "exec-start"}, nil).After(restart)
|
||||
conn2s, conn2c := net.Pipe()
|
||||
_ = conn2s.Close()
|
||||
postStartAttach := apiClient.EXPECT().ExecAttach(gomock.Any(), "exec-start", gomock.Any()).
|
||||
Return(client.ExecAttachResult{HijackedResponse: client.NewHijackedResponse(conn2c, "")}, nil).
|
||||
After(postStart)
|
||||
apiClient.EXPECT().ExecInspect(gomock.Any(), "exec-start", gomock.Any()).
|
||||
Return(client.ExecInspectResult{ExitCode: 0}, nil).After(postStartAttach)
|
||||
|
||||
err := svc.restartContainer(t.Context(), service, ctr, api.RestartOptions{})
|
||||
assert.NilError(t, err)
|
||||
|
||||
assert.DeepEqual(t, rec.summary(), []string{
|
||||
"Container prj-web-1: Restarting",
|
||||
"Container prj-web-1: Started",
|
||||
})
|
||||
}
|
||||
327
pkg/compose/start_test.go
Normal file
327
pkg/compose/start_test.go
Normal file
|
|
@ -0,0 +1,327 @@
|
|||
//go:build !windows
|
||||
|
||||
/*
|
||||
Copyright 2020 Docker Compose CLI authors
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
package compose
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net"
|
||||
"strconv"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"github.com/compose-spec/compose-go/v2/types"
|
||||
"github.com/moby/moby/api/types/container"
|
||||
"github.com/moby/moby/client"
|
||||
"go.uber.org/mock/gomock"
|
||||
"gotest.tools/v3/assert"
|
||||
|
||||
"github.com/docker/compose/v5/pkg/api"
|
||||
"github.com/docker/compose/v5/pkg/mocks"
|
||||
)
|
||||
|
||||
// These tests characterize the imperative start path (startService,
|
||||
// startServiceContainer) before it converges into the plan engine (#14081):
|
||||
// which containers are started, in which order relative to file injection and
|
||||
// hooks, when pre_start runs, and which progress events are emitted.
|
||||
|
||||
// recordingEventProcessor captures progress events for sequence assertions.
|
||||
type recordingEventProcessor struct {
|
||||
mu sync.Mutex
|
||||
events []api.Resource
|
||||
}
|
||||
|
||||
func (r *recordingEventProcessor) Start(context.Context, string) {}
|
||||
|
||||
func (r *recordingEventProcessor) On(events ...api.Resource) {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
r.events = append(r.events, events...)
|
||||
}
|
||||
|
||||
func (r *recordingEventProcessor) Done(string, bool) {}
|
||||
|
||||
// summary renders recorded events as "ID: Text" strings.
|
||||
func (r *recordingEventProcessor) summary() []string {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
out := make([]string, 0, len(r.events))
|
||||
for _, e := range r.events {
|
||||
out = append(out, e.ID+": "+e.Text)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (r *recordingEventProcessor) contains(entry string) bool {
|
||||
for _, e := range r.summary() {
|
||||
if e == entry {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// newStartTestService builds a composeService on gomock with a recording
|
||||
// event processor. Any API call without a matching expectation fails the test,
|
||||
// so "no expectation" doubles as a "no daemon interaction" assertion.
|
||||
func newStartTestService(t *testing.T) (*composeService, *mocks.MockAPIClient, *recordingEventProcessor) {
|
||||
t.Helper()
|
||||
mockCtrl := gomock.NewController(t)
|
||||
t.Cleanup(mockCtrl.Finish)
|
||||
apiClient := mocks.NewMockAPIClient(mockCtrl)
|
||||
cli := mocks.NewMockCli(mockCtrl)
|
||||
cli.EXPECT().Client().Return(apiClient).AnyTimes()
|
||||
apiClient.EXPECT().Ping(gomock.Any(), client.PingOptions{NegotiateAPIVersion: true}).
|
||||
Return(client.PingResult{APIVersion: "1.44"}, nil).AnyTimes()
|
||||
apiClient.EXPECT().ClientVersion().Return("1.44").AnyTimes()
|
||||
|
||||
rec := &recordingEventProcessor{}
|
||||
svc, err := NewComposeService(cli, WithEventProcessor(rec))
|
||||
assert.NilError(t, err)
|
||||
return svc.(*composeService), apiClient, rec
|
||||
}
|
||||
|
||||
// runningContainer/stoppedContainer build container summaries the way the
|
||||
// daemon reports project containers: canonical name, service and
|
||||
// container-number labels.
|
||||
func serviceContainer(service string, num int, state container.ContainerState) container.Summary {
|
||||
name := "prj-" + service + "-" + strconv.Itoa(num)
|
||||
return container.Summary{
|
||||
ID: name + "-id",
|
||||
Names: []string{"/" + name},
|
||||
State: state,
|
||||
Labels: map[string]string{
|
||||
api.ServiceLabel: service,
|
||||
api.ContainerNumberLabel: strconv.Itoa(num),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func TestStartService_AlreadyRunningIsSilent(t *testing.T) {
|
||||
svc, _, rec := newStartTestService(t)
|
||||
|
||||
project := &types.Project{Name: "prj"}
|
||||
service := types.ServiceConfig{Name: "web"}
|
||||
containers := Containers{serviceContainer("web", 1, container.StateRunning)}
|
||||
|
||||
// No expectation registered: any ContainerStart (or other call) fails.
|
||||
err := svc.startService(t.Context(), project, service, containers, nil, 0)
|
||||
assert.NilError(t, err)
|
||||
assert.Equal(t, len(rec.summary()), 0)
|
||||
}
|
||||
|
||||
func TestStartService_ZeroReplicasIsNoop(t *testing.T) {
|
||||
svc, _, _ := newStartTestService(t)
|
||||
|
||||
zero := 0
|
||||
project := &types.Project{Name: "prj"}
|
||||
service := types.ServiceConfig{Name: "web", Deploy: &types.DeployConfig{Replicas: &zero}}
|
||||
|
||||
// Even with no containers at all, a zero-replicas service is not an error.
|
||||
err := svc.startService(t.Context(), project, service, nil, nil, 0)
|
||||
assert.NilError(t, err)
|
||||
}
|
||||
|
||||
func TestStartService_NoContainers(t *testing.T) {
|
||||
svc, _, _ := newStartTestService(t)
|
||||
project := &types.Project{Name: "prj"}
|
||||
|
||||
t.Run("scaled service is an error", func(t *testing.T) {
|
||||
service := types.ServiceConfig{Name: "web"}
|
||||
err := svc.startService(t.Context(), project, service, nil, nil, 0)
|
||||
assert.Error(t, err, `service "web" has no container to start`)
|
||||
})
|
||||
|
||||
t.Run("scale zero is a no-op", func(t *testing.T) {
|
||||
service := types.ServiceConfig{Name: "web", Scale: intPtr(0)}
|
||||
err := svc.startService(t.Context(), project, service, nil, nil, 0)
|
||||
assert.NilError(t, err)
|
||||
})
|
||||
}
|
||||
|
||||
// TestStartService_StartsOnlyStoppedReplicas locks three behaviors at once:
|
||||
// only non-running replicas of the target service are started, containers of
|
||||
// other services in the list are untouched, and pre_start does NOT run when at
|
||||
// least one replica is already running.
|
||||
func TestStartService_StartsOnlyStoppedReplicas(t *testing.T) {
|
||||
svc, apiClient, rec := newStartTestService(t)
|
||||
|
||||
project := &types.Project{Name: "prj"}
|
||||
service := types.ServiceConfig{
|
||||
Name: "web",
|
||||
PreStart: []types.ServiceHook{{Command: types.ShellCommand{"init"}}},
|
||||
}
|
||||
running := serviceContainer("web", 1, container.StateRunning)
|
||||
stopped := serviceContainer("web", 2, container.StateExited)
|
||||
other := serviceContainer("db", 1, container.StateExited)
|
||||
containers := Containers{running, stopped, other}
|
||||
|
||||
// Only the stopped web replica is started; no ContainerCreate expectation
|
||||
// means any pre_start hook execution fails the test.
|
||||
apiClient.EXPECT().ContainerStart(gomock.Any(), stopped.ID, gomock.Any()).
|
||||
Return(client.ContainerStartResult{}, nil)
|
||||
|
||||
err := svc.startService(t.Context(), project, service, containers, nil, 0)
|
||||
assert.NilError(t, err)
|
||||
|
||||
assert.DeepEqual(t, rec.summary(), []string{
|
||||
"Container prj-web-2: Starting",
|
||||
"Container prj-web-2: Started",
|
||||
})
|
||||
}
|
||||
|
||||
// TestStartService_PreStartOnLowestReplica locks the pre_start gating: with no
|
||||
// replica running, the hooks run exactly once, against the replica with the
|
||||
// lowest container-number — regardless of the order the daemon listed them in
|
||||
// — and before any service container is started.
|
||||
func TestStartService_PreStartOnLowestReplica(t *testing.T) {
|
||||
svc, apiClient, _ := newStartTestService(t)
|
||||
|
||||
project := &types.Project{Name: "prj"}
|
||||
service := types.ServiceConfig{
|
||||
Name: "web",
|
||||
Image: "alpine",
|
||||
PreStart: []types.ServiceHook{{Command: types.ShellCommand{"init"}}},
|
||||
}
|
||||
// Listed out of order on purpose: replica 2 first.
|
||||
replica2 := serviceContainer("web", 2, container.StateExited)
|
||||
replica1 := serviceContainer("web", 1, container.StateExited)
|
||||
containers := Containers{replica2, replica1}
|
||||
|
||||
// The hook container shares the volumes of the lowest-numbered replica.
|
||||
hookCreate := apiClient.EXPECT().ContainerCreate(gomock.Any(), gomock.Any()).
|
||||
DoAndReturn(func(_ context.Context, opts client.ContainerCreateOptions) (client.ContainerCreateResult, error) {
|
||||
assert.DeepEqual(t, opts.HostConfig.VolumesFrom, []string{replica1.ID})
|
||||
return client.ContainerCreateResult{ID: "hook-1"}, nil
|
||||
})
|
||||
hookWait := apiClient.EXPECT().ContainerWait(gomock.Any(), "hook-1", gomock.Any()).
|
||||
Return(waitResultExit(0)).After(hookCreate)
|
||||
hookStart := apiClient.EXPECT().ContainerStart(gomock.Any(), "hook-1", gomock.Any()).
|
||||
Return(client.ContainerStartResult{}, nil).After(hookWait)
|
||||
|
||||
// Replicas are then started sequentially, in list order, after the hook.
|
||||
start2 := apiClient.EXPECT().ContainerStart(gomock.Any(), replica2.ID, gomock.Any()).
|
||||
Return(client.ContainerStartResult{}, nil).After(hookStart)
|
||||
apiClient.EXPECT().ContainerStart(gomock.Any(), replica1.ID, gomock.Any()).
|
||||
Return(client.ContainerStartResult{}, nil).After(start2)
|
||||
|
||||
err := svc.startService(t.Context(), project, service, containers, nil, 0)
|
||||
assert.NilError(t, err)
|
||||
}
|
||||
|
||||
// TestStartServiceContainer_Order locks the per-container start sequence:
|
||||
// secret/config files are copied in before ContainerStart, post_start hooks
|
||||
// run after it, and the Started event is only emitted once the hooks are done.
|
||||
func TestStartServiceContainer_Order(t *testing.T) {
|
||||
svc, apiClient, rec := newStartTestService(t)
|
||||
|
||||
content := "s3cret"
|
||||
project := &types.Project{
|
||||
Name: "prj",
|
||||
Secrets: types.Secrets{
|
||||
"token": types.SecretConfig{Name: "token", Content: content},
|
||||
},
|
||||
}
|
||||
service := types.ServiceConfig{
|
||||
Name: "web",
|
||||
Secrets: []types.ServiceSecretConfig{{Source: "token"}},
|
||||
PostStart: []types.ServiceHook{{Command: types.ShellCommand{"notify"}}},
|
||||
}
|
||||
ctr := serviceContainer("web", 1, container.StateExited)
|
||||
|
||||
inject := apiClient.EXPECT().CopyToContainer(gomock.Any(), ctr.ID, gomock.Any()).
|
||||
Return(client.CopyToContainerResult{}, nil)
|
||||
start := apiClient.EXPECT().ContainerStart(gomock.Any(), ctr.ID, gomock.Any()).
|
||||
DoAndReturn(func(context.Context, string, client.ContainerStartOptions) (client.ContainerStartResult, error) {
|
||||
assert.Assert(t, rec.contains("Container prj-web-1: Starting"))
|
||||
assert.Assert(t, !rec.contains("Container prj-web-1: Started"))
|
||||
return client.ContainerStartResult{}, nil
|
||||
}).After(inject)
|
||||
|
||||
// post_start hook runs as an exec after the container started.
|
||||
execCreate := apiClient.EXPECT().ExecCreate(gomock.Any(), ctr.ID, gomock.Any()).
|
||||
Return(client.ExecCreateResult{ID: "exec-1"}, nil).After(start)
|
||||
serverConn, clientConn := net.Pipe()
|
||||
_ = serverConn.Close()
|
||||
execAttach := apiClient.EXPECT().ExecAttach(gomock.Any(), "exec-1", gomock.Any()).
|
||||
Return(client.ExecAttachResult{HijackedResponse: client.NewHijackedResponse(clientConn, "")}, nil).
|
||||
After(execCreate)
|
||||
apiClient.EXPECT().ExecInspect(gomock.Any(), "exec-1", gomock.Any()).
|
||||
DoAndReturn(func(context.Context, string, client.ExecInspectOptions) (client.ExecInspectResult, error) {
|
||||
// Started must not be emitted before post_start completed.
|
||||
assert.Assert(t, !rec.contains("Container prj-web-1: Started"))
|
||||
return client.ExecInspectResult{ExitCode: 0}, nil
|
||||
}).After(execAttach)
|
||||
|
||||
err := svc.startServiceContainer(t.Context(), project, service, ctr, nil)
|
||||
assert.NilError(t, err)
|
||||
|
||||
assert.DeepEqual(t, rec.summary(), []string{
|
||||
"Container prj-web-1: Starting",
|
||||
"Container prj-web-1: Started",
|
||||
})
|
||||
}
|
||||
|
||||
// TestStartServiceContainer_FailedPostStartBlocksStarted locks that a failing
|
||||
// post_start hook fails the start and leaves the Started event unemitted.
|
||||
func TestStartServiceContainer_FailedPostStart(t *testing.T) {
|
||||
svc, apiClient, rec := newStartTestService(t)
|
||||
|
||||
project := &types.Project{Name: "prj"}
|
||||
service := types.ServiceConfig{
|
||||
Name: "web",
|
||||
PostStart: []types.ServiceHook{{Command: types.ShellCommand{"fail"}}},
|
||||
}
|
||||
ctr := serviceContainer("web", 1, container.StateExited)
|
||||
|
||||
apiClient.EXPECT().ContainerStart(gomock.Any(), ctr.ID, gomock.Any()).
|
||||
Return(client.ContainerStartResult{}, nil)
|
||||
apiClient.EXPECT().ExecCreate(gomock.Any(), ctr.ID, gomock.Any()).
|
||||
Return(client.ExecCreateResult{ID: "exec-1"}, nil)
|
||||
serverConn, clientConn := net.Pipe()
|
||||
_ = serverConn.Close()
|
||||
apiClient.EXPECT().ExecAttach(gomock.Any(), "exec-1", gomock.Any()).
|
||||
Return(client.ExecAttachResult{HijackedResponse: client.NewHijackedResponse(clientConn, "")}, nil)
|
||||
apiClient.EXPECT().ExecInspect(gomock.Any(), "exec-1", gomock.Any()).
|
||||
Return(client.ExecInspectResult{ExitCode: 3}, nil)
|
||||
|
||||
err := svc.startServiceContainer(t.Context(), project, service, ctr, nil)
|
||||
assert.ErrorContains(t, err, "hook exited with status 3")
|
||||
assert.Assert(t, !rec.contains("Container prj-web-1: Started"))
|
||||
}
|
||||
|
||||
// TestGetDependencyCondition locks the --wait condition selection: a service
|
||||
// that others depend on with service_completed_successfully is waited on with
|
||||
// that condition (or --wait would hang on one-shot services), anything else
|
||||
// with running_or_healthy.
|
||||
func TestGetDependencyCondition(t *testing.T) {
|
||||
oneShot := types.ServiceConfig{Name: "migrate"}
|
||||
web := types.ServiceConfig{
|
||||
Name: "web",
|
||||
DependsOn: types.DependsOnConfig{
|
||||
"migrate": {Condition: types.ServiceConditionCompletedSuccessfully},
|
||||
},
|
||||
}
|
||||
project := &types.Project{
|
||||
Name: "prj",
|
||||
Services: types.Services{"web": web, "migrate": oneShot},
|
||||
}
|
||||
|
||||
assert.Equal(t, getDependencyCondition(oneShot, project), types.ServiceConditionCompletedSuccessfully)
|
||||
assert.Equal(t, getDependencyCondition(web, project), ServiceConditionRunningOrHealthy)
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue