yao/sandbox/manager_test.go
Max c1e92b726d Implement MCP Configuration and Tool Integration for Sandbox
- Added functionality to build and manage MCP configuration for sandbox environments, allowing for dynamic tool execution.
- Enhanced the Assistant's Stream method to skip MCP tool calls in sandbox mode, with internal handling by Claude CLI.
- Introduced unit tests for MCP configuration building and skills directory resolution, ensuring robust integration.
- Updated sandbox manager to create IPC sessions and manage tool exposure dynamically, improving interaction with external agents.
- Enhanced documentation to reflect new features and integration points for MCP and skills within the sandbox.
2026-01-30 19:57:31 +08:00

876 lines
22 KiB
Go

package sandbox
import (
"context"
"os"
"path/filepath"
"strings"
"sync"
"testing"
"time"
"github.com/yaoapp/yao/config"
"github.com/yaoapp/yao/test"
)
// getTestDirs returns workspace and IPC directories for testing.
// Uses environment variables if set (for CI), otherwise creates temp directories.
// Returns workspaceRoot, ipcDir, tmpDir (empty if using env vars), error
func getTestDirs(prefix string) (string, string, string, error) {
workspaceRoot := os.Getenv("YAO_SANDBOX_WORKSPACE")
ipcDir := os.Getenv("YAO_SANDBOX_IPC")
var tmpDir string
var err error
if workspaceRoot == "" || ipcDir == "" {
// Create temporary directories for test
// Use /tmp directly to avoid long paths (Unix socket path limit ~104 bytes)
tmpDir, err = os.MkdirTemp("/tmp", prefix)
if err != nil {
return "", "", "", err
}
if workspaceRoot == "" {
workspaceRoot = filepath.Join(tmpDir, "ws")
}
if ipcDir == "" {
ipcDir = filepath.Join(tmpDir, "ipc")
}
}
return workspaceRoot, ipcDir, tmpDir, nil
}
// getContainerUser returns the container user from environment variable
func getContainerUser() string {
return os.Getenv("YAO_SANDBOX_CONTAINER_USER")
}
// skipIfNoDocker skips the test if Docker is not available
func skipIfNoDocker(t *testing.T) *Manager {
t.Helper()
workspaceRoot, ipcDir, tmpDir, err := getTestDirs("sandbox-test-*")
if err != nil {
t.Fatalf("Failed to create temp dir: %v", err)
}
cfg := &Config{
Image: "yaoapp/sandbox-claude:latest",
WorkspaceRoot: workspaceRoot,
IPCDir: ipcDir,
MaxContainers: 5,
IdleTimeout: 1 * time.Minute,
MaxMemory: "512m",
MaxCPU: 0.5,
ContainerUser: getContainerUser(),
}
m, err := NewManager(cfg)
if err != nil {
// Clean up temp dir
if tmpDir != "" {
os.RemoveAll(tmpDir)
}
if strings.Contains(err.Error(), "Docker not available") ||
strings.Contains(err.Error(), "Cannot connect to the Docker daemon") {
t.Skipf("Skipping test: %v", err)
}
t.Fatalf("Failed to create manager: %v", err)
}
// Store tmpDir in test cleanup
t.Cleanup(func() {
m.Close()
if tmpDir != "" {
os.RemoveAll(tmpDir)
}
})
return m
}
// TestNewManager tests manager creation
func TestNewManager(t *testing.T) {
m := skipIfNoDocker(t)
if m.dockerClient == nil {
t.Error("Docker client should not be nil")
}
if m.ipcManager == nil {
t.Error("IPC manager should not be nil")
}
if m.config == nil {
t.Error("Config should not be nil")
}
}
// TestNewManagerWithNilConfig tests manager creation with nil config
func TestNewManagerWithNilConfig(t *testing.T) {
tmpDir, err := os.MkdirTemp("", "sandbox-test-nil-*")
if err != nil {
t.Fatalf("Failed to create temp dir: %v", err)
}
defer os.RemoveAll(tmpDir)
// Set environment variables for default paths
os.Setenv("YAO_SANDBOX_WORKSPACE", filepath.Join(tmpDir, "workspace"))
os.Setenv("YAO_SANDBOX_IPC", filepath.Join(tmpDir, "ipc"))
defer os.Unsetenv("YAO_SANDBOX_WORKSPACE")
defer os.Unsetenv("YAO_SANDBOX_IPC")
cfg := DefaultConfig()
cfg.Init(tmpDir)
m, err := NewManager(cfg)
if err != nil {
if strings.Contains(err.Error(), "Docker not available") {
t.Skip("Docker not available")
}
t.Fatalf("Failed to create manager: %v", err)
}
defer m.Close()
if m.config.MaxContainers != 100 {
t.Errorf("Expected MaxContainers 100, got %d", m.config.MaxContainers)
}
}
// TestGetOrCreate tests container creation
func TestGetOrCreate(t *testing.T) {
m := skipIfNoDocker(t)
ctx := context.Background()
userID := "test-user"
chatID := "test-chat-" + time.Now().Format("20060102150405")
// Create container
container, err := m.GetOrCreate(ctx, userID, chatID)
if err != nil {
t.Fatalf("GetOrCreate failed: %v", err)
}
// Verify container properties
if container.UserID != userID {
t.Errorf("Expected UserID %s, got %s", userID, container.UserID)
}
if container.ChatID != chatID {
t.Errorf("Expected ChatID %s, got %s", chatID, container.ChatID)
}
expectedName := containerName(userID, chatID)
if container.Name != expectedName {
t.Errorf("Expected Name %s, got %s", expectedName, container.Name)
}
if container.Status != StatusCreated {
t.Errorf("Expected Status %s, got %s", StatusCreated, container.Status)
}
// Get same container again (should return existing)
container2, err := m.GetOrCreate(ctx, userID, chatID)
if err != nil {
t.Fatalf("GetOrCreate (second call) failed: %v", err)
}
if container.ID != container2.ID {
t.Error("Expected same container on second GetOrCreate call")
}
// Cleanup
if err := m.Remove(ctx, container.Name); err != nil {
t.Logf("Warning: failed to remove container: %v", err)
}
}
// TestContainerStartStopRemove tests container lifecycle
func TestContainerStartStopRemove(t *testing.T) {
m := skipIfNoDocker(t)
ctx := context.Background()
userID := "lifecycle-user"
chatID := "lifecycle-chat-" + time.Now().Format("20060102150405")
// Create container
container, err := m.GetOrCreate(ctx, userID, chatID)
if err != nil {
t.Fatalf("GetOrCreate failed: %v", err)
}
// Start container
if err := m.Start(ctx, container.Name); err != nil {
t.Fatalf("Start failed: %v", err)
}
// Verify status is running
c, ok := m.containers.Load(container.Name)
if !ok {
t.Fatal("Container not found in map")
}
if c.(*Container).Status != StatusRunning {
t.Errorf("Expected status %s, got %s", StatusRunning, c.(*Container).Status)
}
// Stop container
if err := m.Stop(ctx, container.Name); err != nil {
t.Fatalf("Stop failed: %v", err)
}
// Verify status is stopped
c, ok = m.containers.Load(container.Name)
if !ok {
t.Fatal("Container not found in map after stop")
}
if c.(*Container).Status != StatusStopped {
t.Errorf("Expected status %s, got %s", StatusStopped, c.(*Container).Status)
}
// Remove container
if err := m.Remove(ctx, container.Name); err != nil {
t.Fatalf("Remove failed: %v", err)
}
// Verify container is removed from map
if _, ok := m.containers.Load(container.Name); ok {
t.Error("Container should be removed from map")
}
}
// TestExec tests command execution
func TestExec(t *testing.T) {
m := skipIfNoDocker(t)
ctx := context.Background()
userID := "exec-user"
chatID := "exec-chat-" + time.Now().Format("20060102150405")
// Create and start container
container, err := m.GetOrCreate(ctx, userID, chatID)
if err != nil {
t.Fatalf("GetOrCreate failed: %v", err)
}
defer m.Remove(ctx, container.Name)
// Execute simple command
result, err := m.Exec(ctx, container.Name, []string{"echo", "hello world"}, nil)
if err != nil {
t.Fatalf("Exec failed: %v", err)
}
// Note: Docker multiplexed stream includes header bytes
if !strings.Contains(result.Stdout, "hello world") {
t.Errorf("Expected stdout to contain 'hello world', got: %s", result.Stdout)
}
}
// TestExecWithEnv tests command execution with environment variables
func TestExecWithEnv(t *testing.T) {
m := skipIfNoDocker(t)
ctx := context.Background()
userID := "exec-env-user"
chatID := "exec-env-chat-" + time.Now().Format("20060102150405")
container, err := m.GetOrCreate(ctx, userID, chatID)
if err != nil {
t.Fatalf("GetOrCreate failed: %v", err)
}
defer m.Remove(ctx, container.Name)
result, err := m.Exec(ctx, container.Name, []string{"sh", "-c", "echo $TEST_VAR"}, &ExecOptions{
Env: map[string]string{
"TEST_VAR": "test_value_123",
},
})
if err != nil {
t.Fatalf("Exec with env failed: %v", err)
}
if !strings.Contains(result.Stdout, "test_value_123") {
t.Errorf("Expected stdout to contain 'test_value_123', got: %s", result.Stdout)
}
}
// TestExecWithTimeout tests command execution timeout
func TestExecWithTimeout(t *testing.T) {
m := skipIfNoDocker(t)
ctx := context.Background()
userID := "exec-timeout-user"
chatID := "exec-timeout-chat-" + time.Now().Format("20060102150405")
container, err := m.GetOrCreate(ctx, userID, chatID)
if err != nil {
t.Fatalf("GetOrCreate failed: %v", err)
}
defer m.Remove(ctx, container.Name)
// Execute command with short timeout (sleep 10s but timeout after 500ms)
start := time.Now()
_, err = m.Exec(ctx, container.Name, []string{"sleep", "10"}, &ExecOptions{
Timeout: 500 * time.Millisecond,
})
elapsed := time.Since(start)
// Should timeout with context deadline exceeded
if err == nil {
t.Error("Expected timeout error, but command completed without error")
} else if err != context.DeadlineExceeded {
t.Logf("Got error (expected context.DeadlineExceeded): %v", err)
}
// Verify it didn't wait the full 10 seconds
if elapsed > 5*time.Second {
t.Errorf("Timeout took too long: %v (expected < 5s)", elapsed)
}
t.Logf("Timeout completed in %v", elapsed)
}
// TestFileOperations tests filesystem operations
func TestFileOperations(t *testing.T) {
m := skipIfNoDocker(t)
ctx := context.Background()
userID := "file-user"
chatID := "file-chat-" + time.Now().Format("20060102150405")
container, err := m.GetOrCreate(ctx, userID, chatID)
if err != nil {
t.Fatalf("GetOrCreate failed: %v", err)
}
defer m.Remove(ctx, container.Name)
// Start container first
if err := m.Start(ctx, container.Name); err != nil {
t.Fatalf("Start failed: %v", err)
}
// Test MkDir
testDir := "/workspace/testdir"
if err := m.MkDir(ctx, container.Name, testDir); err != nil {
t.Fatalf("MkDir failed: %v", err)
}
// Test WriteFile
testFile := "/workspace/testdir/test.txt"
testContent := []byte("Hello, Sandbox!")
if err := m.WriteFile(ctx, container.Name, testFile, testContent); err != nil {
t.Fatalf("WriteFile failed: %v", err)
}
// Test ReadFile
content, err := m.ReadFile(ctx, container.Name, testFile)
if err != nil {
t.Fatalf("ReadFile failed: %v", err)
}
if string(content) != string(testContent) {
t.Errorf("Expected content '%s', got '%s'", testContent, content)
}
// Test Stat
info, err := m.Stat(ctx, container.Name, testFile)
if err != nil {
t.Fatalf("Stat failed: %v", err)
}
if info == nil {
t.Fatal("Stat returned nil")
}
if info.Name != "test.txt" {
t.Errorf("Expected name 'test.txt', got '%s'", info.Name)
}
if info.Size != int64(len(testContent)) {
t.Errorf("Expected size %d, got %d", len(testContent), info.Size)
}
// Test ListDir
files, err := m.ListDir(ctx, container.Name, "/workspace/testdir")
if err != nil {
t.Fatalf("ListDir failed: %v", err)
}
found := false
for _, f := range files {
if f.Name == "test.txt" {
found = true
break
}
}
if !found {
t.Error("Expected to find test.txt in directory listing")
}
// Test RemoveFile
if err := m.RemoveFile(ctx, container.Name, testFile); err != nil {
t.Fatalf("RemoveFile failed: %v", err)
}
// Verify file is removed - check via ls instead of stat
// (stat command may still succeed with different output)
files2, err := m.ListDir(ctx, container.Name, "/workspace/testdir")
if err != nil {
t.Fatalf("ListDir after removal failed: %v", err)
}
found = false
for _, f := range files2 {
if f.Name == "test.txt" {
found = true
break
}
}
if found {
t.Error("File test.txt should be removed")
}
}
// TestCopyOperations tests copy to/from container
func TestCopyOperations(t *testing.T) {
m := skipIfNoDocker(t)
ctx := context.Background()
userID := "copy-user"
chatID := "copy-chat-" + time.Now().Format("20060102150405")
container, err := m.GetOrCreate(ctx, userID, chatID)
if err != nil {
t.Fatalf("GetOrCreate failed: %v", err)
}
defer m.Remove(ctx, container.Name)
// Start container
if err := m.Start(ctx, container.Name); err != nil {
t.Fatalf("Start failed: %v", err)
}
// Create temp file on host
tmpDir, err := os.MkdirTemp("", "sandbox-copy-test-*")
if err != nil {
t.Fatalf("Failed to create temp dir: %v", err)
}
defer os.RemoveAll(tmpDir)
hostFile := filepath.Join(tmpDir, "source.txt")
if err := os.WriteFile(hostFile, []byte("copy test content"), 0644); err != nil {
t.Fatalf("Failed to write host file: %v", err)
}
// Copy to container
if err := m.CopyToContainer(ctx, container.Name, hostFile, "/workspace/"); err != nil {
t.Fatalf("CopyToContainer failed: %v", err)
}
// Verify file exists in container
content, err := m.ReadFile(ctx, container.Name, "/workspace/source.txt")
if err != nil {
t.Fatalf("ReadFile after copy failed: %v", err)
}
if string(content) != "copy test content" {
t.Errorf("Expected 'copy test content', got '%s'", content)
}
// Copy from container
extractDir := filepath.Join(tmpDir, "extracted")
os.MkdirAll(extractDir, 0755)
if err := m.CopyFromContainer(ctx, container.Name, "/workspace/source.txt", extractDir); err != nil {
t.Fatalf("CopyFromContainer failed: %v", err)
}
// Verify extracted file
extractedContent, err := os.ReadFile(filepath.Join(extractDir, "source.txt"))
if err != nil {
t.Fatalf("Failed to read extracted file: %v", err)
}
if string(extractedContent) != "copy test content" {
t.Errorf("Expected 'copy test content', got '%s'", extractedContent)
}
}
// TestListContainers tests listing containers for a user
func TestListContainers(t *testing.T) {
m := skipIfNoDocker(t)
ctx := context.Background()
userID := "list-user"
chatIDs := []string{
"list-chat-1-" + time.Now().Format("20060102150405"),
"list-chat-2-" + time.Now().Format("20060102150405"),
}
// Create multiple containers for same user
for _, chatID := range chatIDs {
container, err := m.GetOrCreate(ctx, userID, chatID)
if err != nil {
t.Fatalf("GetOrCreate failed for %s: %v", chatID, err)
}
defer m.Remove(ctx, container.Name)
}
// List containers for user
containers, err := m.List(ctx, userID)
if err != nil {
t.Fatalf("List failed: %v", err)
}
if len(containers) != 2 {
t.Errorf("Expected 2 containers, got %d", len(containers))
}
// Verify all containers belong to user
for _, c := range containers {
if c.UserID != userID {
t.Errorf("Expected UserID %s, got %s", userID, c.UserID)
}
}
}
// TestConcurrencyLimit tests the max containers limit
func TestConcurrencyLimit(t *testing.T) {
workspaceRoot, ipcDir, tmpDir, err := getTestDirs("sandbox-concurrency-*")
if err != nil {
t.Fatalf("Failed to create temp dir: %v", err)
}
if tmpDir != "" {
defer os.RemoveAll(tmpDir)
}
cfg := &Config{
Image: "yaoapp/sandbox-claude:latest",
WorkspaceRoot: workspaceRoot,
IPCDir: ipcDir,
MaxContainers: 2, // Low limit for testing
IdleTimeout: 1 * time.Minute,
MaxMemory: "256m",
MaxCPU: 0.25,
ContainerUser: getContainerUser(),
}
m, err := NewManager(cfg)
if err != nil {
if strings.Contains(err.Error(), "Docker not available") {
t.Skip("Docker not available")
}
t.Fatalf("Failed to create manager: %v", err)
}
defer m.Close()
ctx := context.Background()
// Create containers up to limit
containers := make([]*Container, 0)
for i := 0; i < cfg.MaxContainers; i++ {
c, err := m.GetOrCreate(ctx, "limit-user", "limit-chat-"+string(rune('a'+i)))
if err != nil {
t.Fatalf("GetOrCreate failed for container %d: %v", i, err)
}
containers = append(containers, c)
}
// Try to create one more - should fail
_, err = m.GetOrCreate(ctx, "limit-user", "limit-chat-extra")
if err != ErrTooManyContainers {
t.Errorf("Expected ErrTooManyContainers, got: %v", err)
}
// Cleanup
for _, c := range containers {
m.Remove(ctx, c.Name)
}
}
// TestConcurrentAccess tests concurrent container access
func TestConcurrentAccess(t *testing.T) {
m := skipIfNoDocker(t)
ctx := context.Background()
userID := "concurrent-user"
chatID := "concurrent-chat-" + time.Now().Format("20060102150405")
// Create container
container, err := m.GetOrCreate(ctx, userID, chatID)
if err != nil {
t.Fatalf("GetOrCreate failed: %v", err)
}
defer m.Remove(ctx, container.Name)
// Concurrent GetOrCreate calls should return same container
var wg sync.WaitGroup
results := make(chan *Container, 10)
errors := make(chan error, 10)
for i := 0; i < 10; i++ {
wg.Add(1)
go func() {
defer wg.Done()
c, err := m.GetOrCreate(ctx, userID, chatID)
if err != nil {
errors <- err
return
}
results <- c
}()
}
wg.Wait()
close(results)
close(errors)
// Check for errors
for err := range errors {
t.Errorf("Concurrent GetOrCreate error: %v", err)
}
// All results should have same ID
var firstID string
for c := range results {
if firstID == "" {
firstID = c.ID
} else if c.ID != firstID {
t.Errorf("Expected same container ID, got different: %s vs %s", firstID, c.ID)
}
}
}
// TestContainerNotFound tests operations on non-existent container
func TestContainerNotFound(t *testing.T) {
m := skipIfNoDocker(t)
ctx := context.Background()
fakeName := "yao-sandbox-fake-user-fake-chat"
// Test Stop on non-existent (should not error)
if err := m.Stop(ctx, fakeName); err != nil {
t.Errorf("Stop on non-existent container should not error: %v", err)
}
// Test Remove on non-existent (should not error)
if err := m.Remove(ctx, fakeName); err != nil {
t.Errorf("Remove on non-existent container should not error: %v", err)
}
// Test ensureRunning on non-existent (should error)
if err := m.ensureRunning(ctx, fakeName); err != ErrContainerNotFound {
t.Errorf("Expected ErrContainerNotFound, got: %v", err)
}
}
// TestCleanup tests the cleanup function
func TestCleanup(t *testing.T) {
workspaceRoot, ipcDir, tmpDir, err := getTestDirs("sandbox-cleanup-*")
if err != nil {
t.Fatalf("Failed to create temp dir: %v", err)
}
if tmpDir != "" {
defer os.RemoveAll(tmpDir)
}
cfg := &Config{
Image: "yaoapp/sandbox-claude:latest",
WorkspaceRoot: workspaceRoot,
IPCDir: ipcDir,
MaxContainers: 10,
IdleTimeout: 100 * time.Millisecond, // Very short for testing
MaxMemory: "256m",
MaxCPU: 0.25,
ContainerUser: getContainerUser(),
}
m, err := NewManager(cfg)
if err != nil {
if strings.Contains(err.Error(), "Docker not available") {
t.Skip("Docker not available")
}
t.Fatalf("Failed to create manager: %v", err)
}
defer m.Close()
ctx := context.Background()
// Create and start container
container, err := m.GetOrCreate(ctx, "cleanup-user", "cleanup-chat")
if err != nil {
t.Fatalf("GetOrCreate failed: %v", err)
}
defer m.Remove(ctx, container.Name)
if err := m.Start(ctx, container.Name); err != nil {
t.Fatalf("Start failed: %v", err)
}
// Verify running
c, _ := m.containers.Load(container.Name)
if c.(*Container).Status != StatusRunning {
t.Fatalf("Container should be running")
}
// Set LastUsedAt to past
c.(*Container).LastUsedAt = time.Now().Add(-1 * time.Hour)
// Run cleanup
if err := m.Cleanup(ctx); err != nil {
t.Fatalf("Cleanup failed: %v", err)
}
// Verify stopped
c, _ = m.containers.Load(container.Name)
if c.(*Container).Status != StatusStopped {
t.Errorf("Container should be stopped after cleanup, got: %s", c.(*Container).Status)
}
}
// TestManagerWithYaoApp tests sandbox with Yao application loaded
// This is the full integration test that loads the Yao application environment
func TestManagerWithYaoApp(t *testing.T) {
// Check if YAO_TEST_APPLICATION is set
if os.Getenv("YAO_TEST_APPLICATION") == "" {
t.Skip("Skipping: YAO_TEST_APPLICATION not set")
}
// Prepare Yao test environment
test.Prepare(t, config.Conf)
defer test.Clean()
// Now test with the Yao environment loaded
workspaceRoot, ipcDir, tmpDir, err := getTestDirs("sandbox-yao-*")
if err != nil {
t.Fatalf("Failed to create temp dir: %v", err)
}
if tmpDir != "" {
defer os.RemoveAll(tmpDir)
}
cfg := &Config{
Image: "yaoapp/sandbox-claude:latest",
WorkspaceRoot: workspaceRoot,
IPCDir: ipcDir,
MaxContainers: 5,
IdleTimeout: 5 * time.Minute,
MaxMemory: "1g",
MaxCPU: 1.0,
ContainerUser: getContainerUser(),
}
m, err := NewManager(cfg)
if err != nil {
if strings.Contains(err.Error(), "Docker not available") {
t.Skip("Docker not available")
}
t.Fatalf("Failed to create manager: %v", err)
}
defer m.Close()
ctx := context.Background()
// Create container
container, err := m.GetOrCreate(ctx, "yao-user", "yao-chat")
if err != nil {
t.Fatalf("GetOrCreate failed: %v", err)
}
defer m.Remove(ctx, container.Name)
// Start container
if err := m.Start(ctx, container.Name); err != nil {
t.Fatalf("Start failed: %v", err)
}
// Execute a command to verify container is working
result, err := m.Exec(ctx, container.Name, []string{"node", "--version"}, nil)
if err != nil {
t.Fatalf("Exec node --version failed: %v", err)
}
if !strings.Contains(result.Stdout, "v") {
t.Errorf("Expected node version output, got: %s", result.Stdout)
}
// Execute Python version check
result, err = m.Exec(ctx, container.Name, []string{"python3", "--version"}, nil)
if err != nil {
t.Fatalf("Exec python3 --version failed: %v", err)
}
if !strings.Contains(result.Stdout, "Python") {
t.Errorf("Expected Python version output, got: %s", result.Stdout)
}
t.Log("Sandbox integration with Yao app successful")
}
// TestGetAccessors tests getter methods
func TestGetAccessors(t *testing.T) {
m := skipIfNoDocker(t)
// Test GetIPCManager
ipcMgr := m.GetIPCManager()
if ipcMgr == nil {
t.Error("GetIPCManager should not return nil")
}
// Test GetConfig
cfg := m.GetConfig()
if cfg == nil {
t.Error("GetConfig should not return nil")
}
if cfg.MaxContainers != 5 {
t.Errorf("Expected MaxContainers 5, got %d", cfg.MaxContainers)
}
}
// TestEnsureImageAutoPull tests that missing images are automatically pulled
func TestEnsureImageAutoPull(t *testing.T) {
workspaceRoot, ipcDir, tmpDir, err := getTestDirs("sandbox-autopull-*")
if err != nil {
t.Fatalf("Failed to create temp dir: %v", err)
}
if tmpDir != "" {
defer os.RemoveAll(tmpDir)
}
// Use a small known image for testing
cfg := &Config{
Image: "alpine:latest",
WorkspaceRoot: workspaceRoot,
IPCDir: ipcDir,
MaxContainers: 2,
IdleTimeout: 1 * time.Minute,
MaxMemory: "128m",
MaxCPU: 0.25,
ContainerUser: getContainerUser(),
}
m, err := NewManager(cfg)
if err != nil {
if strings.Contains(err.Error(), "Docker not available") {
t.Skip("Docker not available")
}
t.Fatalf("Failed to create manager: %v", err)
}
defer m.Close()
ctx := context.Background()
// Create container - should auto-pull alpine if not present
container, err := m.GetOrCreate(ctx, "autopull-user", "autopull-chat")
if err != nil {
t.Fatalf("GetOrCreate failed (should auto-pull image): %v", err)
}
defer m.Remove(ctx, container.Name)
// Verify container was created
if container.Status != StatusCreated {
t.Errorf("Expected status %s, got %s", StatusCreated, container.Status)
}
t.Log("Image auto-pull successful")
}