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
|
|
} |