diff --git a/pkg/compose/convergence.go b/pkg/compose/convergence.go index b480b6be9d..425fc8c798 100644 --- a/pkg/compose/convergence.go +++ b/pkg/compose/convergence.go @@ -482,6 +482,14 @@ func (s *composeService) waitDependencies(ctx context.Context, project *types.Pr case ServiceConditionRunningOrHealthy: isHealthy, err := s.isServiceHealthy(ctx, waitingFor, true) if err != nil { + // A container that exited with code 0 can be treated as + // successfully completed (init/oneshot containers) when the + // service won't be restarted by Docker after a clean exit. + var exitErr *containerExitError + if errors.As(err, &exitErr) && exitErr.exitCode == 0 && !restartsOnExit0(project, dep) { + s.events.On(containerEvents(waitingFor, exited)...) + return nil + } if !config.Required { s.events.On(containerReasonEvents(waitingFor, skippedEvent, fmt.Sprintf("optional dependency %q is not running or is unhealthy", dep))...) @@ -552,6 +560,29 @@ func (s *composeService) waitDependencies(ctx context.Context, project *types.Pr return err } +// restartsOnExit0 reports whether Docker will restart the named service after +// it exits with code 0. Only restart policies "always" and "unless-stopped" +// cause a restart on clean exit; "no", "on-failure", and unset do not. +// For deploy.restart_policy.condition, "any" (and the non-spec but accepted +// "always" and "unless-stopped") cause a restart on clean exit. +func restartsOnExit0(project *types.Project, serviceName string) bool { + service, err := project.GetService(serviceName) + if err != nil { + return false + } + switch service.Restart { + case types.RestartPolicyAlways, types.RestartPolicyUnlessStopped: + return true + } + if service.Deploy != nil && service.Deploy.RestartPolicy != nil { + switch service.Deploy.RestartPolicy.Condition { + case "any", "always", "unless-stopped": + return true + } + } + return false +} + func shouldWaitForDependency(serviceName string, dependencyConfig types.ServiceDependency, project *types.Project) (bool, error) { if dependencyConfig.Condition == types.ServiceConditionStarted { // already managed by InDependencyOrder @@ -803,6 +834,18 @@ func (s *composeService) getLinks(ctx context.Context, projectName string, servi return links, nil } +// containerExitError is returned by isServiceHealthy when a container has exited. +// It carries the exit code so callers can distinguish clean exits (code 0) +// from failures without parsing error strings. +type containerExitError struct { + name string + exitCode int +} + +func (e *containerExitError) Error() string { + return fmt.Sprintf("container %s exited (%d)", e.name, e.exitCode) +} + func (s *composeService) isServiceHealthy(ctx context.Context, containers Containers, fallbackRunning bool) (bool, error) { for _, c := range containers { res, err := s.apiClient().ContainerInspect(ctx, c.ID, client.ContainerInspectOptions{}) @@ -813,7 +856,7 @@ func (s *composeService) isServiceHealthy(ctx context.Context, containers Contai name := ctr.Name[1:] if ctr.State.Status == container.StateExited { - return false, fmt.Errorf("container %s exited (%d)", name, ctr.State.ExitCode) + return false, &containerExitError{name: name, exitCode: ctr.State.ExitCode} } noHealthcheck := ctr.Config.Healthcheck == nil || (len(ctr.Config.Healthcheck.Test) > 0 && ctr.Config.Healthcheck.Test[0] == "NONE") diff --git a/pkg/compose/convergence_test.go b/pkg/compose/convergence_test.go index 901f43ccea..8855dac320 100644 --- a/pkg/compose/convergence_test.go +++ b/pkg/compose/convergence_test.go @@ -18,6 +18,7 @@ package compose import ( "context" + "errors" "fmt" "net/netip" "strings" @@ -366,6 +367,34 @@ func TestIsServiceHealthy(t *testing.T) { _, err := tested.(*composeService).isServiceHealthy(ctx, containers, true) assert.ErrorContains(t, err, "exited") + var exitErr *containerExitError + assert.Assert(t, errors.As(err, &exitErr)) + assert.Equal(t, exitErr.exitCode, 1) + }) + + t.Run("exited container with exit code 0 returns containerExitError", func(t *testing.T) { + containerID := "test-container-id" + containers := Containers{ + {ID: containerID}, + } + + apiClient.EXPECT().ContainerInspect(ctx, containerID, gomock.Any()).Return(client.ContainerInspectResult{ + Container: container.InspectResponse{ + ID: containerID, + Name: "test-container", + State: &container.State{ + Status: "exited", + ExitCode: 0, + }, + Config: &container.Config{}, + }, + }, nil) + + _, err := tested.(*composeService).isServiceHealthy(ctx, containers, true) + assert.Assert(t, err != nil, "expected error for exited container") + var exitErr *containerExitError + assert.Assert(t, errors.As(err, &exitErr)) + assert.Equal(t, exitErr.exitCode, 0) }) t.Run("healthy container with healthcheck", func(t *testing.T) { diff --git a/pkg/compose/start_test.go b/pkg/compose/start_test.go new file mode 100644 index 0000000000..3120d13db4 --- /dev/null +++ b/pkg/compose/start_test.go @@ -0,0 +1,109 @@ +/* + 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 ( + "testing" + + "github.com/compose-spec/compose-go/v2/types" + "gotest.tools/v3/assert" +) + +func TestRestartsOnExit0(t *testing.T) { + tests := []struct { + name string + service types.ServiceConfig + expected bool + }{ + { + name: "no restart policy", + service: types.ServiceConfig{Name: "init"}, + expected: false, + }, + { + name: "restart no", + service: types.ServiceConfig{Name: "init", Restart: types.RestartPolicyNo}, + expected: false, + }, + { + name: "restart on-failure", + service: types.ServiceConfig{Name: "init", Restart: types.RestartPolicyOnFailure}, + expected: false, + }, + { + name: "restart always", + service: types.ServiceConfig{Name: "web", Restart: types.RestartPolicyAlways}, + expected: true, + }, + { + name: "restart unless-stopped", + service: types.ServiceConfig{Name: "web", Restart: types.RestartPolicyUnlessStopped}, + expected: true, + }, + { + name: "deploy restart policy condition any", + service: types.ServiceConfig{ + Name: "web", + Deploy: &types.DeployConfig{ + RestartPolicy: &types.RestartPolicy{Condition: "any"}, + }, + }, + expected: true, + }, + { + name: "deploy restart policy condition on-failure", + service: types.ServiceConfig{ + Name: "web", + Deploy: &types.DeployConfig{ + RestartPolicy: &types.RestartPolicy{Condition: "on-failure"}, + }, + }, + expected: false, + }, + { + name: "deploy restart policy condition none", + service: types.ServiceConfig{ + Name: "web", + Deploy: &types.DeployConfig{ + RestartPolicy: &types.RestartPolicy{Condition: "none"}, + }, + }, + expected: false, + }, + { + name: "deploy restart policy condition unless-stopped", + service: types.ServiceConfig{ + Name: "web", + Deploy: &types.DeployConfig{ + RestartPolicy: &types.RestartPolicy{Condition: "unless-stopped"}, + }, + }, + expected: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + project := &types.Project{ + Services: types.Services{ + tt.service.Name: tt.service, + }, + } + assert.Equal(t, restartsOnExit0(project, tt.service.Name), tt.expected) + }) + } +}