package container import ( "context" "syscall" "testing" "time" core "dappco.re/go/core" "dappco.re/go/core/container/internal/coreutil" "dappco.re/go/core/container/internal/proc" "dappco.re/go/core/io" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) // MockHypervisor is a mock implementation for testing. type MockHypervisor struct { name string available bool buildErr error lastImage string lastOpts *HypervisorOptions commandToRun string } func NewMockHypervisor() *MockHypervisor { return &MockHypervisor{ name: "mock", available: true, commandToRun: "echo", } } func (m *MockHypervisor) Name() string { return m.name } func (m *MockHypervisor) Available() bool { return m.available } func (m *MockHypervisor) BuildCommand(ctx context.Context, image string, opts *HypervisorOptions) (*proc.Command, error) { m.lastImage = image m.lastOpts = opts if m.buildErr != nil { return nil, m.buildErr } // Return a simple command that exits quickly return proc.NewCommandContext(ctx, m.commandToRun, "test"), nil } // newTestManager creates a LinuxKitManager with mock hypervisor for testing. // Uses manual temp directory management to avoid race conditions with t.TempDir cleanup. func newTestManager(t *testing.T) (*LinuxKitManager, *MockHypervisor, string) { tmpDir, err := coreutil.MkdirTemp("linuxkit-test-") require.NoError(t, err) // Manual cleanup that handles race conditions with state file writes t.Cleanup(func() { // Give any pending file operations time to complete time.Sleep(10 * time.Millisecond) _ = io.Local.DeleteAll(tmpDir) }) statePath := coreutil.JoinPath(tmpDir, "containers.json") state, err := LoadState(statePath) require.NoError(t, err) mock := NewMockHypervisor() manager := NewLinuxKitManagerWithHypervisor(io.Local, state, mock) return manager, mock, tmpDir } func TestLinuxKit_NewLinuxKitManagerWithHypervisor_Good(t *testing.T) { tmpDir := t.TempDir() statePath := coreutil.JoinPath(tmpDir, "containers.json") state, _ := LoadState(statePath) mock := NewMockHypervisor() manager := NewLinuxKitManagerWithHypervisor(io.Local, state, mock) assert.NotNil(t, manager) assert.Equal(t, state, manager.State()) assert.Equal(t, mock, manager.Hypervisor()) } func TestLinuxKitManager_Run_Detached_Good(t *testing.T) { manager, mock, tmpDir := newTestManager(t) // Create a test image file imagePath := coreutil.JoinPath(tmpDir, "test.iso") err := io.Local.Write(imagePath, "fake image") require.NoError(t, err) // Use a command that runs briefly then exits mock.commandToRun = "sleep" ctx := context.Background() opts := RunOptions{ Name: "test-vm", Detach: true, Memory: 512, CPUs: 2, } container, err := manager.Run(ctx, imagePath, opts) require.NoError(t, err) assert.NotEmpty(t, container.ID) assert.Equal(t, "test-vm", container.Name) assert.Equal(t, imagePath, container.Image) assert.Equal(t, StatusRunning, container.Status) assert.Greater(t, container.PID, 0) assert.Equal(t, 512, container.Memory) assert.Equal(t, 2, container.CPUs) // Verify hypervisor was called with correct options assert.Equal(t, imagePath, mock.lastImage) assert.Equal(t, 512, mock.lastOpts.Memory) assert.Equal(t, 2, mock.lastOpts.CPUs) // Clean up - stop the container time.Sleep(100 * time.Millisecond) } func TestLinuxKitManager_Run_DefaultValues_Good(t *testing.T) { manager, mock, tmpDir := newTestManager(t) imagePath := coreutil.JoinPath(tmpDir, "test.qcow2") err := io.Local.Write(imagePath, "fake image") require.NoError(t, err) ctx := context.Background() opts := RunOptions{Detach: true} container, err := manager.Run(ctx, imagePath, opts) require.NoError(t, err) // Check defaults were applied assert.Equal(t, 1024, mock.lastOpts.Memory) assert.Equal(t, 1, mock.lastOpts.CPUs) assert.Equal(t, 2222, mock.lastOpts.SSHPort) // Name should default to first 8 chars of ID assert.Equal(t, container.ID[:8], container.Name) // Wait for the mock process to complete to avoid temp dir cleanup issues time.Sleep(50 * time.Millisecond) } func TestLinuxKitManager_Run_ImageNotFound_Bad(t *testing.T) { manager, _, _ := newTestManager(t) ctx := context.Background() opts := RunOptions{Detach: true} _, err := manager.Run(ctx, "/nonexistent/image.iso", opts) assert.Error(t, err) assert.Contains(t, err.Error(), "image not found") } func TestLinuxKitManager_Run_UnsupportedFormat_Bad(t *testing.T) { manager, _, tmpDir := newTestManager(t) imagePath := coreutil.JoinPath(tmpDir, "test.txt") err := io.Local.Write(imagePath, "not an image") require.NoError(t, err) ctx := context.Background() opts := RunOptions{Detach: true} _, err = manager.Run(ctx, imagePath, opts) assert.Error(t, err) assert.Contains(t, err.Error(), "unsupported image format") } func TestLinuxKitManager_Stop_Good(t *testing.T) { manager, _, _ := newTestManager(t) // Add a fake running container with a non-existent PID // The Stop function should handle this gracefully container := &Container{ ID: "abc12345", Status: StatusRunning, PID: 999999, // Non-existent PID StartedAt: time.Now(), } _ = manager.State().Add(container) ctx := context.Background() err := manager.Stop(ctx, "abc12345") // Stop should succeed (process doesn't exist, so container is marked stopped) assert.NoError(t, err) // Verify the container status was updated c, ok := manager.State().Get("abc12345") assert.True(t, ok) assert.Equal(t, StatusStopped, c.Status) } func TestLinuxKitManager_Stop_NotFound_Bad(t *testing.T) { manager, _, _ := newTestManager(t) ctx := context.Background() err := manager.Stop(ctx, "nonexistent") assert.Error(t, err) assert.Contains(t, err.Error(), "container not found") } func TestLinuxKitManager_Stop_NotRunning_Bad(t *testing.T) { _, _, tmpDir := newTestManager(t) statePath := coreutil.JoinPath(tmpDir, "containers.json") state, err := LoadState(statePath) require.NoError(t, err) manager := NewLinuxKitManagerWithHypervisor(io.Local, state, NewMockHypervisor()) container := &Container{ ID: "abc12345", Status: StatusStopped, } _ = state.Add(container) ctx := context.Background() err = manager.Stop(ctx, "abc12345") assert.Error(t, err) assert.Contains(t, err.Error(), "not running") } func TestLinuxKitManager_List_Good(t *testing.T) { _, _, tmpDir := newTestManager(t) statePath := coreutil.JoinPath(tmpDir, "containers.json") state, err := LoadState(statePath) require.NoError(t, err) manager := NewLinuxKitManagerWithHypervisor(io.Local, state, NewMockHypervisor()) _ = state.Add(&Container{ID: "aaa11111", Status: StatusStopped}) _ = state.Add(&Container{ID: "bbb22222", Status: StatusStopped}) ctx := context.Background() containers, err := manager.List(ctx) require.NoError(t, err) assert.Len(t, containers, 2) } func TestLinuxKitManager_List_VerifiesRunningStatus_Good(t *testing.T) { _, _, tmpDir := newTestManager(t) statePath := coreutil.JoinPath(tmpDir, "containers.json") state, err := LoadState(statePath) require.NoError(t, err) manager := NewLinuxKitManagerWithHypervisor(io.Local, state, NewMockHypervisor()) // Add a "running" container with a fake PID that doesn't exist _ = state.Add(&Container{ ID: "abc12345", Status: StatusRunning, PID: 999999, // PID that almost certainly doesn't exist }) ctx := context.Background() containers, err := manager.List(ctx) require.NoError(t, err) assert.Len(t, containers, 1) // Status should have been updated to stopped since PID doesn't exist assert.Equal(t, StatusStopped, containers[0].Status) } func TestLinuxKitManager_Logs_Good(t *testing.T) { manager, _, tmpDir := newTestManager(t) // Create a log file manually logsDir := coreutil.JoinPath(tmpDir, "logs") require.NoError(t, io.Local.EnsureDir(logsDir)) container := &Container{ID: "abc12345"} _ = manager.State().Add(container) // Override the default logs dir for testing by creating the log file // at the expected location logContent := "test log content\nline 2\n" logPath, err := LogPath("abc12345") require.NoError(t, err) require.NoError(t, io.Local.EnsureDir(core.PathDir(logPath))) require.NoError(t, io.Local.Write(logPath, logContent)) ctx := context.Background() reader, err := manager.Logs(ctx, "abc12345", false) require.NoError(t, err) defer func() { _ = reader.Close() }() buf := make([]byte, 1024) n, _ := reader.Read(buf) assert.Equal(t, logContent, string(buf[:n])) } func TestLinuxKitManager_Logs_NotFound_Bad(t *testing.T) { manager, _, _ := newTestManager(t) ctx := context.Background() _, err := manager.Logs(ctx, "nonexistent", false) assert.Error(t, err) assert.Contains(t, err.Error(), "container not found") } func TestLinuxKitManager_Logs_NoLogFile_Bad(t *testing.T) { manager, _, _ := newTestManager(t) // Use a unique ID that won't have a log file uniqueID, err := GenerateID() require.NoError(t, err) container := &Container{ID: uniqueID} _ = manager.State().Add(container) ctx := context.Background() reader, err := manager.Logs(ctx, uniqueID, false) // If logs existed somehow, clean up the reader if reader != nil { _ = reader.Close() } assert.Error(t, err) if err != nil { assert.Contains(t, err.Error(), "no logs available") } } func TestLinuxKitManager_Exec_NotFound_Bad(t *testing.T) { manager, _, _ := newTestManager(t) ctx := context.Background() err := manager.Exec(ctx, "nonexistent", []string{"ls"}) assert.Error(t, err) assert.Contains(t, err.Error(), "container not found") } func TestLinuxKitManager_Exec_NotRunning_Bad(t *testing.T) { manager, _, _ := newTestManager(t) container := &Container{ID: "abc12345", Status: StatusStopped} _ = manager.State().Add(container) ctx := context.Background() err := manager.Exec(ctx, "abc12345", []string{"ls"}) assert.Error(t, err) assert.Contains(t, err.Error(), "not running") } func TestLinuxKit_DetectImageFormat_Good(t *testing.T) { tests := []struct { path string format ImageFormat }{ {"/path/to/image.iso", FormatISO}, {"/path/to/image.ISO", FormatISO}, {"/path/to/image.qcow2", FormatQCOW2}, {"/path/to/image.QCOW2", FormatQCOW2}, {"/path/to/image.vmdk", FormatVMDK}, {"/path/to/image.raw", FormatRaw}, {"/path/to/image.img", FormatRaw}, {"image.iso", FormatISO}, } for _, tt := range tests { t.Run(tt.path, func(t *testing.T) { assert.Equal(t, tt.format, DetectImageFormat(tt.path)) }) } } func TestDetectImageFormat_Unknown_Bad(t *testing.T) { tests := []string{ "/path/to/image.txt", "/path/to/image", "noextension", "/path/to/image.docx", } for _, path := range tests { t.Run(path, func(t *testing.T) { assert.Equal(t, FormatUnknown, DetectImageFormat(path)) }) } } func TestQemuHypervisor_Name_Good(t *testing.T) { q := NewQemuHypervisor() assert.Equal(t, "qemu", q.Name()) } func TestQemuHypervisor_BuildCommand_Good(t *testing.T) { q := NewQemuHypervisor() ctx := context.Background() opts := &HypervisorOptions{ Memory: 2048, CPUs: 4, SSHPort: 2222, Ports: map[int]int{8080: 80}, Detach: true, } cmd, err := q.BuildCommand(ctx, "/path/to/image.iso", opts) require.NoError(t, err) assert.NotNil(t, cmd) // Check command path assert.Contains(t, cmd.Path, "qemu") // Check that args contain expected values args := cmd.Args assert.Contains(t, args, "-m") assert.Contains(t, args, "2048") assert.Contains(t, args, "-smp") assert.Contains(t, args, "4") assert.Contains(t, args, "-nographic") } func TestLinuxKitManager_Logs_Follow_Good(t *testing.T) { manager, _, _ := newTestManager(t) // Create a unique container ID uniqueID, err := GenerateID() require.NoError(t, err) container := &Container{ID: uniqueID} _ = manager.State().Add(container) // Create a log file at the expected location logPath, err := LogPath(uniqueID) require.NoError(t, err) require.NoError(t, io.Local.EnsureDir(core.PathDir(logPath))) // Write initial content err = io.Local.Write(logPath, "initial log content\n") require.NoError(t, err) // Create a cancellable context ctx, cancel := context.WithCancel(context.Background()) // Get the follow reader reader, err := manager.Logs(ctx, uniqueID, true) require.NoError(t, err) // Cancel the context to stop the follow cancel() // Read should return EOF after context cancellation buf := make([]byte, 1024) _, readErr := reader.Read(buf) // After context cancel, Read should return EOF assert.Equal(t, "EOF", readErr.Error()) // Close the reader assert.NoError(t, reader.Close()) } func TestFollowReader_Read_WithData_Good(t *testing.T) { tmpDir := t.TempDir() logPath := coreutil.JoinPath(tmpDir, "test.log") // Create log file with content content := "test log line 1\ntest log line 2\n" err := io.Local.Write(logPath, content) require.NoError(t, err) ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) defer cancel() reader, err := newFollowReader(ctx, io.Local, logPath) require.NoError(t, err) defer func() { _ = reader.Close() }() // The followReader seeks to end, so we need to append more content f, err := io.Local.Append(logPath) require.NoError(t, err) _, err = f.Write([]byte("new line\n")) require.NoError(t, err) require.NoError(t, f.Close()) // Give the reader time to poll time.Sleep(150 * time.Millisecond) buf := make([]byte, 1024) n, err := reader.Read(buf) if err == nil { assert.Greater(t, n, 0) } } func TestFollowReader_Read_ContextCancel_Good(t *testing.T) { tmpDir := t.TempDir() logPath := coreutil.JoinPath(tmpDir, "test.log") // Create log file err := io.Local.Write(logPath, "initial content\n") require.NoError(t, err) ctx, cancel := context.WithCancel(context.Background()) reader, err := newFollowReader(ctx, io.Local, logPath) require.NoError(t, err) // Cancel the context cancel() // Read should return EOF buf := make([]byte, 1024) _, readErr := reader.Read(buf) assert.Equal(t, "EOF", readErr.Error()) _ = reader.Close() } func TestFollowReader_Close_Good(t *testing.T) { tmpDir := t.TempDir() logPath := coreutil.JoinPath(tmpDir, "test.log") err := io.Local.Write(logPath, "content\n") require.NoError(t, err) ctx := context.Background() reader, err := newFollowReader(ctx, io.Local, logPath) require.NoError(t, err) err = reader.Close() assert.NoError(t, err) // Reading after close should fail or return EOF buf := make([]byte, 1024) _, readErr := reader.Read(buf) assert.Error(t, readErr) } func TestNewFollowReader_FileNotFound_Bad(t *testing.T) { ctx := context.Background() _, err := newFollowReader(ctx, io.Local, "/nonexistent/path/to/file.log") assert.Error(t, err) } func TestLinuxKitManager_Run_BuildCommandError_Bad(t *testing.T) { manager, mock, tmpDir := newTestManager(t) // Create a test image file imagePath := coreutil.JoinPath(tmpDir, "test.iso") err := io.Local.Write(imagePath, "fake image") require.NoError(t, err) // Configure mock to return an error mock.buildErr = assert.AnError ctx := context.Background() opts := RunOptions{Detach: true} _, err = manager.Run(ctx, imagePath, opts) assert.Error(t, err) assert.Contains(t, err.Error(), "failed to build hypervisor command") } func TestLinuxKitManager_Run_Foreground_Good(t *testing.T) { manager, mock, tmpDir := newTestManager(t) // Create a test image file imagePath := coreutil.JoinPath(tmpDir, "test.iso") err := io.Local.Write(imagePath, "fake image") require.NoError(t, err) // Use echo which exits quickly mock.commandToRun = "echo" ctx := context.Background() opts := RunOptions{ Name: "test-foreground", Detach: false, // Run in foreground Memory: 512, CPUs: 1, } container, err := manager.Run(ctx, imagePath, opts) require.NoError(t, err) assert.NotEmpty(t, container.ID) assert.Equal(t, "test-foreground", container.Name) // Foreground process should have completed assert.Equal(t, StatusStopped, container.Status) } func TestLinuxKitManager_Stop_ContextCancelled_Good(t *testing.T) { manager, mock, tmpDir := newTestManager(t) // Create a test image file imagePath := coreutil.JoinPath(tmpDir, "test.iso") err := io.Local.Write(imagePath, "fake image") require.NoError(t, err) // Use a command that takes a long time mock.commandToRun = "sleep" // Start a container ctx := context.Background() opts := RunOptions{ Name: "test-cancel", Detach: true, } container, err := manager.Run(ctx, imagePath, opts) require.NoError(t, err) // Ensure cleanup happens regardless of test outcome t.Cleanup(func() { _ = manager.Stop(context.Background(), container.ID) }) // Create a context that's already cancelled cancelCtx, cancel := context.WithCancel(context.Background()) cancel() // Stop with cancelled context err = manager.Stop(cancelCtx, container.ID) // Should return context error assert.Error(t, err) assert.Equal(t, context.Canceled, err) } func TestIsProcessRunning_ExistingProcess_Good(t *testing.T) { // Use our own PID which definitely exists running := isProcessRunning(syscall.Getpid()) assert.True(t, running) } func TestIsProcessRunning_NonexistentProcess_Bad(t *testing.T) { // Use a PID that almost certainly doesn't exist running := isProcessRunning(999999) assert.False(t, running) } func TestLinuxKitManager_Run_WithPortsAndVolumes_Good(t *testing.T) { manager, mock, tmpDir := newTestManager(t) imagePath := coreutil.JoinPath(tmpDir, "test.iso") err := io.Local.Write(imagePath, "fake image") require.NoError(t, err) ctx := context.Background() opts := RunOptions{ Name: "test-ports", Detach: true, Memory: 512, CPUs: 1, SSHPort: 2223, Ports: map[int]int{8080: 80, 443: 443}, Volumes: map[string]string{"/host/data": "/container/data"}, } container, err := manager.Run(ctx, imagePath, opts) require.NoError(t, err) assert.NotEmpty(t, container.ID) assert.Equal(t, map[int]int{8080: 80, 443: 443}, container.Ports) assert.Equal(t, 2223, mock.lastOpts.SSHPort) assert.Equal(t, map[string]string{"/host/data": "/container/data"}, mock.lastOpts.Volumes) time.Sleep(50 * time.Millisecond) } func TestFollowReader_Read_ReaderError_Bad(t *testing.T) { tmpDir := t.TempDir() logPath := coreutil.JoinPath(tmpDir, "test.log") // Create log file err := io.Local.Write(logPath, "content\n") require.NoError(t, err) ctx := context.Background() reader, err := newFollowReader(ctx, io.Local, logPath) require.NoError(t, err) // Close the underlying file to cause read errors _ = reader.file.Close() // Read should return an error buf := make([]byte, 1024) _, readErr := reader.Read(buf) assert.Error(t, readErr) } func TestLinuxKitManager_Run_StartError_Bad(t *testing.T) { manager, mock, tmpDir := newTestManager(t) imagePath := coreutil.JoinPath(tmpDir, "test.iso") err := io.Local.Write(imagePath, "fake image") require.NoError(t, err) // Use a command that doesn't exist to cause Start() to fail mock.commandToRun = "/nonexistent/command/that/does/not/exist" ctx := context.Background() opts := RunOptions{ Name: "test-start-error", Detach: true, } _, err = manager.Run(ctx, imagePath, opts) assert.Error(t, err) assert.Contains(t, err.Error(), "failed to start VM") } func TestLinuxKitManager_Run_ForegroundStartError_Bad(t *testing.T) { manager, mock, tmpDir := newTestManager(t) imagePath := coreutil.JoinPath(tmpDir, "test.iso") err := io.Local.Write(imagePath, "fake image") require.NoError(t, err) // Use a command that doesn't exist to cause Start() to fail mock.commandToRun = "/nonexistent/command/that/does/not/exist" ctx := context.Background() opts := RunOptions{ Name: "test-foreground-error", Detach: false, } _, err = manager.Run(ctx, imagePath, opts) assert.Error(t, err) assert.Contains(t, err.Error(), "failed to start VM") } func TestLinuxKitManager_Run_ForegroundWithError_Good(t *testing.T) { manager, mock, tmpDir := newTestManager(t) imagePath := coreutil.JoinPath(tmpDir, "test.iso") err := io.Local.Write(imagePath, "fake image") require.NoError(t, err) // Use a command that exits with error mock.commandToRun = "false" // false command exits with code 1 ctx := context.Background() opts := RunOptions{ Name: "test-foreground-exit-error", Detach: false, } container, err := manager.Run(ctx, imagePath, opts) require.NoError(t, err) // Run itself should succeed // Container should be in error state since process exited with error assert.Equal(t, StatusError, container.Status) } func TestLinuxKitManager_Stop_ProcessExitedWhileRunning_Good(t *testing.T) { manager, _, _ := newTestManager(t) // Add a "running" container with a process that has already exited // This simulates the race condition where process exits between status check // and signal send container := &Container{ ID: "test1234", Status: StatusRunning, PID: 999999, // Non-existent PID StartedAt: time.Now(), } _ = manager.State().Add(container) ctx := context.Background() err := manager.Stop(ctx, "test1234") // Stop should succeed gracefully assert.NoError(t, err) // Container should be stopped c, ok := manager.State().Get("test1234") assert.True(t, ok) assert.Equal(t, StatusStopped, c.Status) }