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>
This commit is contained in:
482
pkg/execution/docker_test.go
Normal file
482
pkg/execution/docker_test.go
Normal file
@@ -0,0 +1,482 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user