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>
465 lines
12 KiB
Go
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")
|
|
}
|