diff --git a/pkg/compose/convergence.go b/pkg/compose/convergence.go index 0db3d1b104..177dc6c042 100644 --- a/pkg/compose/convergence.go +++ b/pkg/compose/convergence.go @@ -833,7 +833,8 @@ func (s *composeService) isServiceHealthy(ctx context.Context, containers Contai return false, fmt.Errorf("container %s exited (%d)", name, ctr.State.ExitCode) } - if ctr.Config.Healthcheck == nil && fallbackRunning { + noHealthcheck := ctr.Config.Healthcheck == nil || (len(ctr.Config.Healthcheck.Test) > 0 && ctr.Config.Healthcheck.Test[0] == "NONE") + if noHealthcheck && fallbackRunning { // Container does not define a health check, but we can fall back to "running" state return ctr.State != nil && ctr.State.Status == container.StateRunning, nil } diff --git a/pkg/compose/convergence_test.go b/pkg/compose/convergence_test.go index 637be02961..cad03d9d02 100644 --- a/pkg/compose/convergence_test.go +++ b/pkg/compose/convergence_test.go @@ -250,6 +250,148 @@ func TestWaitDependencies(t *testing.T) { }) } +func TestIsServiceHealthy(t *testing.T) { + mockCtrl := gomock.NewController(t) + defer mockCtrl.Finish() + + apiClient := mocks.NewMockAPIClient(mockCtrl) + cli := mocks.NewMockCli(mockCtrl) + tested, err := NewComposeService(cli) + assert.NilError(t, err) + cli.EXPECT().Client().Return(apiClient).AnyTimes() + + ctx := context.Background() + + t.Run("disabled healthcheck with fallback to running", func(t *testing.T) { + containerID := "test-container-id" + containers := Containers{ + {ID: containerID}, + } + + // Container with disabled healthcheck (Test: ["NONE"]) + apiClient.EXPECT().ContainerInspect(ctx, containerID).Return(container.InspectResponse{ + ContainerJSONBase: &container.ContainerJSONBase{ + ID: containerID, + Name: "test-container", + State: &container.State{Status: "running"}, + }, + Config: &container.Config{ + Healthcheck: &container.HealthConfig{ + Test: []string{"NONE"}, + }, + }, + }, nil) + + isHealthy, err := tested.(*composeService).isServiceHealthy(ctx, containers, true) + assert.NilError(t, err) + assert.Equal(t, true, isHealthy, "Container with disabled healthcheck should be considered healthy when running with fallbackRunning=true") + }) + + t.Run("disabled healthcheck without fallback", func(t *testing.T) { + containerID := "test-container-id" + containers := Containers{ + {ID: containerID}, + } + + // Container with disabled healthcheck (Test: ["NONE"]) but fallbackRunning=false + apiClient.EXPECT().ContainerInspect(ctx, containerID).Return(container.InspectResponse{ + ContainerJSONBase: &container.ContainerJSONBase{ + ID: containerID, + Name: "test-container", + State: &container.State{Status: "running"}, + }, + Config: &container.Config{ + Healthcheck: &container.HealthConfig{ + Test: []string{"NONE"}, + }, + }, + }, nil) + + _, err := tested.(*composeService).isServiceHealthy(ctx, containers, false) + assert.ErrorContains(t, err, "has no healthcheck configured") + }) + + t.Run("no healthcheck with fallback to running", func(t *testing.T) { + containerID := "test-container-id" + containers := Containers{ + {ID: containerID}, + } + + // Container with no healthcheck at all + apiClient.EXPECT().ContainerInspect(ctx, containerID).Return(container.InspectResponse{ + ContainerJSONBase: &container.ContainerJSONBase{ + ID: containerID, + Name: "test-container", + State: &container.State{Status: "running"}, + }, + Config: &container.Config{ + Healthcheck: nil, + }, + }, nil) + + isHealthy, err := tested.(*composeService).isServiceHealthy(ctx, containers, true) + assert.NilError(t, err) + assert.Equal(t, true, isHealthy, "Container with no healthcheck should be considered healthy when running with fallbackRunning=true") + }) + + t.Run("exited container with disabled healthcheck", func(t *testing.T) { + containerID := "test-container-id" + containers := Containers{ + {ID: containerID}, + } + + // Container with disabled healthcheck but exited + apiClient.EXPECT().ContainerInspect(ctx, containerID).Return(container.InspectResponse{ + ContainerJSONBase: &container.ContainerJSONBase{ + ID: containerID, + Name: "test-container", + State: &container.State{ + Status: "exited", + ExitCode: 1, + }, + }, + Config: &container.Config{ + Healthcheck: &container.HealthConfig{ + Test: []string{"NONE"}, + }, + }, + }, nil) + + _, err := tested.(*composeService).isServiceHealthy(ctx, containers, true) + assert.ErrorContains(t, err, "exited") + }) + + t.Run("healthy container with healthcheck", func(t *testing.T) { + containerID := "test-container-id" + containers := Containers{ + {ID: containerID}, + } + + // Container with actual healthcheck that is healthy + apiClient.EXPECT().ContainerInspect(ctx, containerID).Return(container.InspectResponse{ + ContainerJSONBase: &container.ContainerJSONBase{ + ID: containerID, + Name: "test-container", + State: &container.State{ + Status: "running", + Health: &container.Health{ + Status: container.Healthy, + }, + }, + }, + Config: &container.Config{ + Healthcheck: &container.HealthConfig{ + Test: []string{"CMD", "curl", "-f", "http://localhost"}, + }, + }, + }, nil) + + isHealthy, err := tested.(*composeService).isServiceHealthy(ctx, containers, false) + assert.NilError(t, err) + assert.Equal(t, true, isHealthy, "Container with healthy status should be healthy") + }) +} + func TestCreateMobyContainer(t *testing.T) { t.Run("connects container networks one by one if API <1.44", func(t *testing.T) { mockCtrl := gomock.NewController(t)