package host import ( "bytes" "context" "fmt" "os/exec" "strings" "time" ) // DockerBackend executes commands inside a Docker container, // providing a full Linux OS environment with container-level isolation. type DockerBackend struct { container string image string timeout time.Duration } // NewDockerBackend creates a Docker backend that runs commands in the // specified container. If the container does not exist, it will be // created from the given image. func NewDockerBackend(container, image string, defaultTimeout time.Duration) *DockerBackend { if defaultTimeout <= 0 { defaultTimeout = 30 * time.Second } return &DockerBackend{ container: container, image: image, timeout: defaultTimeout, } } func (b *DockerBackend) Name() string { return "docker" } // ensureContainer checks that the container exists and is running. // If it doesn't exist, it creates it from the configured image. func (b *DockerBackend) ensureContainer() error { // Check if container exists and is running check := exec.Command("docker", "inspect", "-f", "{{.State.Running}}", b.container) out, err := check.Output() if err == nil && strings.TrimSpace(string(out)) == "true" { return nil } // Check if container exists but is stopped if err == nil && strings.TrimSpace(string(out)) == "false" { start := exec.Command("docker", "start", b.container) if out, err := start.CombinedOutput(); err != nil { return fmt.Errorf("cannot start container %s: %s — %w", b.container, string(out), err) } return nil } // Create and start a new container create := exec.Command("docker", "run", "-d", "--name", b.container, "--restart", "unless-stopped", b.image, "sleep", "infinity") if out, err := create.CombinedOutput(); err != nil { return fmt.Errorf("cannot create container %s from image %s: %s — %w", b.container, b.image, string(out), err) } return nil } // Exec runs a command inside the Docker container. func (b *DockerBackend) Exec(ctx context.Context, command, workDir string, timeout time.Duration) (*ExecResult, error) { if command == "" { return nil, fmt.Errorf("empty command") } if err := b.ensureContainer(); err != nil { return nil, err } if timeout <= 0 { timeout = b.timeout } execCtx, cancel := context.WithTimeout(ctx, timeout) defer cancel() // Build the shell command to run inside the container script := command if workDir != "" { script = fmt.Sprintf("cd %s && %s", shellEscapeDocker(workDir), command) } cmd := exec.CommandContext(execCtx, "docker", "exec", b.container, "sh", "-c", script) var stdout, stderr bytes.Buffer cmd.Stdout = &stdout cmd.Stderr = &stderr start := time.Now() err := cmd.Run() elapsed := time.Since(start) result := &ExecResult{ Duration: elapsed.Round(time.Millisecond).String(), Stdout: stdout.String(), Stderr: stderr.String(), } if execCtx.Err() == context.DeadlineExceeded { result.TimedOut = true result.ExitCode = -1 return result, fmt.Errorf("command timed out after %s", timeout) } if err != nil { if exitErr, ok := err.(*exec.ExitError); ok { result.ExitCode = exitErr.ExitCode() } else { result.ExitCode = -1 } } else { result.ExitCode = 0 } return result, err } // ReadFile reads a file from inside the container using cat. func (b *DockerBackend) ReadFile(path string, maxBytes int) (string, error) { if maxBytes <= 0 { maxBytes = 1024 * 1024 } if err := b.ensureContainer(); err != nil { return "", err } ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) defer cancel() cmd := exec.CommandContext(ctx, "docker", "exec", b.container, "cat", path) out, err := cmd.Output() if err != nil { return "", fmt.Errorf("cannot read file %s: %w", path, err) } if len(out) > maxBytes { out = out[:maxBytes] } return string(out), nil } // WriteFile writes content to a file inside the container. func (b *DockerBackend) WriteFile(path, content string, maxBytes int) error { if maxBytes <= 0 { maxBytes = 1024 * 1024 } if len(content) > maxBytes { return fmt.Errorf("content too large: %d bytes (max %d)", len(content), maxBytes) } if err := b.ensureContainer(); err != nil { return err } ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) defer cancel() // Create parent directory and write file cmd := exec.CommandContext(ctx, "docker", "exec", "-i", b.container, "sh", "-c", fmt.Sprintf("mkdir -p $(dirname %s) && cat > %s", shellEscapeDocker(path), shellEscapeDocker(path))) cmd.Stdin = strings.NewReader(content) _, err := cmd.Output() if err != nil { return fmt.Errorf("cannot write file %s: %w", path, err) } return nil } // ListDir lists a directory inside the container. func (b *DockerBackend) ListDir(path string) ([]DirEntry, error) { if err := b.ensureContainer(); err != nil { return nil, err } ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) defer cancel() cmd := exec.CommandContext(ctx, "docker", "exec", b.container, "sh", "-c", fmt.Sprintf("ls -la %s 2>/dev/null | tail -n +2 || echo ''", shellEscapeDocker(path))) out, err := cmd.Output() if err != nil { return nil, fmt.Errorf("cannot list dir %s: %w", path, err) } lines := strings.Split(strings.TrimSpace(string(out)), "\n") result := make([]DirEntry, 0, len(lines)) for _, line := range lines { if line == "" || strings.HasPrefix(line, "total ") { continue } // Parse ls -la output: drwxr-xr-x 2 root root 4096 Jan 1 12:00 name fields := strings.Fields(line) if len(fields) < 9 { continue } isDir := strings.HasPrefix(fields[0], "d") name := fields[len(fields)-1] if name == "." || name == ".." { continue } var size int64 fmt.Sscanf(fields[4], "%d", &size) result = append(result, DirEntry{ Name: name, IsDir: isDir, Size: size, }) } return result, nil } // SystemInfo returns system information from inside the container. func (b *DockerBackend) SystemInfo() map[string]interface{} { info := map[string]interface{}{ "backend": "docker", "container": b.container, "image": b.image, } if err := b.ensureContainer(); err != nil { info["error"] = err.Error() return info } ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) defer cancel() if out, err := exec.CommandContext(ctx, "docker", "exec", b.container, "uname", "-a").Output(); err == nil { info["uname"] = strings.TrimSpace(string(out)) } if out, err := exec.CommandContext(ctx, "docker", "exec", b.container, "hostname").Output(); err == nil { info["hostname"] = strings.TrimSpace(string(out)) } if out, err := exec.CommandContext(ctx, "docker", "exec", b.container, "free", "-h").Output(); err == nil { info["memory"] = strings.TrimSpace(string(out)) } if out, err := exec.CommandContext(ctx, "docker", "exec", b.container, "df", "-h", "/").Output(); err == nil { lines := strings.Split(strings.TrimSpace(string(out)), "\n") if len(lines) > 1 { info["disk"] = strings.TrimSpace(lines[1]) } } return info } // DiskUsage returns disk usage for a path inside the container. func (b *DockerBackend) DiskUsage(path string) (map[string]interface{}, error) { if err := b.ensureContainer(); err != nil { return nil, err } ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) defer cancel() cmd := exec.CommandContext(ctx, "docker", "exec", b.container, "stat", path) out, err := cmd.Output() if err != nil { return nil, fmt.Errorf("cannot stat path %s: %w", path, err) } return map[string]interface{}{ "path": path, "stat": strings.TrimSpace(string(out)), }, nil } // shellEscapeDocker escapes a string for safe use in a shell command. func shellEscapeDocker(s string) string { escaped := strings.ReplaceAll(s, "'", "'\\''") return "'" + escaped + "'" }