 8d9b62daf3
			
		
	
	8d9b62daf3
	
	
	
		
			
			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>
		
			
				
	
	
		
			1020 lines
		
	
	
		
			27 KiB
		
	
	
	
		
			Go
		
	
	
	
	
	
			
		
		
	
	
			1020 lines
		
	
	
		
			27 KiB
		
	
	
	
		
			Go
		
	
	
	
	
	
| package execution
 | |
| 
 | |
| import (
 | |
| 	"archive/tar"
 | |
| 	"bytes"
 | |
| 	"context"
 | |
| 	"encoding/json"
 | |
| 	"fmt"
 | |
| 	"io"
 | |
| 	"os"
 | |
| 	"path/filepath"
 | |
| 	"strconv"
 | |
| 	"strings"
 | |
| 	"time"
 | |
| 
 | |
| 	"github.com/docker/docker/api/types/container"
 | |
| 	"github.com/docker/docker/api/types/filters"
 | |
| 	"github.com/docker/docker/api/types/image"
 | |
| 	"github.com/docker/docker/api/types/mount"
 | |
| 	"github.com/docker/docker/api/types/network"
 | |
| 	"github.com/docker/docker/client"
 | |
| 	"github.com/docker/go-connections/nat"
 | |
| 	"github.com/docker/go-units"
 | |
| )
 | |
| 
 | |
| // DockerSandbox implements ExecutionSandbox using Docker containers
 | |
| type DockerSandbox struct {
 | |
| 	client      *client.Client
 | |
| 	containerID string
 | |
| 	config      *SandboxConfig
 | |
| 	info        SandboxInfo
 | |
| 	workingDir  string
 | |
| 	environment map[string]string
 | |
| 	tempDir     string
 | |
| }
 | |
| 
 | |
| // NewDockerSandbox creates a new Docker-based sandbox
 | |
| func NewDockerSandbox() *DockerSandbox {
 | |
| 	return &DockerSandbox{
 | |
| 		environment: make(map[string]string),
 | |
| 	}
 | |
| }
 | |
| 
 | |
| // Initialize sets up the Docker container sandbox
 | |
| func (d *DockerSandbox) Initialize(ctx context.Context, config *SandboxConfig) error {
 | |
| 	d.config = config
 | |
| 
 | |
| 	// Initialize Docker client
 | |
| 	cli, err := client.NewClientWithOpts(client.FromEnv, client.WithAPIVersionNegotiation())
 | |
| 	if err != nil {
 | |
| 		return NewSandboxErrorWithCause(ErrSandboxInitFailed, "failed to create Docker client", err)
 | |
| 	}
 | |
| 	d.client = cli
 | |
| 
 | |
| 	// Create temporary directory for file operations
 | |
| 	tempDir, err := os.MkdirTemp("", "chorus-sandbox-*")
 | |
| 	if err != nil {
 | |
| 		return NewSandboxErrorWithCause(ErrSandboxInitFailed, "failed to create temp directory", err)
 | |
| 	}
 | |
| 	d.tempDir = tempDir
 | |
| 
 | |
| 	// Pull image if needed
 | |
| 	if err := d.ensureImage(ctx); err != nil {
 | |
| 		return err
 | |
| 	}
 | |
| 
 | |
| 	// Create and start container
 | |
| 	if err := d.createContainer(ctx); err != nil {
 | |
| 		return err
 | |
| 	}
 | |
| 
 | |
| 	if err := d.startContainer(ctx); err != nil {
 | |
| 		return err
 | |
| 	}
 | |
| 
 | |
| 	// Set up repository if configured
 | |
| 	if config.Repository.URL != "" || config.Repository.LocalPath != "" {
 | |
| 		if err := d.setupRepository(ctx); err != nil {
 | |
| 			return err
 | |
| 		}
 | |
| 	}
 | |
| 
 | |
| 	// Configure environment and working directory
 | |
| 	d.workingDir = config.WorkingDir
 | |
| 	if d.workingDir == "" {
 | |
| 		d.workingDir = config.Repository.MountPoint
 | |
| 		if d.workingDir == "" {
 | |
| 			d.workingDir = "/workspace"
 | |
| 		}
 | |
| 	}
 | |
| 
 | |
| 	// Merge environment variables
 | |
| 	for k, v := range config.Environment {
 | |
| 		d.environment[k] = v
 | |
| 	}
 | |
| 
 | |
| 	d.info = SandboxInfo{
 | |
| 		ID:        d.containerID,
 | |
| 		Name:      fmt.Sprintf("chorus-sandbox-%s", d.containerID[:12]),
 | |
| 		Type:      "docker",
 | |
| 		Status:    StatusRunning,
 | |
| 		CreatedAt: time.Now(),
 | |
| 		StartedAt: time.Now(),
 | |
| 		Runtime:   "docker",
 | |
| 		Image:     config.Image,
 | |
| 		Platform:  config.Architecture,
 | |
| 		Config:    *config,
 | |
| 		Labels:    config.Labels,
 | |
| 		Annotations: config.Annotations,
 | |
| 	}
 | |
| 
 | |
| 	return nil
 | |
| }
 | |
| 
 | |
| // ExecuteCommand runs a command in the Docker container
 | |
| func (d *DockerSandbox) ExecuteCommand(ctx context.Context, cmd *Command) (*CommandResult, error) {
 | |
| 	if d.containerID == "" {
 | |
| 		return nil, NewSandboxError(ErrSandboxNotRunning, "container not initialized")
 | |
| 	}
 | |
| 
 | |
| 	startTime := time.Now()
 | |
| 
 | |
| 	// Build command arguments
 | |
| 	execCmd := []string{cmd.Executable}
 | |
| 	execCmd = append(execCmd, cmd.Args...)
 | |
| 
 | |
| 	// Prepare environment
 | |
| 	env := d.buildEnvironment(cmd.Environment)
 | |
| 
 | |
| 	// Set working directory
 | |
| 	workDir := cmd.WorkingDir
 | |
| 	if workDir == "" {
 | |
| 		workDir = d.workingDir
 | |
| 	}
 | |
| 
 | |
| 	// Create execution configuration
 | |
| 	execConfig := container.ExecOptions{
 | |
| 		User:         cmd.User,
 | |
| 		Privileged:   false,
 | |
| 		Tty:          false,
 | |
| 		AttachStdin:  cmd.Stdin != nil || cmd.StdinContent != "",
 | |
| 		AttachStdout: true,
 | |
| 		AttachStderr: true,
 | |
| 		Env:          env,
 | |
| 		WorkingDir:   workDir,
 | |
| 		Cmd:          execCmd,
 | |
| 	}
 | |
| 
 | |
| 	// Create exec instance
 | |
| 	exec, err := d.client.ContainerExecCreate(ctx, d.containerID, execConfig)
 | |
| 	if err != nil {
 | |
| 		return nil, NewSandboxErrorWithCause(ErrCommandExecutionFailed, "failed to create exec instance", err)
 | |
| 	}
 | |
| 
 | |
| 	// Attach to execution
 | |
| 	attachOptions := container.ExecAttachOptions{}
 | |
| 	resp, err := d.client.ContainerExecAttach(ctx, exec.ID, attachOptions)
 | |
| 	if err != nil {
 | |
| 		return nil, NewSandboxErrorWithCause(ErrCommandExecutionFailed, "failed to attach to exec", err)
 | |
| 	}
 | |
| 	defer resp.Close()
 | |
| 
 | |
| 	// Handle stdin if provided
 | |
| 	if cmd.Stdin != nil {
 | |
| 		go func() {
 | |
| 			defer resp.CloseWrite()
 | |
| 			io.Copy(resp.Conn, cmd.Stdin)
 | |
| 		}()
 | |
| 	} else if cmd.StdinContent != "" {
 | |
| 		go func() {
 | |
| 			defer resp.CloseWrite()
 | |
| 			io.WriteString(resp.Conn, cmd.StdinContent)
 | |
| 		}()
 | |
| 	}
 | |
| 
 | |
| 	// Read stdout and stderr
 | |
| 	var stdout, stderr bytes.Buffer
 | |
| 	var combined bytes.Buffer
 | |
| 
 | |
| 	// Use a multiplexed reader to separate stdout/stderr
 | |
| 	stdoutReader := io.TeeReader(resp.Reader, &combined)
 | |
| 	go func() {
 | |
| 		// Docker multiplexes stdout/stderr, we need to demultiplex
 | |
| 		d.demultiplexOutput(stdoutReader, &stdout, &stderr)
 | |
| 	}()
 | |
| 
 | |
| 	// Wait for execution with timeout
 | |
| 	execCtx := ctx
 | |
| 	if cmd.Timeout > 0 {
 | |
| 		var cancel context.CancelFunc
 | |
| 		execCtx, cancel = context.WithTimeout(ctx, cmd.Timeout)
 | |
| 		defer cancel()
 | |
| 	}
 | |
| 
 | |
| 	// Wait for completion
 | |
| 	for {
 | |
| 		inspect, err := d.client.ContainerExecInspect(execCtx, exec.ID)
 | |
| 		if err != nil {
 | |
| 			return nil, NewSandboxErrorWithCause(ErrCommandExecutionFailed, "failed to inspect exec", err)
 | |
| 		}
 | |
| 
 | |
| 		if !inspect.Running {
 | |
| 			endTime := time.Now()
 | |
| 
 | |
| 			// Get resource usage
 | |
| 			resourceUsage, _ := d.GetResourceUsage(ctx)
 | |
| 
 | |
| 			result := &CommandResult{
 | |
| 				ExitCode:      inspect.ExitCode,
 | |
| 				Success:       inspect.ExitCode == 0,
 | |
| 				Stdout:        stdout.String(),
 | |
| 				Stderr:        stderr.String(),
 | |
| 				Combined:      combined.String(),
 | |
| 				StartTime:     startTime,
 | |
| 				EndTime:       endTime,
 | |
| 				Duration:      endTime.Sub(startTime),
 | |
| 				ProcessID:     inspect.Pid,
 | |
| 			}
 | |
| 
 | |
| 			if resourceUsage != nil {
 | |
| 				result.ResourceUsage = *resourceUsage
 | |
| 			}
 | |
| 
 | |
| 			if inspect.ExitCode != 0 {
 | |
| 				result.Error = fmt.Sprintf("command exited with code %d", inspect.ExitCode)
 | |
| 			}
 | |
| 
 | |
| 			return result, nil
 | |
| 		}
 | |
| 
 | |
| 		// Check for timeout
 | |
| 		select {
 | |
| 		case <-execCtx.Done():
 | |
| 			return nil, NewSandboxError(ErrTimeoutExceeded, "command execution timed out")
 | |
| 		case <-time.After(100 * time.Millisecond):
 | |
| 			// Continue polling
 | |
| 		}
 | |
| 	}
 | |
| }
 | |
| 
 | |
| // CopyFiles copies files between host and container
 | |
| func (d *DockerSandbox) CopyFiles(ctx context.Context, source, dest string) error {
 | |
| 	if d.containerID == "" {
 | |
| 		return NewSandboxError(ErrSandboxNotRunning, "container not initialized")
 | |
| 	}
 | |
| 
 | |
| 	// Determine copy direction based on paths
 | |
| 	if strings.HasPrefix(source, "container:") {
 | |
| 		// Copy from container to host
 | |
| 		containerPath := strings.TrimPrefix(source, "container:")
 | |
| 		return d.copyFromContainer(ctx, containerPath, dest)
 | |
| 	} else if strings.HasPrefix(dest, "container:") {
 | |
| 		// Copy from host to container
 | |
| 		containerPath := strings.TrimPrefix(dest, "container:")
 | |
| 		return d.copyToContainer(ctx, source, containerPath)
 | |
| 	}
 | |
| 
 | |
| 	return NewSandboxError(ErrFileOperationFailed, "invalid copy paths: use container: prefix for container paths")
 | |
| }
 | |
| 
 | |
| // WriteFile writes content to a file in the container
 | |
| func (d *DockerSandbox) WriteFile(ctx context.Context, path string, content []byte, mode uint32) error {
 | |
| 	// Create a tar archive with the file
 | |
| 	buf := new(bytes.Buffer)
 | |
| 	tw := tar.NewWriter(buf)
 | |
| 
 | |
| 	header := &tar.Header{
 | |
| 		Name: filepath.Base(path),
 | |
| 		Mode: int64(mode),
 | |
| 		Size: int64(len(content)),
 | |
| 	}
 | |
| 
 | |
| 	if err := tw.WriteHeader(header); err != nil {
 | |
| 		return NewSandboxErrorWithCause(ErrFileOperationFailed, "failed to create tar header", err)
 | |
| 	}
 | |
| 
 | |
| 	if _, err := tw.Write(content); err != nil {
 | |
| 		return NewSandboxErrorWithCause(ErrFileOperationFailed, "failed to write file content", err)
 | |
| 	}
 | |
| 
 | |
| 	tw.Close()
 | |
| 
 | |
| 	// Copy to container
 | |
| 	containerDir := filepath.Dir(path)
 | |
| 	return d.client.CopyToContainer(ctx, d.containerID, containerDir, buf, container.CopyToContainerOptions{})
 | |
| }
 | |
| 
 | |
| // ReadFile reads content from a file in the container
 | |
| func (d *DockerSandbox) ReadFile(ctx context.Context, path string) ([]byte, error) {
 | |
| 	reader, _, err := d.client.CopyFromContainer(ctx, d.containerID, path)
 | |
| 	if err != nil {
 | |
| 		return nil, NewSandboxErrorWithCause(ErrFileOperationFailed, "failed to copy from container", err)
 | |
| 	}
 | |
| 	defer reader.Close()
 | |
| 
 | |
| 	// Extract from tar archive
 | |
| 	tr := tar.NewReader(reader)
 | |
| 	header, err := tr.Next()
 | |
| 	if err != nil {
 | |
| 		return nil, NewSandboxErrorWithCause(ErrFileOperationFailed, "failed to read tar header", err)
 | |
| 	}
 | |
| 
 | |
| 	content := make([]byte, header.Size)
 | |
| 	if _, err := io.ReadFull(tr, content); err != nil {
 | |
| 		return nil, NewSandboxErrorWithCause(ErrFileOperationFailed, "failed to read file content", err)
 | |
| 	}
 | |
| 
 | |
| 	return content, nil
 | |
| }
 | |
| 
 | |
| // ListFiles lists files in a directory within the container
 | |
| func (d *DockerSandbox) ListFiles(ctx context.Context, path string) ([]FileInfo, error) {
 | |
| 	// Use ls command to list files
 | |
| 	cmd := &Command{
 | |
| 		Executable: "ls",
 | |
| 		Args:       []string{"-la", "--time-style=+%Y-%m-%d %H:%M:%S", path},
 | |
| 	}
 | |
| 
 | |
| 	result, err := d.ExecuteCommand(ctx, cmd)
 | |
| 	if err != nil {
 | |
| 		return nil, err
 | |
| 	}
 | |
| 
 | |
| 	if !result.Success {
 | |
| 		return nil, NewSandboxError(ErrFileOperationFailed, "ls command failed: "+result.Stderr)
 | |
| 	}
 | |
| 
 | |
| 	// Parse ls output
 | |
| 	return d.parseLsOutput(result.Stdout, path)
 | |
| }
 | |
| 
 | |
| // GetWorkingDirectory returns the current working directory
 | |
| func (d *DockerSandbox) GetWorkingDirectory() string {
 | |
| 	return d.workingDir
 | |
| }
 | |
| 
 | |
| // SetWorkingDirectory changes the working directory
 | |
| func (d *DockerSandbox) SetWorkingDirectory(path string) error {
 | |
| 	d.workingDir = path
 | |
| 	return nil
 | |
| }
 | |
| 
 | |
| // GetEnvironment returns environment variables
 | |
| func (d *DockerSandbox) GetEnvironment() map[string]string {
 | |
| 	env := make(map[string]string)
 | |
| 	for k, v := range d.environment {
 | |
| 		env[k] = v
 | |
| 	}
 | |
| 	return env
 | |
| }
 | |
| 
 | |
| // SetEnvironment sets environment variables
 | |
| func (d *DockerSandbox) SetEnvironment(env map[string]string) error {
 | |
| 	for k, v := range env {
 | |
| 		d.environment[k] = v
 | |
| 	}
 | |
| 	return nil
 | |
| }
 | |
| 
 | |
| // GetResourceUsage returns current resource usage
 | |
| func (d *DockerSandbox) GetResourceUsage(ctx context.Context) (*ResourceUsage, error) {
 | |
| 	if d.containerID == "" {
 | |
| 		return nil, NewSandboxError(ErrSandboxNotRunning, "container not initialized")
 | |
| 	}
 | |
| 
 | |
| 	stats, err := d.client.ContainerStats(ctx, d.containerID, false)
 | |
| 	if err != nil {
 | |
| 		return nil, NewSandboxErrorWithCause(ErrSandboxInitFailed, "failed to get container stats", err)
 | |
| 	}
 | |
| 	defer stats.Body.Close()
 | |
| 
 | |
| 	var dockerStats container.StatsResponse
 | |
| 	if err := json.NewDecoder(stats.Body).Decode(&dockerStats); err != nil {
 | |
| 		return nil, NewSandboxErrorWithCause(ErrSandboxInitFailed, "failed to decode stats", err)
 | |
| 	}
 | |
| 
 | |
| 	// Calculate CPU usage percentage
 | |
| 	cpuDelta := float64(dockerStats.CPUStats.CPUUsage.TotalUsage - dockerStats.PreCPUStats.CPUUsage.TotalUsage)
 | |
| 	systemDelta := float64(dockerStats.CPUStats.SystemUsage - dockerStats.PreCPUStats.SystemUsage)
 | |
| 	cpuPercent := 0.0
 | |
| 	if systemDelta > 0 {
 | |
| 		cpuPercent = (cpuDelta / systemDelta) * float64(len(dockerStats.CPUStats.CPUUsage.PercpuUsage)) * 100.0
 | |
| 	}
 | |
| 
 | |
| 	// Calculate memory usage
 | |
| 	memUsage := dockerStats.MemoryStats.Usage
 | |
| 	if dockerStats.MemoryStats.Stats != nil {
 | |
| 		if cache, ok := dockerStats.MemoryStats.Stats["cache"]; ok {
 | |
| 			memUsage -= cache
 | |
| 		}
 | |
| 	}
 | |
| 
 | |
| 	memPercent := 0.0
 | |
| 	if dockerStats.MemoryStats.Limit > 0 {
 | |
| 		memPercent = float64(memUsage) / float64(dockerStats.MemoryStats.Limit) * 100.0
 | |
| 	}
 | |
| 
 | |
| 	return &ResourceUsage{
 | |
| 		Timestamp:     time.Now(),
 | |
| 		CPUUsage:      cpuPercent,
 | |
| 		CPUTime:       time.Duration(dockerStats.CPUStats.CPUUsage.TotalUsage),
 | |
| 		MemoryUsage:   int64(memUsage),
 | |
| 		MemoryPercent: memPercent,
 | |
| 		MemoryPeak:    int64(dockerStats.MemoryStats.MaxUsage),
 | |
| 		NetworkIn:     int64(dockerStats.Networks["eth0"].RxBytes),
 | |
| 		NetworkOut:    int64(dockerStats.Networks["eth0"].TxBytes),
 | |
| 		ProcessCount:  int(dockerStats.PidsStats.Current),
 | |
| 	}, nil
 | |
| }
 | |
| 
 | |
| // Cleanup destroys the container and cleans up resources
 | |
| func (d *DockerSandbox) Cleanup() error {
 | |
| 	if d.client == nil {
 | |
| 		return nil
 | |
| 	}
 | |
| 
 | |
| 	var lastErr error
 | |
| 
 | |
| 	// Stop container if running
 | |
| 	if d.containerID != "" {
 | |
| 		timeout := int(30) // 30 seconds
 | |
| 		if err := d.client.ContainerStop(context.Background(), d.containerID, container.StopOptions{
 | |
| 			Timeout: &timeout,
 | |
| 		}); err != nil {
 | |
| 			lastErr = err
 | |
| 		}
 | |
| 
 | |
| 		// Remove container
 | |
| 		if err := d.client.ContainerRemove(context.Background(), d.containerID, container.RemoveOptions{
 | |
| 			Force: true,
 | |
| 		}); err != nil {
 | |
| 			lastErr = err
 | |
| 		}
 | |
| 	}
 | |
| 
 | |
| 	// Clean up temporary directory
 | |
| 	if d.tempDir != "" {
 | |
| 		if err := os.RemoveAll(d.tempDir); err != nil {
 | |
| 			lastErr = err
 | |
| 		}
 | |
| 	}
 | |
| 
 | |
| 	// Close Docker client
 | |
| 	if err := d.client.Close(); err != nil {
 | |
| 		lastErr = err
 | |
| 	}
 | |
| 
 | |
| 	if lastErr != nil {
 | |
| 		return NewSandboxErrorWithCause(ErrSandboxInitFailed, "cleanup failed", lastErr)
 | |
| 	}
 | |
| 
 | |
| 	d.info.Status = StatusDestroyed
 | |
| 	return nil
 | |
| }
 | |
| 
 | |
| // GetInfo returns sandbox information
 | |
| func (d *DockerSandbox) GetInfo() SandboxInfo {
 | |
| 	return d.info
 | |
| }
 | |
| 
 | |
| // ensureImage pulls the Docker image if it doesn't exist
 | |
| func (d *DockerSandbox) ensureImage(ctx context.Context) error {
 | |
| 	// Check if image exists locally
 | |
| 	imageFilters := filters.NewArgs()
 | |
| 	imageFilters.Add("reference", d.config.Image)
 | |
| 	images, err := d.client.ImageList(ctx, image.ListOptions{
 | |
| 		Filters: imageFilters,
 | |
| 	})
 | |
| 	if err != nil {
 | |
| 		return NewSandboxErrorWithCause(ErrSandboxInitFailed, "failed to list images", err)
 | |
| 	}
 | |
| 
 | |
| 	// Pull image if not found
 | |
| 	if len(images) == 0 {
 | |
| 		reader, err := d.client.ImagePull(ctx, d.config.Image, image.PullOptions{})
 | |
| 		if err != nil {
 | |
| 			return NewSandboxErrorWithCause(ErrSandboxInitFailed, "failed to pull image", err)
 | |
| 		}
 | |
| 		defer reader.Close()
 | |
| 
 | |
| 		// Wait for pull to complete
 | |
| 		if _, err := io.ReadAll(reader); err != nil {
 | |
| 			return NewSandboxErrorWithCause(ErrSandboxInitFailed, "failed to read pull response", err)
 | |
| 		}
 | |
| 	}
 | |
| 
 | |
| 	return nil
 | |
| }
 | |
| 
 | |
| // createContainer creates the Docker container
 | |
| func (d *DockerSandbox) createContainer(ctx context.Context) error {
 | |
| 	// Build container configuration
 | |
| 	containerConfig := d.buildContainerConfig()
 | |
| 	hostConfig := d.buildHostConfig()
 | |
| 	networkConfig := d.buildNetworkConfig()
 | |
| 
 | |
| 	// Create container
 | |
| 	resp, err := d.client.ContainerCreate(ctx, containerConfig, hostConfig, networkConfig, nil, "")
 | |
| 	if err != nil {
 | |
| 		return NewSandboxErrorWithCause(ErrSandboxInitFailed, "failed to create container", err)
 | |
| 	}
 | |
| 
 | |
| 	d.containerID = resp.ID
 | |
| 	d.info.Status = StatusCreating
 | |
| 
 | |
| 	return nil
 | |
| }
 | |
| 
 | |
| // startContainer starts the Docker container
 | |
| func (d *DockerSandbox) startContainer(ctx context.Context) error {
 | |
| 	if err := d.client.ContainerStart(ctx, d.containerID, container.StartOptions{}); err != nil {
 | |
| 		return NewSandboxErrorWithCause(ErrSandboxInitFailed, "failed to start container", err)
 | |
| 	}
 | |
| 
 | |
| 	d.info.Status = StatusRunning
 | |
| 	return nil
 | |
| }
 | |
| 
 | |
| // buildContainerConfig builds the container configuration
 | |
| func (d *DockerSandbox) buildContainerConfig() *container.Config {
 | |
| 	env := d.buildEnvironment(d.config.Environment)
 | |
| 
 | |
| 	config := &container.Config{
 | |
| 		Image:        d.config.Image,
 | |
| 		Env:          env,
 | |
| 		WorkingDir:   d.config.WorkingDir,
 | |
| 		Cmd:          []string{"tail", "-f", "/dev/null"}, // Keep container running
 | |
| 		AttachStdin:  false,
 | |
| 		AttachStdout: false,
 | |
| 		AttachStderr: false,
 | |
| 		Tty:          false,
 | |
| 		OpenStdin:    false,
 | |
| 		StdinOnce:    false,
 | |
| 		Labels:       d.config.Labels,
 | |
| 	}
 | |
| 
 | |
| 	// Configure user if specified
 | |
| 	if d.config.Security.RunAsUser != "" {
 | |
| 		config.User = d.config.Security.RunAsUser
 | |
| 		if d.config.Security.RunAsGroup != "" {
 | |
| 			config.User = d.config.Security.RunAsUser + ":" + d.config.Security.RunAsGroup
 | |
| 		}
 | |
| 	}
 | |
| 
 | |
| 	// Add exposed ports
 | |
| 	if len(d.config.Network.PortMappings) > 0 {
 | |
| 		exposedPorts := make(nat.PortSet)
 | |
| 		for _, mapping := range d.config.Network.PortMappings {
 | |
| 			port := nat.Port(fmt.Sprintf("%d/%s", mapping.ContainerPort, mapping.Protocol))
 | |
| 			exposedPorts[port] = struct{}{}
 | |
| 		}
 | |
| 		config.ExposedPorts = exposedPorts
 | |
| 	}
 | |
| 
 | |
| 	return config
 | |
| }
 | |
| 
 | |
| // buildHostConfig builds the host configuration
 | |
| func (d *DockerSandbox) buildHostConfig() *container.HostConfig {
 | |
| 	// Configure resource limits
 | |
| 	resources := container.Resources{}
 | |
| 
 | |
| 	if d.config.Resources.MemoryLimit > 0 {
 | |
| 		resources.Memory = d.config.Resources.MemoryLimit
 | |
| 	}
 | |
| 	if d.config.Resources.MemoryRequest > 0 {
 | |
| 		resources.MemoryReservation = d.config.Resources.MemoryRequest
 | |
| 	}
 | |
| 	if d.config.Resources.CPULimit > 0 {
 | |
| 		resources.NanoCPUs = int64(d.config.Resources.CPULimit * 1e9)
 | |
| 	}
 | |
| 	if d.config.Resources.ProcessLimit > 0 {
 | |
| 		pidsLimit := int64(d.config.Resources.ProcessLimit)
 | |
| 		resources.PidsLimit = &pidsLimit
 | |
| 	}
 | |
| 
 | |
| 	// Configure ulimits for file handles and other limits
 | |
| 	ulimits := []*units.Ulimit{}
 | |
| 	if d.config.Resources.FileLimit > 0 {
 | |
| 		ulimits = append(ulimits, &units.Ulimit{
 | |
| 			Name: "nofile",
 | |
| 			Soft: int64(d.config.Resources.FileLimit),
 | |
| 			Hard: int64(d.config.Resources.FileLimit),
 | |
| 		})
 | |
| 	}
 | |
| 
 | |
| 	// Configure security options
 | |
| 	securityOpt := []string{}
 | |
| 	if d.config.Security.NoNewPrivileges {
 | |
| 		securityOpt = append(securityOpt, "no-new-privileges:true")
 | |
| 	}
 | |
| 	if d.config.Security.SELinuxContext != "" {
 | |
| 		securityOpt = append(securityOpt, "label="+d.config.Security.SELinuxContext)
 | |
| 	}
 | |
| 	if d.config.Security.AppArmorProfile != "" {
 | |
| 		securityOpt = append(securityOpt, "apparmor="+d.config.Security.AppArmorProfile)
 | |
| 	}
 | |
| 	if d.config.Security.SeccompProfile != "" {
 | |
| 		securityOpt = append(securityOpt, "seccomp="+d.config.Security.SeccompProfile)
 | |
| 	}
 | |
| 
 | |
| 	// Configure capabilities - default to secure by dropping all, then adding only necessary ones
 | |
| 	capAdd := []string{}
 | |
| 	capDrop := []string{}
 | |
| 
 | |
| 	if len(d.config.Security.DropCapabilities) > 0 {
 | |
| 		capDrop = append(capDrop, d.config.Security.DropCapabilities...)
 | |
| 	} else {
 | |
| 		// Default security: drop all capabilities
 | |
| 		capDrop = append(capDrop, "ALL")
 | |
| 	}
 | |
| 
 | |
| 	if len(d.config.Security.AddCapabilities) > 0 {
 | |
| 		capAdd = append(capAdd, d.config.Security.AddCapabilities...)
 | |
| 	} else if d.config.Security.AllowNetworking {
 | |
| 		// Add minimal networking capabilities if networking is allowed
 | |
| 		capAdd = append(capAdd, "NET_BIND_SERVICE")
 | |
| 	}
 | |
| 
 | |
| 	// Configure network settings
 | |
| 	networkMode := container.NetworkMode("bridge")
 | |
| 	if d.config.Security.IsolateNetwork || !d.config.Security.AllowNetworking {
 | |
| 		networkMode = container.NetworkMode("none")
 | |
| 	}
 | |
| 
 | |
| 	hostConfig := &container.HostConfig{
 | |
| 		RestartPolicy:    container.RestartPolicy{Name: "no"},
 | |
| 		NetworkMode:      networkMode,
 | |
| 		IpcMode:          container.IpcMode("private"),
 | |
| 		PidMode:          container.PidMode("private"),
 | |
| 		Resources:        resources,
 | |
| 		SecurityOpt:      securityOpt,
 | |
| 		CapAdd:           capAdd,
 | |
| 		CapDrop:          capDrop,
 | |
| 		ReadonlyRootfs:   d.config.Security.ReadOnlyRoot,
 | |
| 		UsernsMode:       container.UsernsMode("host"), // Required for proper file permissions
 | |
| 		AutoRemove:       false, // We handle cleanup manually
 | |
| 		Privileged:       false, // Never run privileged
 | |
| 		PublishAllPorts:  false,
 | |
| 	}
 | |
| 
 | |
| 	// Configure user if specified
 | |
| 	if d.config.Security.RunAsUser != "" {
 | |
| 		// Note: User is set in container config, not host config
 | |
| 	}
 | |
| 
 | |
| 	// Configure port mappings
 | |
| 	if len(d.config.Network.PortMappings) > 0 {
 | |
| 		portBindings := make(nat.PortMap)
 | |
| 		for _, mapping := range d.config.Network.PortMappings {
 | |
| 			containerPort := nat.Port(fmt.Sprintf("%d/%s", mapping.ContainerPort, mapping.Protocol))
 | |
| 			hostBinding := nat.PortBinding{
 | |
| 				HostPort: strconv.Itoa(mapping.HostPort),
 | |
| 			}
 | |
| 			portBindings[containerPort] = []nat.PortBinding{hostBinding}
 | |
| 		}
 | |
| 		hostConfig.PortBindings = portBindings
 | |
| 	}
 | |
| 
 | |
| 	// Configure mounts
 | |
| 	mounts := []mount.Mount{}
 | |
| 
 | |
| 	// Add repository mount if configured
 | |
| 	if d.config.Repository.LocalPath != "" {
 | |
| 		mountPoint := d.config.Repository.MountPoint
 | |
| 		if mountPoint == "" {
 | |
| 			mountPoint = "/workspace"
 | |
| 		}
 | |
| 
 | |
| 		mounts = append(mounts, mount.Mount{
 | |
| 			Type:     mount.TypeBind,
 | |
| 			Source:   d.config.Repository.LocalPath,
 | |
| 			Target:   mountPoint,
 | |
| 			ReadOnly: d.config.Repository.ReadOnly,
 | |
| 			BindOptions: &mount.BindOptions{
 | |
| 				Propagation: mount.PropagationRPrivate,
 | |
| 			},
 | |
| 		})
 | |
| 	}
 | |
| 
 | |
| 	// Add tmpfs mounts for security
 | |
| 	for _, tmpfsPath := range d.config.Security.TmpfsPaths {
 | |
| 		mounts = append(mounts, mount.Mount{
 | |
| 			Type:   mount.TypeTmpfs,
 | |
| 			Target: tmpfsPath,
 | |
| 			TmpfsOptions: &mount.TmpfsOptions{
 | |
| 				SizeBytes: 100 * 1024 * 1024, // 100MB default
 | |
| 				Mode:      0755,
 | |
| 			},
 | |
| 		})
 | |
| 	}
 | |
| 
 | |
| 	// Add masked paths (bind mount /dev/null over them)
 | |
| 	for _, maskedPath := range d.config.Security.MaskedPaths {
 | |
| 		mounts = append(mounts, mount.Mount{
 | |
| 			Type:     mount.TypeBind,
 | |
| 			Source:   "/dev/null",
 | |
| 			Target:   maskedPath,
 | |
| 			ReadOnly: true,
 | |
| 		})
 | |
| 	}
 | |
| 
 | |
| 	// Add read-only bind mounts
 | |
| 	for _, readOnlyPath := range d.config.Security.ReadOnlyPaths {
 | |
| 		mounts = append(mounts, mount.Mount{
 | |
| 			Type:     mount.TypeBind,
 | |
| 			Source:   readOnlyPath,
 | |
| 			Target:   readOnlyPath,
 | |
| 			ReadOnly: true,
 | |
| 			BindOptions: &mount.BindOptions{
 | |
| 				Propagation: mount.PropagationRPrivate,
 | |
| 			},
 | |
| 		})
 | |
| 	}
 | |
| 
 | |
| 	hostConfig.Mounts = mounts
 | |
| 
 | |
| 	return hostConfig
 | |
| }
 | |
| 
 | |
| // buildNetworkConfig builds the network configuration
 | |
| func (d *DockerSandbox) buildNetworkConfig() *network.NetworkingConfig {
 | |
| 	return &network.NetworkingConfig{}
 | |
| }
 | |
| 
 | |
| // setupRepository sets up the repository in the container
 | |
| func (d *DockerSandbox) setupRepository(ctx context.Context) error {
 | |
| 	if d.config.Repository.LocalPath == "" {
 | |
| 		return nil // No local repository to set up
 | |
| 	}
 | |
| 
 | |
| 	// Repository is mounted via volume, configure Git if needed
 | |
| 	if d.config.Repository.GitConfig.UserName != "" {
 | |
| 		gitCmds := [][]string{
 | |
| 			{"git", "config", "--global", "user.name", d.config.Repository.GitConfig.UserName},
 | |
| 			{"git", "config", "--global", "user.email", d.config.Repository.GitConfig.UserEmail},
 | |
| 		}
 | |
| 
 | |
| 		for key, value := range d.config.Repository.GitConfig.ConfigValues {
 | |
| 			gitCmds = append(gitCmds, []string{"git", "config", "--global", key, value})
 | |
| 		}
 | |
| 
 | |
| 		for _, cmdArgs := range gitCmds {
 | |
| 			cmd := &Command{
 | |
| 				Executable: cmdArgs[0],
 | |
| 				Args:       cmdArgs[1:],
 | |
| 			}
 | |
| 
 | |
| 			if _, err := d.ExecuteCommand(ctx, cmd); err != nil {
 | |
| 				return NewSandboxErrorWithCause(ErrSandboxInitFailed, "failed to configure git", err)
 | |
| 			}
 | |
| 		}
 | |
| 	}
 | |
| 
 | |
| 	return nil
 | |
| }
 | |
| 
 | |
| // buildEnvironment builds environment variable array
 | |
| func (d *DockerSandbox) buildEnvironment(additional map[string]string) []string {
 | |
| 	env := make([]string, 0)
 | |
| 
 | |
| 	// Add default environment
 | |
| 	for k, v := range d.environment {
 | |
| 		env = append(env, fmt.Sprintf("%s=%s", k, v))
 | |
| 	}
 | |
| 
 | |
| 	// Add additional environment
 | |
| 	for k, v := range additional {
 | |
| 		env = append(env, fmt.Sprintf("%s=%s", k, v))
 | |
| 	}
 | |
| 
 | |
| 	return env
 | |
| }
 | |
| 
 | |
| // copyToContainer copies files from host to container
 | |
| func (d *DockerSandbox) copyToContainer(ctx context.Context, sourcePath, destPath string) error {
 | |
| 	// Create tar archive
 | |
| 	buf := new(bytes.Buffer)
 | |
| 	tw := tar.NewWriter(buf)
 | |
| 	defer tw.Close()
 | |
| 
 | |
| 	err := filepath.Walk(sourcePath, func(path string, info os.FileInfo, err error) error {
 | |
| 		if err != nil {
 | |
| 			return err
 | |
| 		}
 | |
| 
 | |
| 		// Create tar header
 | |
| 		header, err := tar.FileInfoHeader(info, "")
 | |
| 		if err != nil {
 | |
| 			return err
 | |
| 		}
 | |
| 
 | |
| 		// Update header name to be relative
 | |
| 		relPath, err := filepath.Rel(sourcePath, path)
 | |
| 		if err != nil {
 | |
| 			return err
 | |
| 		}
 | |
| 		header.Name = relPath
 | |
| 
 | |
| 		if err := tw.WriteHeader(header); err != nil {
 | |
| 			return err
 | |
| 		}
 | |
| 
 | |
| 		// Copy file content
 | |
| 		if info.Mode().IsRegular() {
 | |
| 			file, err := os.Open(path)
 | |
| 			if err != nil {
 | |
| 				return err
 | |
| 			}
 | |
| 			defer file.Close()
 | |
| 
 | |
| 			if _, err := io.Copy(tw, file); err != nil {
 | |
| 				return err
 | |
| 			}
 | |
| 		}
 | |
| 
 | |
| 		return nil
 | |
| 	})
 | |
| 
 | |
| 	if err != nil {
 | |
| 		return NewSandboxErrorWithCause(ErrFileOperationFailed, "failed to create tar archive", err)
 | |
| 	}
 | |
| 
 | |
| 	// Copy to container
 | |
| 	return d.client.CopyToContainer(ctx, d.containerID, destPath, buf, container.CopyToContainerOptions{})
 | |
| }
 | |
| 
 | |
| // copyFromContainer copies files from container to host
 | |
| func (d *DockerSandbox) copyFromContainer(ctx context.Context, sourcePath, destPath string) error {
 | |
| 	reader, _, err := d.client.CopyFromContainer(ctx, d.containerID, sourcePath)
 | |
| 	if err != nil {
 | |
| 		return NewSandboxErrorWithCause(ErrFileOperationFailed, "failed to copy from container", err)
 | |
| 	}
 | |
| 	defer reader.Close()
 | |
| 
 | |
| 	// Extract tar archive
 | |
| 	tr := tar.NewReader(reader)
 | |
| 	for {
 | |
| 		header, err := tr.Next()
 | |
| 		if err == io.EOF {
 | |
| 			break
 | |
| 		}
 | |
| 		if err != nil {
 | |
| 			return NewSandboxErrorWithCause(ErrFileOperationFailed, "failed to read tar header", err)
 | |
| 		}
 | |
| 
 | |
| 		// Create destination path
 | |
| 		destFile := filepath.Join(destPath, header.Name)
 | |
| 		destDir := filepath.Dir(destFile)
 | |
| 
 | |
| 		if err := os.MkdirAll(destDir, 0755); err != nil {
 | |
| 			return NewSandboxErrorWithCause(ErrFileOperationFailed, "failed to create directory", err)
 | |
| 		}
 | |
| 
 | |
| 		// Create file
 | |
| 		file, err := os.Create(destFile)
 | |
| 		if err != nil {
 | |
| 			return NewSandboxErrorWithCause(ErrFileOperationFailed, "failed to create file", err)
 | |
| 		}
 | |
| 		defer file.Close()
 | |
| 
 | |
| 		// Copy content
 | |
| 		if _, err := io.Copy(file, tr); err != nil {
 | |
| 			return NewSandboxErrorWithCause(ErrFileOperationFailed, "failed to copy file content", err)
 | |
| 		}
 | |
| 
 | |
| 		// Set file mode
 | |
| 		if err := file.Chmod(os.FileMode(header.Mode)); err != nil {
 | |
| 			return NewSandboxErrorWithCause(ErrFileOperationFailed, "failed to set file mode", err)
 | |
| 		}
 | |
| 	}
 | |
| 
 | |
| 	return nil
 | |
| }
 | |
| 
 | |
| // demultiplexOutput separates Docker's multiplexed stdout/stderr
 | |
| func (d *DockerSandbox) demultiplexOutput(reader io.Reader, stdout, stderr io.Writer) error {
 | |
| 	buf := make([]byte, 8192)
 | |
| 	for {
 | |
| 		n, err := reader.Read(buf)
 | |
| 		if err != nil {
 | |
| 			if err == io.EOF {
 | |
| 				return nil
 | |
| 			}
 | |
| 			return err
 | |
| 		}
 | |
| 
 | |
| 		if n < 8 {
 | |
| 			continue // Not enough data for Docker header
 | |
| 		}
 | |
| 
 | |
| 		// Docker multiplexing format: [STREAM_TYPE, 0, 0, 0, SIZE1, SIZE2, SIZE3, SIZE4, DATA...]
 | |
| 		streamType := buf[0]
 | |
| 		size := int(buf[4])<<24 + int(buf[5])<<16 + int(buf[6])<<8 + int(buf[7])
 | |
| 
 | |
| 		if n < 8+size {
 | |
| 			continue // Incomplete frame
 | |
| 		}
 | |
| 
 | |
| 		data := buf[8 : 8+size]
 | |
| 
 | |
| 		switch streamType {
 | |
| 		case 1: // stdout
 | |
| 			stdout.Write(data)
 | |
| 		case 2: // stderr
 | |
| 			stderr.Write(data)
 | |
| 		}
 | |
| 
 | |
| 		// Continue with remaining data
 | |
| 		if n > 8+size {
 | |
| 			remaining := buf[8+size : n]
 | |
| 			reader = io.MultiReader(bytes.NewReader(remaining), reader)
 | |
| 		}
 | |
| 	}
 | |
| }
 | |
| 
 | |
| // parseLsOutput parses ls command output into FileInfo structs
 | |
| func (d *DockerSandbox) parseLsOutput(output, basePath string) ([]FileInfo, error) {
 | |
| 	lines := strings.Split(strings.TrimSpace(output), "\n")
 | |
| 	files := make([]FileInfo, 0, len(lines))
 | |
| 
 | |
| 	for _, line := range lines {
 | |
| 		if strings.HasPrefix(line, "total ") || line == "" {
 | |
| 			continue
 | |
| 		}
 | |
| 
 | |
| 		fields := strings.Fields(line)
 | |
| 		if len(fields) < 9 {
 | |
| 			continue
 | |
| 		}
 | |
| 
 | |
| 		// Parse file info from ls output
 | |
| 		permissions := fields[0]
 | |
| 		owner := fields[2]
 | |
| 		group := fields[3]
 | |
| 		sizeStr := fields[4]
 | |
| 		name := strings.Join(fields[8:], " ")
 | |
| 
 | |
| 		size, _ := strconv.ParseInt(sizeStr, 10, 64)
 | |
| 		isDir := strings.HasPrefix(permissions, "d")
 | |
| 
 | |
| 		// Convert permissions to mode
 | |
| 		mode := d.parsePermissions(permissions)
 | |
| 
 | |
| 		files = append(files, FileInfo{
 | |
| 			Name:        name,
 | |
| 			Path:        filepath.Join(basePath, name),
 | |
| 			Size:        size,
 | |
| 			Mode:        mode,
 | |
| 			IsDir:       isDir,
 | |
| 			Owner:       owner,
 | |
| 			Group:       group,
 | |
| 			Permissions: permissions,
 | |
| 		})
 | |
| 	}
 | |
| 
 | |
| 	return files, nil
 | |
| }
 | |
| 
 | |
| // parsePermissions converts ls permission string to mode
 | |
| func (d *DockerSandbox) parsePermissions(perms string) uint32 {
 | |
| 	mode := uint32(0)
 | |
| 
 | |
| 	if len(perms) < 10 {
 | |
| 		return mode
 | |
| 	}
 | |
| 
 | |
| 	// File type
 | |
| 	switch perms[0] {
 | |
| 	case 'd':
 | |
| 		mode |= 0040000 // Directory
 | |
| 	case 'l':
 | |
| 		mode |= 0120000 // Symbolic link
 | |
| 	case 'p':
 | |
| 		mode |= 0010000 // Named pipe
 | |
| 	case 's':
 | |
| 		mode |= 0140000 // Socket
 | |
| 	case 'b':
 | |
| 		mode |= 0060000 // Block device
 | |
| 	case 'c':
 | |
| 		mode |= 0020000 // Character device
 | |
| 	default:
 | |
| 		mode |= 0100000 // Regular file
 | |
| 	}
 | |
| 
 | |
| 	// Owner permissions
 | |
| 	if perms[1] == 'r' {
 | |
| 		mode |= 0400
 | |
| 	}
 | |
| 	if perms[2] == 'w' {
 | |
| 		mode |= 0200
 | |
| 	}
 | |
| 	if perms[3] == 'x' {
 | |
| 		mode |= 0100
 | |
| 	}
 | |
| 
 | |
| 	// Group permissions
 | |
| 	if perms[4] == 'r' {
 | |
| 		mode |= 0040
 | |
| 	}
 | |
| 	if perms[5] == 'w' {
 | |
| 		mode |= 0020
 | |
| 	}
 | |
| 	if perms[6] == 'x' {
 | |
| 		mode |= 0010
 | |
| 	}
 | |
| 
 | |
| 	// Other permissions
 | |
| 	if perms[7] == 'r' {
 | |
| 		mode |= 0004
 | |
| 	}
 | |
| 	if perms[8] == 'w' {
 | |
| 		mode |= 0002
 | |
| 	}
 | |
| 	if perms[9] == 'x' {
 | |
| 		mode |= 0001
 | |
| 	}
 | |
| 
 | |
| 	return mode
 | |
| } |