diff --git a/cmd/run.go b/cmd/run.go index be90fe2e..fc059a7c 100644 --- a/cmd/run.go +++ b/cmd/run.go @@ -84,7 +84,7 @@ var runCmd = &cobra.Command{ log.Fatal().Err(err).Msg("Cannot initialize docker client") } - containerRunner := devcontainer.NewDockerRunner(util.NewExecutorImpl(), devcontainer.NewImageManager(dockerClient, random.String, devcontainer.NewDockerHubRegistryCredentials(dockerUser, dockerToken)), devcontainer.NewDockerContainerManager(dockerClient)) + containerRunner := devcontainer.NewDockerRunner(devcontainer.NewExecutorImpl(), devcontainer.NewImageManager(dockerClient, random.String, devcontainer.NewDockerHubRegistryCredentials(dockerUser, dockerToken)), devcontainer.NewDockerContainerManager(dockerClient)) projectStore := project.NewInMemoryStore(make(map[string]*model.Project)) home, err := os.UserHomeDir() if err != nil { diff --git a/pkg/util/commands.go b/pkg/devcontainer/executor.go similarity index 90% rename from pkg/util/commands.go rename to pkg/devcontainer/executor.go index 7edb0352..263de7b8 100644 --- a/pkg/util/commands.go +++ b/pkg/devcontainer/executor.go @@ -1,4 +1,4 @@ -package util +package devcontainer import ( "fmt" @@ -44,8 +44,8 @@ func (e *ExecutorImpl) Run(command []string, dir string, stdout, stderr io.Write // Stream the command output // TODO: should we use logger instead? - go ReadOutput(cmdStdout, stdout) - go ReadOutput(cmdStderr, stderr) + go readOutput(cmdStdout, stdout) + go readOutput(cmdStderr, stderr) // Wait for the command to complete // TODO: do async wait @@ -57,7 +57,7 @@ func (e *ExecutorImpl) Run(command []string, dir string, stdout, stderr io.Write return nil } -func ReadOutput(src io.Reader, dst io.Writer) error { +func readOutput(src io.Reader, dst io.Writer) error { if _, err := io.Copy(dst, src); err != nil { return err } diff --git a/pkg/devcontainer/mocks/mock_container_manager.go b/pkg/devcontainer/mocks/mock_container_manager.go new file mode 100644 index 00000000..03027260 --- /dev/null +++ b/pkg/devcontainer/mocks/mock_container_manager.go @@ -0,0 +1,35 @@ +package mocks + +import ( + "context" + + "github.com/stretchr/testify/mock" + + "github.com/artmoskvin/hide/pkg/devcontainer" +) + +var _ devcontainer.ContainerManager = (*MockContainerManager)(nil) + +type MockContainerManager struct { + mock.Mock +} + +func (m *MockContainerManager) CreateContainer(ctx context.Context, image string, projectPath string, config devcontainer.Config) (string, error) { + args := m.Called(ctx, image, projectPath, config) + return args.String(0), args.Error(1) +} + +func (m *MockContainerManager) StartContainer(ctx context.Context, containerId string) error { + args := m.Called(ctx, containerId) + return args.Error(0) +} + +func (m *MockContainerManager) StopContainer(ctx context.Context, containerId string) error { + args := m.Called(ctx, containerId) + return args.Error(0) +} + +func (m *MockContainerManager) Exec(ctx context.Context, containerId string, command []string) (devcontainer.ExecResult, error) { + args := m.Called(ctx, containerId, command) + return args.Get(0).(devcontainer.ExecResult), args.Error(1) +} diff --git a/pkg/devcontainer/mocks/mock_executor.go b/pkg/devcontainer/mocks/mock_executor.go index 04e13697..9a446466 100644 --- a/pkg/devcontainer/mocks/mock_executor.go +++ b/pkg/devcontainer/mocks/mock_executor.go @@ -1,12 +1,20 @@ package mocks -import "io" +import ( + "io" + + "github.com/stretchr/testify/mock" + + "github.com/artmoskvin/hide/pkg/devcontainer" +) + +var _ devcontainer.Executor = (*MockExecutor)(nil) -// MockExecutor is a mock of the util.Executor interface for testing type MockExecutor struct { - RunFunc func(command []string, dir string, stdout, stderr io.Writer) error + mock.Mock } func (m *MockExecutor) Run(command []string, dir string, stdout, stderr io.Writer) error { - return m.RunFunc(command, dir, stdout, stderr) + args := m.Called(command, dir, stdout, stderr) + return args.Error(0) } diff --git a/pkg/devcontainer/mocks/mock_image_manager.go b/pkg/devcontainer/mocks/mock_image_manager.go new file mode 100644 index 00000000..66349bda --- /dev/null +++ b/pkg/devcontainer/mocks/mock_image_manager.go @@ -0,0 +1,25 @@ +package mocks + +import ( + "context" + + "github.com/stretchr/testify/mock" + + "github.com/artmoskvin/hide/pkg/devcontainer" +) + +var _ devcontainer.ImageManager = (*MockImageManager)(nil) + +type MockImageManager struct { + mock.Mock +} + +func (m *MockImageManager) PullImage(ctx context.Context, name string) error { + args := m.Called(ctx, name) + return args.Error(0) +} + +func (m *MockImageManager) BuildImage(ctx context.Context, workingDir string, config devcontainer.Config) (string, error) { + args := m.Called(ctx, workingDir, config) + return args.String(0), args.Error(1) +} diff --git a/pkg/devcontainer/runner.go b/pkg/devcontainer/runner.go index 2a8faa35..d1cc0ea7 100644 --- a/pkg/devcontainer/runner.go +++ b/pkg/devcontainer/runner.go @@ -5,7 +5,6 @@ import ( "fmt" "os" - "github.com/artmoskvin/hide/pkg/util" "github.com/rs/zerolog/log" ) @@ -22,12 +21,12 @@ type Runner interface { } type DockerRunner struct { - commandExecutor util.Executor + commandExecutor Executor imageManager ImageManager containerManager ContainerManager } -func NewDockerRunner(commandExecutor util.Executor, imageManager ImageManager, containerManager ContainerManager) Runner { +func NewDockerRunner(commandExecutor Executor, imageManager ImageManager, containerManager ContainerManager) Runner { return &DockerRunner{ commandExecutor: commandExecutor, imageManager: imageManager, @@ -40,7 +39,7 @@ func (r *DockerRunner) Run(ctx context.Context, projectPath string, config Confi // Run initialize commands if command := config.LifecycleProps.InitializeCommand; command != nil { if err := r.executeLifecycleCommand(command, projectPath); err != nil { - return "", fmt.Errorf("Failed to run initialize command %s: %w", command, err) + return "", fmt.Errorf("Failed to run initialize commands: %w", err) } } @@ -86,35 +85,35 @@ func (r *DockerRunner) Run(ctx context.Context, projectPath string, config Confi // Run onCreate commands if command := config.LifecycleProps.OnCreateCommand; command != nil { if err := r.executeLifecycleCommandInContainer(ctx, command, containerId); err != nil { - return "", fmt.Errorf("Failed to run onCreate command %s: %w", command, err) + return "", fmt.Errorf("Failed to run onCreate commands: %w", err) } } // Run updateContent commands if command := config.LifecycleProps.UpdateContentCommand; command != nil { if err := r.executeLifecycleCommandInContainer(ctx, command, containerId); err != nil { - return "", fmt.Errorf("Failed to run updateContent command %s: %w", command, err) + return "", fmt.Errorf("Failed to run updateContent commands: %w", err) } } // Run postCreate commands if command := config.LifecycleProps.PostCreateCommand; command != nil { if err := r.executeLifecycleCommandInContainer(ctx, command, containerId); err != nil { - return "", fmt.Errorf("Failed to run postCreate command %s: %w", command, err) + return "", fmt.Errorf("Failed to run postCreate commands: %w", err) } } // Run postStart commands if command := config.LifecycleProps.PostStartCommand; command != nil { if err := r.executeLifecycleCommand(command, projectPath); err != nil { - return "", fmt.Errorf("Failed to run postStart command %s: %w", command, err) + return "", fmt.Errorf("Failed to run postStart commands: %w", err) } } // Run postAttach commands if command := config.LifecycleProps.PostAttachCommand; command != nil { if err := r.executeLifecycleCommand(command, projectPath); err != nil { - return "", fmt.Errorf("Failed to run postAttach command %s: %w", command, err) + return "", fmt.Errorf("Failed to run postAttach commands: %w", err) } } @@ -130,11 +129,11 @@ func (r *DockerRunner) Exec(ctx context.Context, containerID string, command []s } func (r *DockerRunner) executeLifecycleCommand(lifecycleCommand LifecycleCommand, workingDir string) error { - for _, command := range lifecycleCommand { - log.Debug().Str("command", fmt.Sprintf("%s", command)).Msg("Running command") + for name, command := range lifecycleCommand { + log.Debug().Str("name", name).Str("command", fmt.Sprintf("%s", command)).Msg("Running command") if err := r.commandExecutor.Run(command, workingDir, os.Stdout, os.Stderr); err != nil { - return err + return fmt.Errorf("Failed to run command %s %s: %w", name, command, err) } } @@ -142,17 +141,17 @@ func (r *DockerRunner) executeLifecycleCommand(lifecycleCommand LifecycleCommand } func (r *DockerRunner) executeLifecycleCommandInContainer(ctx context.Context, lifecycleCommand LifecycleCommand, containerId string) error { - for _, command := range lifecycleCommand { - log.Debug().Str("command", fmt.Sprintf("%s", command)).Msg("Running command") + for name, command := range lifecycleCommand { + log.Debug().Str("name", name).Str("command", fmt.Sprintf("%s", command)).Msg("Running command") result, err := r.Exec(ctx, containerId, command) if err != nil { - return err + return fmt.Errorf("Failed to run command %s %s in container %s: %w", name, command, containerId, err) } if result.ExitCode != 0 { - return fmt.Errorf("Exit code %d. Stdout: %s, Stderr: %s", result.ExitCode, result.StdOut, result.StdErr) + return fmt.Errorf("Failed to run command %s %s in container %s: Exit code %d. Stdout: %s, Stderr: %s", name, command, containerId, result.ExitCode, result.StdOut, result.StdErr) } } diff --git a/pkg/devcontainer/runner_test.go b/pkg/devcontainer/runner_test.go new file mode 100644 index 00000000..042c9cac --- /dev/null +++ b/pkg/devcontainer/runner_test.go @@ -0,0 +1,299 @@ +package devcontainer_test + +import ( + "context" + "errors" + "testing" + + "github.com/artmoskvin/hide/pkg/devcontainer" + "github.com/artmoskvin/hide/pkg/devcontainer/mocks" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" +) + +func TestDockerRunner_Run(t *testing.T) { + tests := []struct { + name string + config devcontainer.Config + setupMocks func(*mocks.MockExecutor, *mocks.MockImageManager, *mocks.MockContainerManager) + wantResult string + wantError string + }{ + { + name: "Successful run with image", + config: devcontainer.Config{ + DockerImageProps: devcontainer.DockerImageProps{ + Image: "test-image", + }, + }, + setupMocks: func(me *mocks.MockExecutor, mim *mocks.MockImageManager, mcm *mocks.MockContainerManager) { + mim.On("PullImage", mock.Anything, "test-image").Return(nil) + mcm.On("CreateContainer", mock.Anything, "test-image", mock.Anything, mock.Anything).Return("container-id", nil) + mcm.On("StartContainer", mock.Anything, "container-id").Return(nil) + }, + wantResult: "container-id", + }, + { + name: "Successful run with Dockerfile", + config: devcontainer.Config{ + DockerImageProps: devcontainer.DockerImageProps{ + Dockerfile: "Dockerfile", + }, + }, + setupMocks: func(me *mocks.MockExecutor, mim *mocks.MockImageManager, mcm *mocks.MockContainerManager) { + mim.On("BuildImage", mock.Anything, mock.Anything, mock.Anything).Return("built-image-id", nil) + mcm.On("CreateContainer", mock.Anything, "built-image-id", mock.Anything, mock.Anything).Return("container-id", nil) + mcm.On("StartContainer", mock.Anything, "container-id").Return(nil) + }, + wantResult: "container-id", + }, + { + name: "Failed with docker compose", + config: devcontainer.Config{ + DockerComposeProps: devcontainer.DockerComposeProps{ + DockerComposeFile: []string{"docker-compose.yml"}, + Service: "test-service", + }, + }, + setupMocks: func(me *mocks.MockExecutor, mim *mocks.MockImageManager, mcm *mocks.MockContainerManager) {}, + wantError: "Docker Compose is not supported yet", + }, + { + name: "Failed with invalid devcontainer", + config: devcontainer.Config{}, + setupMocks: func(me *mocks.MockExecutor, mim *mocks.MockImageManager, mcm *mocks.MockContainerManager) {}, + wantError: "Invalid devcontainer configuration", + }, + { + name: "Failed image pull", + config: devcontainer.Config{ + DockerImageProps: devcontainer.DockerImageProps{ + Image: "test-image", + }, + }, + setupMocks: func(me *mocks.MockExecutor, mim *mocks.MockImageManager, mcm *mocks.MockContainerManager) { + mim.On("PullImage", mock.Anything, "test-image").Return(errors.New("pull error")) + }, + wantError: "Failed to pull or build image: Failed to pull image: pull error", + }, + { + name: "Failed image build", + config: devcontainer.Config{ + DockerImageProps: devcontainer.DockerImageProps{ + Dockerfile: "Dockerfile", + }, + }, + setupMocks: func(me *mocks.MockExecutor, mim *mocks.MockImageManager, mcm *mocks.MockContainerManager) { + mim.On("BuildImage", mock.Anything, mock.Anything, mock.Anything).Return("", errors.New("build error")) + }, + wantError: "Failed to pull or build image: Failed to build image: build error", + }, + { + name: "Failed container creation", + config: devcontainer.Config{ + DockerImageProps: devcontainer.DockerImageProps{ + Image: "test-image", + }, + }, + setupMocks: func(me *mocks.MockExecutor, mim *mocks.MockImageManager, mcm *mocks.MockContainerManager) { + mim.On("PullImage", mock.Anything, "test-image").Return(nil) + mcm.On("CreateContainer", mock.Anything, "test-image", mock.Anything, mock.Anything).Return("", errors.New("create error")) + }, + wantError: "Failed to create container: create error", + }, + { + name: "Failed container start", + config: devcontainer.Config{ + DockerImageProps: devcontainer.DockerImageProps{ + Image: "test-image", + }, + }, + setupMocks: func(me *mocks.MockExecutor, mim *mocks.MockImageManager, mcm *mocks.MockContainerManager) { + mim.On("PullImage", mock.Anything, "test-image").Return(nil) + mcm.On("CreateContainer", mock.Anything, "test-image", mock.Anything, mock.Anything).Return("container-id", nil) + mcm.On("StartContainer", mock.Anything, "container-id").Return(errors.New("start error")) + }, + wantError: "Failed to start container: start error", + }, + { + name: "Successful run with lifecycle commands", + config: devcontainer.Config{ + DockerImageProps: devcontainer.DockerImageProps{ + Image: "test-image", + }, + LifecycleProps: devcontainer.LifecycleProps{ + InitializeCommand: devcontainer.LifecycleCommand{"command": []string{"initialize"}}, + OnCreateCommand: devcontainer.LifecycleCommand{"command": []string{"onCreate"}}, + UpdateContentCommand: devcontainer.LifecycleCommand{"command": []string{"updateContent"}}, + PostCreateCommand: devcontainer.LifecycleCommand{"command": []string{"postCreate"}}, + PostStartCommand: devcontainer.LifecycleCommand{"command": []string{"postStart"}}, + PostAttachCommand: devcontainer.LifecycleCommand{"command": []string{"postAttach"}}, + }, + }, + setupMocks: func(me *mocks.MockExecutor, mim *mocks.MockImageManager, mcm *mocks.MockContainerManager) { + mim.On("PullImage", mock.Anything, "test-image").Return(nil) + mcm.On("CreateContainer", mock.Anything, "test-image", mock.Anything, mock.Anything).Return("container-id", nil) + mcm.On("StartContainer", mock.Anything, "container-id").Return(nil) + me.On("Run", []string{"initialize"}, mock.Anything, mock.Anything, mock.Anything).Return(nil) + mcm.On("Exec", mock.Anything, "container-id", []string{"onCreate"}).Return(devcontainer.ExecResult{ExitCode: 0}, nil) + mcm.On("Exec", mock.Anything, "container-id", []string{"updateContent"}).Return(devcontainer.ExecResult{ExitCode: 0}, nil) + mcm.On("Exec", mock.Anything, "container-id", []string{"postCreate"}).Return(devcontainer.ExecResult{ExitCode: 0}, nil) + me.On("Run", []string{"postStart"}, mock.Anything, mock.Anything, mock.Anything).Return(nil) + me.On("Run", []string{"postAttach"}, mock.Anything, mock.Anything, mock.Anything).Return(nil) + }, + wantResult: "container-id", + }, + { + name: "Failed lifecycle command", + config: devcontainer.Config{ + DockerImageProps: devcontainer.DockerImageProps{ + Image: "test-image", + }, + LifecycleProps: devcontainer.LifecycleProps{ + OnCreateCommand: devcontainer.LifecycleCommand{"test": []string{"test", "command"}}, + }, + }, + setupMocks: func(me *mocks.MockExecutor, mim *mocks.MockImageManager, mcm *mocks.MockContainerManager) { + mim.On("PullImage", mock.Anything, "test-image").Return(nil) + mcm.On("CreateContainer", mock.Anything, "test-image", mock.Anything, mock.Anything).Return("container-id", nil) + mcm.On("StartContainer", mock.Anything, "container-id").Return(nil) + mcm.On("Exec", mock.Anything, "container-id", []string{"test", "command"}).Return(devcontainer.ExecResult{ExitCode: 1, StdErr: "command failed"}, nil) + }, + wantError: "Failed to run command test [test command] in container container-id", + }, + } + + for _, tt := range tests { + mockExecutor := &mocks.MockExecutor{} + mockImageManager := &mocks.MockImageManager{} + mockContainerManager := &mocks.MockContainerManager{} + + t.Run(tt.name, func(t *testing.T) { + tt.setupMocks(mockExecutor, mockImageManager, mockContainerManager) + runner := devcontainer.NewDockerRunner(mockExecutor, mockImageManager, mockContainerManager) + result, err := runner.Run(context.Background(), "/test/project", tt.config) + + if tt.wantError != "" { + assert.Contains(t, err.Error(), tt.wantError) + } else { + assert.NoError(t, err) + assert.Equal(t, tt.wantResult, result) + } + + mockExecutor.AssertExpectations(t) + mockImageManager.AssertExpectations(t) + mockContainerManager.AssertExpectations(t) + }) + } +} + +func TestDockerRunnerStop(t *testing.T) { + tests := []struct { + name string + containerID string + setupMocks func(*mocks.MockContainerManager) + wantError string + }{ + { + name: "Successful stop", + containerID: "test-container-id", + setupMocks: func(mcm *mocks.MockContainerManager) { + mcm.On("StopContainer", mock.Anything, "test-container-id").Return(nil) + }, + wantError: "", + }, + { + name: "Failed stop", + containerID: "failed-container-id", + setupMocks: func(mcm *mocks.MockContainerManager) { + mcm.On("StopContainer", mock.Anything, "failed-container-id").Return(errors.New("failed to stop container")) + }, + wantError: "failed to stop container", + }, + } + + for _, tt := range tests { + mockExecutor := &mocks.MockExecutor{} + mockImageManager := &mocks.MockImageManager{} + mockContainerManager := &mocks.MockContainerManager{} + + t.Run(tt.name, func(t *testing.T) { + tt.setupMocks(mockContainerManager) + runner := devcontainer.NewDockerRunner(mockExecutor, mockImageManager, mockContainerManager) + err := runner.Stop(context.Background(), tt.containerID) + + if tt.wantError != "" { + assert.Error(t, err) + assert.Contains(t, err.Error(), tt.wantError) + } else { + assert.NoError(t, err) + } + + mockContainerManager.AssertExpectations(t) + }) + } +} + +func TestDockerRunnerExec(t *testing.T) { + tests := []struct { + name string + containerID string + command []string + setupMocks func(*mocks.MockContainerManager) + wantResult devcontainer.ExecResult + wantError string + }{ + { + name: "Successful exec", + containerID: "test-container-id", + command: []string{"echo", "hello"}, + setupMocks: func(mcm *mocks.MockContainerManager) { + mcm.On("Exec", mock.Anything, "test-container-id", []string{"echo", "hello"}).Return(devcontainer.ExecResult{ExitCode: 0, StdOut: "hello\n"}, nil) + }, + wantResult: devcontainer.ExecResult{ExitCode: 0, StdOut: "hello\n"}, + wantError: "", + }, + { + name: "Failed exec", + containerID: "failed-container-id", + command: []string{"non-existent-command"}, + setupMocks: func(mcm *mocks.MockContainerManager) { + mcm.On("Exec", mock.Anything, "failed-container-id", []string{"non-existent-command"}).Return(devcontainer.ExecResult{}, errors.New("command not found")) + }, + wantResult: devcontainer.ExecResult{}, + wantError: "command not found", + }, + { + name: "Exec with non-zero exit code", + containerID: "exit-code-container-id", + command: []string{"exit", "1"}, + setupMocks: func(mcm *mocks.MockContainerManager) { + mcm.On("Exec", mock.Anything, "exit-code-container-id", []string{"exit", "1"}).Return(devcontainer.ExecResult{ExitCode: 1, StdErr: "Exit status 1"}, nil) + }, + wantResult: devcontainer.ExecResult{ExitCode: 1, StdErr: "Exit status 1"}, + wantError: "", + }, + } + + for _, tt := range tests { + mockExecutor := &mocks.MockExecutor{} + mockImageManager := &mocks.MockImageManager{} + mockContainerManager := &mocks.MockContainerManager{} + + t.Run(tt.name, func(t *testing.T) { + tt.setupMocks(mockContainerManager) + runner := devcontainer.NewDockerRunner(mockExecutor, mockImageManager, mockContainerManager) + result, err := runner.Exec(context.Background(), tt.containerID, tt.command) + + if tt.wantError != "" { + assert.Error(t, err) + assert.Contains(t, err.Error(), tt.wantError) + } else { + assert.NoError(t, err) + assert.Equal(t, tt.wantResult, result) + } + + mockContainerManager.AssertExpectations(t) + }) + } +}