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

## New Features

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

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

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

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

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

## Technical Details

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

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

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

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

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

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

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
}