diff --git a/internal/pkg/docker/dockerengine/dockerengine.go b/internal/pkg/docker/dockerengine/dockerengine.go index 4e5f0268e91..227b73e5dfb 100644 --- a/internal/pkg/docker/dockerengine/dockerengine.go +++ b/internal/pkg/docker/dockerengine/dockerengine.go @@ -44,6 +44,20 @@ const ( credStoreECRLogin = "ecr-login" // set on `credStore` attribute in docker configuration file ) +// Health states of a Container. +const ( + noHealthcheck = "none" // Indicates there is no healthcheck + starting = "starting" // Starting indicates that the container is not yet ready + healthy = "healthy" // Healthy indicates that the container is running correctly + unhealthy = "unhealthy" // Unhealthy indicates that the container has a problem +) + +// State of a docker container. +const ( + containerStatusRunning = "running" + containerStatusExited = "exited" +) + // DockerCmdClient represents the docker client to interact with the server via external commands. type DockerCmdClient struct { runner Cmd @@ -339,13 +353,106 @@ func (c DockerCmdClient) Run(ctx context.Context, options *RunOptions) error { // IsContainerRunning checks if a specific Docker container is running. func (c DockerCmdClient) IsContainerRunning(ctx context.Context, name string) (bool, error) { + state, err := c.containerState(ctx, name) + if err != nil { + return false, err + } + switch state.Status { + case containerStatusRunning: + return true, nil + case containerStatusExited: + return false, &ErrContainerExited{name: name, exitcode: state.ExitCode} + } + return false, nil +} + +// IsContainerCompleteOrSuccess returns true if a docker container exits with an exitcode. +func (c DockerCmdClient) IsContainerCompleteOrSuccess(ctx context.Context, containerName string) (int, error) { + state, err := c.containerState(ctx, containerName) + if err != nil { + return 0, err + } + if state.Status == containerStatusRunning { + return -1, nil + } + return state.ExitCode, nil +} + +// IsContainerHealthy returns true if a container health state is healthy. +func (c DockerCmdClient) IsContainerHealthy(ctx context.Context, containerName string) (bool, error) { + state, err := c.containerState(ctx, containerName) + if err != nil { + return false, err + } + if state.Status != containerStatusRunning { + return false, fmt.Errorf("container %q is not in %q state", containerName, containerStatusRunning) + } + if state.Health == nil { + return false, fmt.Errorf("healthcheck is not configured for container %q", containerName) + } + switch state.Health.Status { + case healthy: + return true, nil + case starting: + return false, nil + case unhealthy: + return false, fmt.Errorf("container %q is %q", containerName, unhealthy) + case noHealthcheck: + return false, fmt.Errorf("healthcheck is not configured for container %q", containerName) + default: + return false, fmt.Errorf("container %q had unexpected health status %q", containerName, state.Health.Status) + } +} + +// ContainerState holds the status, exit code, and health information of a Docker container. +type ContainerState struct { + Status string `json:"Status"` + ExitCode int `json:"ExitCode"` + Health *struct { + Status string `json:"Status"` + } +} + +// containerState retrieves the current state of a specified Docker container. +// It returns a ContainerState object and any error encountered during retrieval. +func (d *DockerCmdClient) containerState(ctx context.Context, containerName string) (ContainerState, error) { + containerID, err := d.containerID(ctx, containerName) + if err != nil { + return ContainerState{}, err + } + if containerID == "" { + return ContainerState{}, nil + } buf := &bytes.Buffer{} - if err := c.runner.RunWithContext(ctx, "docker", []string{"ps", "-q", "--filter", "name=" + name}, exec.Stdout(buf)); err != nil { - return false, fmt.Errorf("run docker ps: %w", err) + if err := d.runner.RunWithContext(ctx, "docker", []string{"inspect", "--format", "{{json .State}}", containerID}, exec.Stdout(buf)); err != nil { + return ContainerState{}, fmt.Errorf("run docker inspect: %w", err) } + var containerState ContainerState + if err := json.Unmarshal([]byte(strings.TrimSpace(buf.String())), &containerState); err != nil { + return ContainerState{}, fmt.Errorf("unmarshal state of container %q:%w", containerName, err) + } + return containerState, nil +} + +// containerID gets the ID of a Docker container by its name. +func (d *DockerCmdClient) containerID(ctx context.Context, containerName string) (string, error) { + buf := &bytes.Buffer{} + if err := d.runner.RunWithContext(ctx, "docker", []string{"ps", "-a", "-q", "--filter", "name=" + containerName}, exec.Stdout(buf)); err != nil { + return "", fmt.Errorf("run docker ps: %w", err) + } + return strings.TrimSpace(buf.String()), nil +} + +// ErrContainerExited represents an error when a Docker container has exited. +// It includes the container name and exit code in the error message. +type ErrContainerExited struct { + name string + exitcode int +} - output := strings.TrimSpace(buf.String()) - return output != "", nil +// ErrContainerExited represents docker container exited with an exitcode. +func (e *ErrContainerExited) Error() string { + return fmt.Sprintf("container %q exited with code %d", e.name, e.exitcode) } // Stop calls `docker stop` to stop a running container. @@ -459,13 +566,13 @@ func PlatformString(os, arch string) string { func parseCredFromDockerConfig(config []byte) (*dockerConfig, error) { /* - Sample docker config file - { - "credsStore" : "ecr-login", - "credHelpers": { - "dummyaccountId.dkr.ecr.region.amazonaws.com": "ecr-login" - } - } + Sample docker config file + { + "credsStore" : "ecr-login", + "credHelpers": { + "dummyaccountId.dkr.ecr.region.amazonaws.com": "ecr-login" + } + } */ cred := dockerConfig{} err := json.Unmarshal(config, &cred) diff --git a/internal/pkg/docker/dockerengine/dockerengine_test.go b/internal/pkg/docker/dockerengine/dockerengine_test.go index ec01f8f6751..d8cd130f254 100644 --- a/internal/pkg/docker/dockerengine/dockerengine_test.go +++ b/internal/pkg/docker/dockerengine/dockerengine_test.go @@ -805,37 +805,81 @@ func TestDockerCommand_IsContainerRunning(t *testing.T) { mockError := errors.New("some error") mockContainerName := "mockContainer" mockUnknownContainerName := "mockUnknownContainer" - var mockCmd *MockCmd tests := map[string]struct { - setupMocks func(controller *gomock.Controller) + setupMocks func(controller *gomock.Controller) *MockCmd inContainerName string - - wantedErr error + wantRunning bool + wantedErr error }{ "error running docker info": { inContainerName: mockUnknownContainerName, - setupMocks: func(controller *gomock.Controller) { - mockCmd = NewMockCmd(controller) - mockCmd.EXPECT().RunWithContext(gomock.Any(), "docker", []string{"ps", "-q", "--filter", "name=mockUnknownContainer"}, gomock.Any()).Return(mockError) + setupMocks: func(controller *gomock.Controller) *MockCmd { + mockCmd := NewMockCmd(controller) + mockCmd.EXPECT().RunWithContext(gomock.Any(), "docker", []string{"ps", "-a", "-q", "--filter", "name=mockUnknownContainer"}, gomock.Any()).Return(mockError) + return mockCmd }, - wantedErr: fmt.Errorf("run docker ps: some error"), }, "successfully check if the container is running": { inContainerName: mockContainerName, - setupMocks: func(controller *gomock.Controller) { - mockCmd = NewMockCmd(controller) - mockCmd.EXPECT().RunWithContext(gomock.Any(), "docker", []string{"ps", "-q", "--filter", "name=mockContainer"}, gomock.Any()).Return(nil) + setupMocks: func(controller *gomock.Controller) *MockCmd { + mockCmd := NewMockCmd(controller) + mockCmd.EXPECT().RunWithContext(gomock.Any(), "docker", []string{"ps", "-a", "-q", "--filter", "name=mockContainer"}, gomock.Any()).DoAndReturn(func(ctx context.Context, name string, args []string, opts ...exec.CmdOption) error { + cmd := &osexec.Cmd{} + for _, opt := range opts { + opt(cmd) + } + cmd.Stdout.Write([]byte("53d6417769ed")) + return nil + }) + mockCmd.EXPECT().RunWithContext(gomock.Any(), "docker", []string{"inspect", "--format", "{{json .State}}", "53d6417769ed"}, gomock.Any()).DoAndReturn(func(ctx context.Context, name string, args []string, opts ...exec.CmdOption) error { + cmd := &osexec.Cmd{} + for _, opt := range opts { + opt(cmd) + } + cmd.Stdout.Write([]byte(` +{ + "Status": "running" +}`)) + return nil + }) + return mockCmd }, }, + "return that container is exited": { + inContainerName: mockContainerName, + setupMocks: func(controller *gomock.Controller) *MockCmd { + mockCmd := NewMockCmd(controller) + mockCmd.EXPECT().RunWithContext(gomock.Any(), "docker", []string{"ps", "-a", "-q", "--filter", "name=mockContainer"}, gomock.Any()).DoAndReturn(func(ctx context.Context, name string, args []string, opts ...exec.CmdOption) error { + cmd := &osexec.Cmd{} + for _, opt := range opts { + opt(cmd) + } + cmd.Stdout.Write([]byte("53d6417769ed")) + return nil + }) + mockCmd.EXPECT().RunWithContext(gomock.Any(), "docker", []string{"inspect", "--format", "{{json .State}}", "53d6417769ed"}, gomock.Any()).DoAndReturn(func(ctx context.Context, name string, args []string, opts ...exec.CmdOption) error { + cmd := &osexec.Cmd{} + for _, opt := range opts { + opt(cmd) + } + cmd.Stdout.Write([]byte(` +{ + "Status": "exited" +}`)) + return nil + }) + return mockCmd + }, + wantedErr: fmt.Errorf(`container "mockContainer" exited with code 0`), + }, } for name, tc := range tests { t.Run(name, func(t *testing.T) { controller := gomock.NewController(t) - tc.setupMocks(controller) s := DockerCmdClient{ - runner: mockCmd, + runner: tc.setupMocks(controller), } _, err := s.IsContainerRunning(context.Background(), tc.inContainerName) if tc.wantedErr != nil { @@ -890,3 +934,226 @@ func TestDockerCommand_Exec(t *testing.T) { }) } } + +func TestDockerCommand_IsContainerHealthy(t *testing.T) { + tests := map[string]struct { + mockContainerName string + mockHealthStatus string + setupMocks func(*gomock.Controller) *MockCmd + wantHealthy bool + wantErr error + }{ + "unhealthy container": { + mockContainerName: "mockContainer", + mockHealthStatus: "unhealthy", + setupMocks: func(controller *gomock.Controller) *MockCmd { + mockCmd := NewMockCmd(controller) + mockCmd.EXPECT().RunWithContext(gomock.Any(), "docker", []string{"ps", "-a", "-q", "--filter", "name=mockContainer"}, gomock.Any()).DoAndReturn(func(ctx context.Context, name string, args []string, opts ...exec.CmdOption) error { + cmd := &osexec.Cmd{} + for _, opt := range opts { + opt(cmd) + } + cmd.Stdout.Write([]byte("53d6417769ed")) + return nil + }) + mockCmd.EXPECT().RunWithContext(gomock.Any(), "docker", []string{"inspect", "--format", "{{json .State}}", "53d6417769ed"}, gomock.Any()).DoAndReturn(func(ctx context.Context, name string, args []string, opts ...exec.CmdOption) error { + cmd := &osexec.Cmd{} + for _, opt := range opts { + opt(cmd) + } + cmd.Stdout.Write([]byte(` +{ + "Status": "running", + "Running": true, + "Health": { + "Status": "unhealthy" + } +}`)) + return nil + }) + return mockCmd + }, + wantHealthy: false, + wantErr: fmt.Errorf(`container "mockContainer" is "unhealthy"`), + }, + + "healthy container": { + mockContainerName: "mockContainer", + mockHealthStatus: "unhealthy", + setupMocks: func(controller *gomock.Controller) *MockCmd { + mockCmd := NewMockCmd(controller) + mockCmd.EXPECT().RunWithContext(gomock.Any(), "docker", []string{"ps", "-a", "-q", "--filter", "name=mockContainer"}, gomock.Any()).DoAndReturn(func(ctx context.Context, name string, args []string, opts ...exec.CmdOption) error { + cmd := &osexec.Cmd{} + for _, opt := range opts { + opt(cmd) + } + cmd.Stdout.Write([]byte("53d6417769ed")) + return nil + }) + mockCmd.EXPECT().RunWithContext(gomock.Any(), "docker", []string{"inspect", "--format", "{{json .State}}", "53d6417769ed"}, gomock.Any()).DoAndReturn(func(ctx context.Context, name string, args []string, opts ...exec.CmdOption) error { + cmd := &osexec.Cmd{} + for _, opt := range opts { + opt(cmd) + } + cmd.Stdout.Write([]byte(` +{ + "Status": "running", + "Running": true, + "Health": { + "Status": "healthy" + } +}`)) + return nil + }) + return mockCmd + }, + wantHealthy: true, + wantErr: nil, + }, + } + + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + s := DockerCmdClient{ + runner: tc.setupMocks(ctrl), // Correctly invoke the setupMocks function + } + + expected, err := s.IsContainerHealthy(context.Background(), tc.mockContainerName) + require.Equal(t, tc.wantHealthy, expected) + if tc.wantErr != nil { + require.EqualError(t, err, tc.wantErr.Error()) + } else { + require.NoError(t, err) + } + }) + } +} + +func TestDockerCommand_IsContainerCompleteOrSuccess(t *testing.T) { + tests := map[string]struct { + mockContainerName string + mockHealthStatus string + setupMocks func(*gomock.Controller) *MockCmd + wantExitCode int + wantErr error + }{ + "container successfully complete": { + mockContainerName: "mockContainer", + mockHealthStatus: "unhealthy", + setupMocks: func(controller *gomock.Controller) *MockCmd { + mockCmd := NewMockCmd(controller) + mockCmd.EXPECT().RunWithContext(gomock.Any(), "docker", []string{"ps", "-a", "-q", "--filter", "name=mockContainer"}, gomock.Any()).DoAndReturn(func(ctx context.Context, name string, args []string, opts ...exec.CmdOption) error { + cmd := &osexec.Cmd{} + for _, opt := range opts { + opt(cmd) + } + cmd.Stdout.Write([]byte("53d6417769ed")) + return nil + }) + mockCmd.EXPECT().RunWithContext(gomock.Any(), "docker", []string{"inspect", "--format", "{{json .State}}", "53d6417769ed"}, gomock.Any()).DoAndReturn(func(ctx context.Context, name string, args []string, opts ...exec.CmdOption) error { + cmd := &osexec.Cmd{} + for _, opt := range opts { + opt(cmd) + } + cmd.Stdout.Write([]byte(` +{ + "Status": "exited", + "ExitCode": 143 +}`)) + return nil + }) + return mockCmd + }, + wantExitCode: 143, + }, + "container success": { + mockContainerName: "mockContainer", + mockHealthStatus: "unhealthy", + setupMocks: func(controller *gomock.Controller) *MockCmd { + mockCmd := NewMockCmd(controller) + mockCmd.EXPECT().RunWithContext(gomock.Any(), "docker", []string{"ps", "-a", "-q", "--filter", "name=mockContainer"}, gomock.Any()).DoAndReturn(func(ctx context.Context, name string, args []string, opts ...exec.CmdOption) error { + cmd := &osexec.Cmd{} + for _, opt := range opts { + opt(cmd) + } + cmd.Stdout.Write([]byte("53d6417769ed")) + return nil + }) + mockCmd.EXPECT().RunWithContext(gomock.Any(), "docker", []string{"inspect", "--format", "{{json .State}}", "53d6417769ed"}, gomock.Any()).DoAndReturn(func(ctx context.Context, name string, args []string, opts ...exec.CmdOption) error { + cmd := &osexec.Cmd{} + for _, opt := range opts { + opt(cmd) + } + cmd.Stdout.Write([]byte(` +{ + "Status": "exited", + "ExitCode": 0 +}`)) + return nil + }) + return mockCmd + }, + }, + "error when fetching container state": { + mockContainerName: "mockContainer", + mockHealthStatus: "unhealthy", + setupMocks: func(controller *gomock.Controller) *MockCmd { + mockCmd := NewMockCmd(controller) + mockCmd.EXPECT().RunWithContext(gomock.Any(), "docker", []string{"ps", "-a", "-q", "--filter", "name=mockContainer"}, gomock.Any()).Return(fmt.Errorf("some error")) + return mockCmd + }, + wantErr: fmt.Errorf("run docker ps: some error"), + }, + "return negative exitcode if container is running": { + mockContainerName: "mockContainer", + mockHealthStatus: "unhealthy", + setupMocks: func(controller *gomock.Controller) *MockCmd { + mockCmd := NewMockCmd(controller) + mockCmd.EXPECT().RunWithContext(gomock.Any(), "docker", []string{"ps", "-a", "-q", "--filter", "name=mockContainer"}, gomock.Any()).DoAndReturn(func(ctx context.Context, name string, args []string, opts ...exec.CmdOption) error { + cmd := &osexec.Cmd{} + for _, opt := range opts { + opt(cmd) + } + cmd.Stdout.Write([]byte("53d6417769ed")) + return nil + }) + mockCmd.EXPECT().RunWithContext(gomock.Any(), "docker", []string{"inspect", "--format", "{{json .State}}", "53d6417769ed"}, gomock.Any()).DoAndReturn(func(ctx context.Context, name string, args []string, opts ...exec.CmdOption) error { + cmd := &osexec.Cmd{} + for _, opt := range opts { + opt(cmd) + } + cmd.Stdout.Write([]byte(` +{ + "Status": "running", + "ExitCode": 0 +}`)) + return nil + }) + return mockCmd + }, + wantExitCode: -1, + }, + } + + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + s := DockerCmdClient{ + runner: tc.setupMocks(ctrl), + } + + expectedCode, err := s.IsContainerCompleteOrSuccess(context.Background(), tc.mockContainerName) + require.Equal(t, tc.wantExitCode, expectedCode) + if tc.wantErr != nil { + require.EqualError(t, err, tc.wantErr.Error()) + } else { + require.NoError(t, err) + } + }) + } +}