cli/pkg/container/linuxkit_test.go
Snider 3bd9f9bc3d feat(container): implement LinuxKit container runtime
Add pkg/container for running LinuxKit VMs:
- Manager interface with Run, Stop, List, Logs, Exec
- Hypervisor abstraction (QEMU, Hyperkit)
- Auto-detect available hypervisor and image format
- State persistence in ~/.core/containers.json
- Log management in ~/.core/logs/

CLI commands:
- core run <image> - run LinuxKit image (-d for detach)
- core ps - list containers (-a for all)
- core stop <id> - stop container
- core logs <id> - view logs (-f to follow)
- core exec <id> <cmd> - execute via SSH

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-28 18:50:32 +00:00

465 lines
12 KiB
Go

package container
import (
"context"
"os"
"os/exec"
"path/filepath"
"testing"
"time"
"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) (*exec.Cmd, error) {
m.lastImage = image
m.lastOpts = opts
if m.buildErr != nil {
return nil, m.buildErr
}
// Return a simple command that exits quickly
return exec.CommandContext(ctx, m.commandToRun, "test"), nil
}
// newTestManager creates a LinuxKitManager with mock hypervisor for testing.
func newTestManager(t *testing.T) (*LinuxKitManager, *MockHypervisor, string) {
tmpDir := t.TempDir()
statePath := filepath.Join(tmpDir, "containers.json")
state, err := LoadState(statePath)
require.NoError(t, err)
mock := NewMockHypervisor()
manager := NewLinuxKitManagerWithHypervisor(state, mock)
return manager, mock, tmpDir
}
func TestNewLinuxKitManagerWithHypervisor_Good(t *testing.T) {
tmpDir := t.TempDir()
statePath := filepath.Join(tmpDir, "containers.json")
state, _ := LoadState(statePath)
mock := NewMockHypervisor()
manager := NewLinuxKitManagerWithHypervisor(state, mock)
assert.NotNil(t, manager)
assert.Equal(t, state, manager.State())
assert.Equal(t, mock, manager.Hypervisor())
}
func TestLinuxKitManager_Run_Good_Detached(t *testing.T) {
manager, mock, tmpDir := newTestManager(t)
// Create a test image file
imagePath := filepath.Join(tmpDir, "test.iso")
err := os.WriteFile(imagePath, []byte("fake image"), 0644)
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_Good_DefaultValues(t *testing.T) {
manager, mock, tmpDir := newTestManager(t)
imagePath := filepath.Join(tmpDir, "test.qcow2")
err := os.WriteFile(imagePath, []byte("fake image"), 0644)
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_Bad_ImageNotFound(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_Bad_UnsupportedFormat(t *testing.T) {
manager, _, tmpDir := newTestManager(t)
imagePath := filepath.Join(tmpDir, "test.txt")
err := os.WriteFile(imagePath, []byte("not an image"), 0644)
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_Bad_NotFound(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_Bad_NotRunning(t *testing.T) {
manager, _, tmpDir := newTestManager(t)
statePath := filepath.Join(tmpDir, "containers.json")
state, _ := LoadState(statePath)
manager = NewLinuxKitManagerWithHypervisor(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) {
manager, _, tmpDir := newTestManager(t)
statePath := filepath.Join(tmpDir, "containers.json")
state, _ := LoadState(statePath)
manager = NewLinuxKitManagerWithHypervisor(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_Good_VerifiesRunningStatus(t *testing.T) {
manager, _, tmpDir := newTestManager(t)
statePath := filepath.Join(tmpDir, "containers.json")
state, _ := LoadState(statePath)
manager = NewLinuxKitManagerWithHypervisor(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 := filepath.Join(tmpDir, "logs")
os.MkdirAll(logsDir, 0755)
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, _ := LogPath("abc12345")
os.MkdirAll(filepath.Dir(logPath), 0755)
os.WriteFile(logPath, []byte(logContent), 0644)
ctx := context.Background()
reader, err := manager.Logs(ctx, "abc12345", false)
require.NoError(t, err)
defer reader.Close()
buf := make([]byte, 1024)
n, _ := reader.Read(buf)
assert.Equal(t, logContent, string(buf[:n]))
}
func TestLinuxKitManager_Logs_Bad_NotFound(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_Bad_NoLogFile(t *testing.T) {
manager, _, _ := newTestManager(t)
// Use a unique ID that won't have a log file
uniqueID, _ := GenerateID()
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_Bad_NotFound(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_Bad_NotRunning(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 TestDetectImageFormat_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_Bad_Unknown(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 TestQemuHypervisor_BuildCommand_Bad_UnknownFormat(t *testing.T) {
q := NewQemuHypervisor()
ctx := context.Background()
opts := &HypervisorOptions{Memory: 1024, CPUs: 1}
_, err := q.BuildCommand(ctx, "/path/to/image.txt", opts)
assert.Error(t, err)
assert.Contains(t, err.Error(), "unknown image format")
}
func TestHyperkitHypervisor_Name_Good(t *testing.T) {
h := NewHyperkitHypervisor()
assert.Equal(t, "hyperkit", h.Name())
}
func TestHyperkitHypervisor_BuildCommand_Good(t *testing.T) {
h := NewHyperkitHypervisor()
ctx := context.Background()
opts := &HypervisorOptions{
Memory: 1024,
CPUs: 2,
SSHPort: 2222,
}
cmd, err := h.BuildCommand(ctx, "/path/to/image.iso", opts)
require.NoError(t, err)
assert.NotNil(t, cmd)
args := cmd.Args
assert.Contains(t, args, "-m")
assert.Contains(t, args, "1024M")
assert.Contains(t, args, "-c")
assert.Contains(t, args, "2")
}
func TestHyperkitHypervisor_BuildCommand_Bad_UnknownFormat(t *testing.T) {
h := NewHyperkitHypervisor()
ctx := context.Background()
opts := &HypervisorOptions{Memory: 1024, CPUs: 1}
_, err := h.BuildCommand(ctx, "/path/to/image.unknown", opts)
assert.Error(t, err)
assert.Contains(t, err.Error(), "unknown image format")
}
func TestGetHypervisor_Bad_Unknown(t *testing.T) {
_, err := GetHypervisor("unknown-hypervisor")
assert.Error(t, err)
assert.Contains(t, err.Error(), "unknown hypervisor")
}