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