Files
CHORUS/pkg/execution/docker_test.go
anthonyrawlins 8d9b62daf3 Phase 2: Implement Execution Environment Abstraction (v0.3.0)
This commit implements Phase 2 of the CHORUS Task Execution Engine development plan,
providing a comprehensive execution environment abstraction layer with Docker
container sandboxing support.

## New Features

### Core Sandbox Interface
- Comprehensive ExecutionSandbox interface with isolated task execution
- Support for command execution, file I/O, environment management
- Resource usage monitoring and sandbox lifecycle management
- Standardized error handling with SandboxError types and categories

### Docker Container Sandbox Implementation
- Full Docker API integration with secure container creation
- Transparent repository mounting with configurable read/write access
- Advanced security policies with capability dropping and privilege controls
- Comprehensive resource limits (CPU, memory, disk, processes, file handles)
- Support for tmpfs mounts, masked paths, and read-only bind mounts
- Container lifecycle management with proper cleanup and health monitoring

### Security & Resource Management
- Configurable security policies with SELinux, AppArmor, and Seccomp support
- Fine-grained capability management with secure defaults
- Network isolation options with configurable DNS and proxy settings
- Resource monitoring with real-time CPU, memory, and network usage tracking
- Comprehensive ulimits configuration for process and file handle limits

### Repository Integration
- Seamless repository mounting from local paths to container workspaces
- Git configuration support with user credentials and global settings
- File inclusion/exclusion patterns for selective repository access
- Configurable permissions and ownership for mounted repositories

### Testing Infrastructure
- Comprehensive test suite with 60+ test cases covering all functionality
- Docker integration tests with Alpine Linux containers (skipped in short mode)
- Mock sandbox implementation for unit testing without Docker dependencies
- Security policy validation tests with read-only filesystem enforcement
- Resource usage monitoring and cleanup verification tests

## Technical Details

### Dependencies Added
- github.com/docker/docker v28.4.0+incompatible - Docker API client
- github.com/docker/go-connections v0.6.0 - Docker connection utilities
- github.com/docker/go-units v0.5.0 - Docker units and formatting
- Associated Docker API dependencies for complete container management

### Architecture
- Interface-driven design enabling multiple sandbox implementations
- Comprehensive configuration structures for all sandbox aspects
- Resource usage tracking with detailed metrics collection
- Error handling with retryable error classification
- Proper cleanup and resource management throughout sandbox lifecycle

### Compatibility
- Maintains backward compatibility with existing CHORUS architecture
- Designed for future integration with Phase 3 Core Task Execution Engine
- Extensible design supporting additional sandbox implementations (VM, process)

This Phase 2 implementation provides the foundation for secure, isolated task
execution that will be integrated with the AI model providers from Phase 1
in the upcoming Phase 3 development.

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-25 14:28:08 +10:00

482 lines
12 KiB
Go

package execution
import (
"context"
"os"
"path/filepath"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestNewDockerSandbox(t *testing.T) {
sandbox := NewDockerSandbox()
assert.NotNil(t, sandbox)
assert.NotNil(t, sandbox.environment)
assert.Empty(t, sandbox.containerID)
}
func TestDockerSandbox_Initialize(t *testing.T) {
if testing.Short() {
t.Skip("Skipping Docker integration test in short mode")
}
sandbox := NewDockerSandbox()
ctx := context.Background()
// Create a minimal configuration
config := &SandboxConfig{
Type: "docker",
Image: "alpine:latest",
Architecture: "amd64",
Resources: ResourceLimits{
MemoryLimit: 512 * 1024 * 1024, // 512MB
CPULimit: 1.0,
ProcessLimit: 50,
FileLimit: 1024,
},
Security: SecurityPolicy{
ReadOnlyRoot: false,
NoNewPrivileges: true,
AllowNetworking: false,
IsolateNetwork: true,
IsolateProcess: true,
DropCapabilities: []string{"ALL"},
},
Environment: map[string]string{
"TEST_VAR": "test_value",
},
WorkingDir: "/workspace",
Timeout: 30 * time.Second,
}
err := sandbox.Initialize(ctx, config)
if err != nil {
t.Skipf("Docker not available or image pull failed: %v", err)
}
defer sandbox.Cleanup()
// Verify sandbox is initialized
assert.NotEmpty(t, sandbox.containerID)
assert.Equal(t, config, sandbox.config)
assert.Equal(t, StatusRunning, sandbox.info.Status)
assert.Equal(t, "docker", sandbox.info.Type)
}
func TestDockerSandbox_ExecuteCommand(t *testing.T) {
if testing.Short() {
t.Skip("Skipping Docker integration test in short mode")
}
sandbox := setupTestSandbox(t)
defer sandbox.Cleanup()
ctx := context.Background()
tests := []struct {
name string
cmd *Command
expectedExit int
expectedOutput string
shouldError bool
}{
{
name: "simple echo command",
cmd: &Command{
Executable: "echo",
Args: []string{"hello world"},
},
expectedExit: 0,
expectedOutput: "hello world\n",
},
{
name: "command with environment",
cmd: &Command{
Executable: "sh",
Args: []string{"-c", "echo $TEST_VAR"},
Environment: map[string]string{"TEST_VAR": "custom_value"},
},
expectedExit: 0,
expectedOutput: "custom_value\n",
},
{
name: "failing command",
cmd: &Command{
Executable: "sh",
Args: []string{"-c", "exit 1"},
},
expectedExit: 1,
},
{
name: "command with timeout",
cmd: &Command{
Executable: "sleep",
Args: []string{"2"},
Timeout: 1 * time.Second,
},
shouldError: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result, err := sandbox.ExecuteCommand(ctx, tt.cmd)
if tt.shouldError {
assert.Error(t, err)
return
}
require.NoError(t, err)
assert.Equal(t, tt.expectedExit, result.ExitCode)
assert.Equal(t, tt.expectedExit == 0, result.Success)
if tt.expectedOutput != "" {
assert.Equal(t, tt.expectedOutput, result.Stdout)
}
assert.NotZero(t, result.Duration)
assert.False(t, result.StartTime.IsZero())
assert.False(t, result.EndTime.IsZero())
})
}
}
func TestDockerSandbox_FileOperations(t *testing.T) {
if testing.Short() {
t.Skip("Skipping Docker integration test in short mode")
}
sandbox := setupTestSandbox(t)
defer sandbox.Cleanup()
ctx := context.Background()
// Test WriteFile
testContent := []byte("Hello, Docker sandbox!")
testPath := "/tmp/test_file.txt"
err := sandbox.WriteFile(ctx, testPath, testContent, 0644)
require.NoError(t, err)
// Test ReadFile
readContent, err := sandbox.ReadFile(ctx, testPath)
require.NoError(t, err)
assert.Equal(t, testContent, readContent)
// Test ListFiles
files, err := sandbox.ListFiles(ctx, "/tmp")
require.NoError(t, err)
assert.NotEmpty(t, files)
// Find our test file
var testFile *FileInfo
for _, file := range files {
if file.Name == "test_file.txt" {
testFile = &file
break
}
}
require.NotNil(t, testFile)
assert.Equal(t, "test_file.txt", testFile.Name)
assert.Equal(t, int64(len(testContent)), testFile.Size)
assert.False(t, testFile.IsDir)
}
func TestDockerSandbox_CopyFiles(t *testing.T) {
if testing.Short() {
t.Skip("Skipping Docker integration test in short mode")
}
sandbox := setupTestSandbox(t)
defer sandbox.Cleanup()
ctx := context.Background()
// Create a temporary file on host
tempDir := t.TempDir()
hostFile := filepath.Join(tempDir, "host_file.txt")
hostContent := []byte("Content from host")
err := os.WriteFile(hostFile, hostContent, 0644)
require.NoError(t, err)
// Copy from host to container
containerPath := "container:/tmp/copied_file.txt"
err = sandbox.CopyFiles(ctx, hostFile, containerPath)
require.NoError(t, err)
// Verify file exists in container
readContent, err := sandbox.ReadFile(ctx, "/tmp/copied_file.txt")
require.NoError(t, err)
assert.Equal(t, hostContent, readContent)
// Copy from container back to host
hostDestFile := filepath.Join(tempDir, "copied_back.txt")
err = sandbox.CopyFiles(ctx, "container:/tmp/copied_file.txt", hostDestFile)
require.NoError(t, err)
// Verify file exists on host
backContent, err := os.ReadFile(hostDestFile)
require.NoError(t, err)
assert.Equal(t, hostContent, backContent)
}
func TestDockerSandbox_Environment(t *testing.T) {
if testing.Short() {
t.Skip("Skipping Docker integration test in short mode")
}
sandbox := setupTestSandbox(t)
defer sandbox.Cleanup()
// Test getting initial environment
env := sandbox.GetEnvironment()
assert.Equal(t, "test_value", env["TEST_VAR"])
// Test setting additional environment
newEnv := map[string]string{
"NEW_VAR": "new_value",
"PATH": "/custom/path",
}
err := sandbox.SetEnvironment(newEnv)
require.NoError(t, err)
// Verify environment is updated
env = sandbox.GetEnvironment()
assert.Equal(t, "new_value", env["NEW_VAR"])
assert.Equal(t, "/custom/path", env["PATH"])
assert.Equal(t, "test_value", env["TEST_VAR"]) // Original should still be there
}
func TestDockerSandbox_WorkingDirectory(t *testing.T) {
if testing.Short() {
t.Skip("Skipping Docker integration test in short mode")
}
sandbox := setupTestSandbox(t)
defer sandbox.Cleanup()
// Test getting initial working directory
workDir := sandbox.GetWorkingDirectory()
assert.Equal(t, "/workspace", workDir)
// Test setting working directory
newWorkDir := "/tmp"
err := sandbox.SetWorkingDirectory(newWorkDir)
require.NoError(t, err)
// Verify working directory is updated
workDir = sandbox.GetWorkingDirectory()
assert.Equal(t, newWorkDir, workDir)
}
func TestDockerSandbox_ResourceUsage(t *testing.T) {
if testing.Short() {
t.Skip("Skipping Docker integration test in short mode")
}
sandbox := setupTestSandbox(t)
defer sandbox.Cleanup()
ctx := context.Background()
// Get resource usage
usage, err := sandbox.GetResourceUsage(ctx)
require.NoError(t, err)
// Verify usage structure
assert.NotNil(t, usage)
assert.False(t, usage.Timestamp.IsZero())
assert.GreaterOrEqual(t, usage.CPUUsage, 0.0)
assert.GreaterOrEqual(t, usage.MemoryUsage, int64(0))
assert.GreaterOrEqual(t, usage.MemoryPercent, 0.0)
}
func TestDockerSandbox_GetInfo(t *testing.T) {
if testing.Short() {
t.Skip("Skipping Docker integration test in short mode")
}
sandbox := setupTestSandbox(t)
defer sandbox.Cleanup()
info := sandbox.GetInfo()
assert.NotEmpty(t, info.ID)
assert.Contains(t, info.Name, "chorus-sandbox")
assert.Equal(t, "docker", info.Type)
assert.Equal(t, StatusRunning, info.Status)
assert.Equal(t, "docker", info.Runtime)
assert.Equal(t, "alpine:latest", info.Image)
assert.False(t, info.CreatedAt.IsZero())
assert.False(t, info.StartedAt.IsZero())
}
func TestDockerSandbox_Cleanup(t *testing.T) {
if testing.Short() {
t.Skip("Skipping Docker integration test in short mode")
}
sandbox := setupTestSandbox(t)
// Verify sandbox is running
assert.Equal(t, StatusRunning, sandbox.info.Status)
assert.NotEmpty(t, sandbox.containerID)
// Cleanup
err := sandbox.Cleanup()
require.NoError(t, err)
// Verify sandbox is destroyed
assert.Equal(t, StatusDestroyed, sandbox.info.Status)
}
func TestDockerSandbox_SecurityPolicies(t *testing.T) {
if testing.Short() {
t.Skip("Skipping Docker integration test in short mode")
}
sandbox := NewDockerSandbox()
ctx := context.Background()
// Create configuration with strict security policies
config := &SandboxConfig{
Type: "docker",
Image: "alpine:latest",
Architecture: "amd64",
Resources: ResourceLimits{
MemoryLimit: 256 * 1024 * 1024, // 256MB
CPULimit: 0.5,
ProcessLimit: 10,
FileLimit: 256,
},
Security: SecurityPolicy{
ReadOnlyRoot: true,
NoNewPrivileges: true,
AllowNetworking: false,
IsolateNetwork: true,
IsolateProcess: true,
DropCapabilities: []string{"ALL"},
RunAsUser: "1000",
RunAsGroup: "1000",
TmpfsPaths: []string{"/tmp", "/var/tmp"},
MaskedPaths: []string{"/proc/kcore", "/proc/keys"},
ReadOnlyPaths: []string{"/etc"},
},
WorkingDir: "/workspace",
Timeout: 30 * time.Second,
}
err := sandbox.Initialize(ctx, config)
if err != nil {
t.Skipf("Docker not available or security policies not supported: %v", err)
}
defer sandbox.Cleanup()
// Test that we can't write to read-only filesystem
result, err := sandbox.ExecuteCommand(ctx, &Command{
Executable: "touch",
Args: []string{"/test_readonly"},
})
require.NoError(t, err)
assert.NotEqual(t, 0, result.ExitCode) // Should fail due to read-only root
// Test that tmpfs is writable
result, err = sandbox.ExecuteCommand(ctx, &Command{
Executable: "touch",
Args: []string{"/tmp/test_tmpfs"},
})
require.NoError(t, err)
assert.Equal(t, 0, result.ExitCode) // Should succeed on tmpfs
}
// setupTestSandbox creates a basic Docker sandbox for testing
func setupTestSandbox(t *testing.T) *DockerSandbox {
sandbox := NewDockerSandbox()
ctx := context.Background()
config := &SandboxConfig{
Type: "docker",
Image: "alpine:latest",
Architecture: "amd64",
Resources: ResourceLimits{
MemoryLimit: 512 * 1024 * 1024, // 512MB
CPULimit: 1.0,
ProcessLimit: 50,
FileLimit: 1024,
},
Security: SecurityPolicy{
ReadOnlyRoot: false,
NoNewPrivileges: true,
AllowNetworking: true, // Allow networking for easier testing
IsolateNetwork: false,
IsolateProcess: true,
DropCapabilities: []string{"NET_ADMIN", "SYS_ADMIN"},
},
Environment: map[string]string{
"TEST_VAR": "test_value",
},
WorkingDir: "/workspace",
Timeout: 30 * time.Second,
}
err := sandbox.Initialize(ctx, config)
if err != nil {
t.Skipf("Docker not available: %v", err)
}
return sandbox
}
// Benchmark tests
func BenchmarkDockerSandbox_ExecuteCommand(b *testing.B) {
if testing.Short() {
b.Skip("Skipping Docker benchmark in short mode")
}
sandbox := &DockerSandbox{}
ctx := context.Background()
// Setup minimal config for benchmarking
config := &SandboxConfig{
Type: "docker",
Image: "alpine:latest",
Architecture: "amd64",
Resources: ResourceLimits{
MemoryLimit: 256 * 1024 * 1024,
CPULimit: 1.0,
ProcessLimit: 50,
},
Security: SecurityPolicy{
NoNewPrivileges: true,
AllowNetworking: true,
},
WorkingDir: "/workspace",
Timeout: 10 * time.Second,
}
err := sandbox.Initialize(ctx, config)
if err != nil {
b.Skipf("Docker not available: %v", err)
}
defer sandbox.Cleanup()
cmd := &Command{
Executable: "echo",
Args: []string{"benchmark test"},
}
b.ResetTimer()
for i := 0; i < b.N; i++ {
_, err := sandbox.ExecuteCommand(ctx, cmd)
if err != nil {
b.Fatalf("Command execution failed: %v", err)
}
}
}