From 8edae4df98f35506833c52fc7139d94ee39f3705 Mon Sep 17 00:00:00 2001 From: Dwi Siswanto Date: Wed, 4 Nov 2026 12:49:37 +0700 Subject: [PATCH 1/2] fix(sandbox): pass args and stdin to Docker containers EvalWithVirtualEnv with VirtualEnvDocker gave the container only the source text and an interpreter. The call args, Options.Args and the input source never reached it, so a script that Eval runs as "engine Options.Args script args < input" ran in Docker with no arguments, no interpreter flags and no stdin. SandboxDocker had no way to take them: RunSource accepted only the source and an interpreter, and the container was created without stdin. RunSourceWithOptions also takes interpreter args, script args and a stdin reader, and EvalWithVirtualEnv passes all three. The arguments reach the container as positional parameters of the wrapper shell, which execs "$@", so the shell never parses them. Stdin is attached before the container starts and closed once the input is copied. RunSource keeps its behavior, and a source with a shebang still runs directly, without the interpreter and its args. runCommand loses its unused createFile and fileContent parameters. Fixes: #46 Signed-off-by: Dwi Siswanto --- gozero.go | 14 +- sandbox/virtual_env_docker.go | 126 +++++++++---- virtual_env_test.go | 339 ++++++++++++++++++++++++++++++++++ 3 files changed, 441 insertions(+), 38 deletions(-) create mode 100644 virtual_env_test.go diff --git a/gozero.go b/gozero.go index ad0f028..097dd21 100644 --- a/gozero.go +++ b/gozero.go @@ -73,8 +73,11 @@ func (g *Gozero) Eval(ctx context.Context, src, input *Source, args ...string) ( return gcmd.Execute(ctx) } -// EvalWithVirtualEnv evaluates the source code in a virtual environment and returns the output -// This function passes the source code into the virtual environment and external parameters as environment variables +// EvalWithVirtualEnv evaluates the source code in a virtual environment and +// returns the output. As in Eval, the engine gets Options.Args before the +// source path and args after it, input is the standard input, and the +// variables of src and input are environment variables. A source that starts +// with a shebang runs directly, without the engine and Options.Args. func (g *Gozero) EvalWithVirtualEnv(ctx context.Context, envType VirtualEnvType, src, input *Source, dockerConfig *sandbox.DockerConfiguration, args ...string) (*types.Result, error) { // Read source code content srcContent, err := src.ReadAll() @@ -119,7 +122,12 @@ func (g *Gozero) EvalWithVirtualEnv(ctx context.Context, envType VirtualEnvType, } // Execute the source code in the Docker container - result, err := dockerSandbox.RunSource(ctx, string(srcContent), interpreter) + result, err := dockerSandbox.RunSourceWithOptions(ctx, string(srcContent), sandbox.DockerSourceOptions{ + Interpreter: interpreter, + InterpreterArgs: g.Options.Args, + Args: args, + Stdin: input.File, + }) if err != nil { return nil, err } diff --git a/sandbox/virtual_env_docker.go b/sandbox/virtual_env_docker.go index ca4e9d5..74147ef 100644 --- a/sandbox/virtual_env_docker.go +++ b/sandbox/virtual_env_docker.go @@ -1,8 +1,10 @@ +//nolint:exhaustruct,exhaustruct_v5 // the Docker API option and config structs use zero values for unset fields package sandbox import ( "context" "fmt" + "io" "log" "os/exec" "strings" @@ -75,8 +77,23 @@ func NewDockerSandbox(ctx context.Context, config *DockerConfiguration) (*Sandbo }, nil } +// DockerSourceOptions holds the per-run inputs of RunSourceWithOptions. +type DockerSourceOptions struct { + // Interpreter runs the source file. If it is empty or the source starts + // with a shebang, the source file runs directly. + Interpreter string + // InterpreterArgs are passed to Interpreter before the source path. + InterpreterArgs []string + // Args are passed to the source after its path. + Args []string + // Stdin is copied to the standard input of the source until EOF or + // until the container exits. If nil, the container has no standard + // input. A read from Stdin can still be pending when the run returns. + Stdin io.Reader +} + // runCommand executes a command in the Docker container with the given command parts -func (s *SandboxDocker) runCommand(ctx context.Context, cmdParts []string, command string, createFile bool, fileContent string) (*types.Result, error) { +func (s *SandboxDocker) runCommand(ctx context.Context, cmdParts []string, command string, stdin io.Reader) (*types.Result, error) { if len(cmdParts) == 0 { return nil, fmt.Errorf("empty command") } @@ -91,34 +108,19 @@ func (s *SandboxDocker) runCommand(ctx context.Context, cmdParts []string, comma env = append(env, fmt.Sprintf("%s=%s", key, value)) } - // If we need to create a file, modify the command to create it first - finalCmd := cmdParts - if createFile && len(fileContent) > 0 { - // Generate a temporary filename - tmpFileName := fmt.Sprintf("/tmp/gozero_script_%d.sh", time.Now().UnixNano()) - - // Create a script that writes the file content and then executes it - scriptContent := fmt.Sprintf(`#!/bin/sh -cat > %s << 'EOF' -%s -EOF -chmod +x %s -exec %s -`, tmpFileName, fileContent, tmpFileName, tmpFileName) - - // Create a command that writes the script and executes it - finalCmd = []string{"/bin/sh", "-c", scriptContent} - } - // Create container configuration containerConfig := &container.Config{ Image: s.config.Image, - Cmd: finalCmd, + Cmd: cmdParts, WorkingDir: s.config.WorkingDir, Env: env, User: s.config.User, AttachStdout: true, AttachStderr: true, + // Stdin stays open until the attached client closes it + AttachStdin: stdin != nil, + OpenStdin: stdin != nil, + StdinOnce: stdin != nil, } // Create host configuration @@ -152,6 +154,19 @@ exec %s containerID := createResp.ID + // Attach stdin before the start, as docker run -i does, so it is + // connected for the whole run + if stdin != nil { + stopStdin, err := s.attachStdin(runCtx, containerID, stdin) + if err != nil { + _, _ = s.dockerClient.ContainerRemove(runCtx, containerID, client.ContainerRemoveOptions{Force: true}) + + return nil, err + } + + defer stopStdin() + } + // Start container _, err = s.dockerClient.ContainerStart(runCtx, containerID, client.ContainerStartOptions{}) if err != nil { @@ -210,7 +225,8 @@ exec %s func (s *SandboxDocker) Run(ctx context.Context, cmd string) (*types.Result, error) { // Parse command into parts cmdParts := strings.Fields(cmd) - return s.runCommand(ctx, cmdParts, cmd, false, "") + + return s.runCommand(ctx, cmdParts, cmd, nil) } // RunScript executes a script in the Docker container @@ -218,33 +234,51 @@ func (s *SandboxDocker) RunScript(ctx context.Context, source string, interprete return s.RunSource(ctx, source, interpreter) } -// RunSource writes source code to a temporary file inside the container and executes it +// RunSource writes source code to a temporary file inside the container and executes it. +// The interpreter can include arguments, such as "python3 -u". func (s *SandboxDocker) RunSource(ctx context.Context, source string, interpreter string) (*types.Result, error) { + var opts DockerSourceOptions + if fields := strings.Fields(interpreter); len(fields) > 0 { + opts.Interpreter, opts.InterpreterArgs = fields[0], fields[1:] + } + + return s.RunSourceWithOptions(ctx, source, opts) +} + +// RunSourceWithOptions writes source code to a temporary file inside the +// container and executes it with the arguments and stdin in opts. +func (s *SandboxDocker) RunSourceWithOptions( + ctx context.Context, source string, opts DockerSourceOptions, +) (*types.Result, error) { // Generate a temporary filename tmpFileName := fmt.Sprintf("/tmp/gozero_script_%d", time.Now().UnixNano()) - // Check if source has a shebang - if so, use it instead of the interpreter - // Otherwise, use the provided interpreter - execCmd := fmt.Sprintf("%s %s", interpreter, tmpFileName) - if strings.HasPrefix(source, "#!") { - // Source has shebang, let it execute directly - execCmd = tmpFileName + // A source with a shebang, or without an interpreter, runs directly + var execArgs []string + if opts.Interpreter != "" && !strings.HasPrefix(source, "#!") { + execArgs = append(execArgs, opts.Interpreter) + execArgs = append(execArgs, opts.InterpreterArgs...) } - // Create a script that writes the source content and then executes it + execArgs = append(execArgs, tmpFileName) + execArgs = append(execArgs, opts.Args...) + + // Create a script that writes the source content and then executes its + // positional parameters, so the shell never parses the arguments scriptContent := fmt.Sprintf(`cat > %s << 'EOF' %s EOF chmod +x %s -exec %s -`, tmpFileName, source, tmpFileName, execCmd) +exec "$@" +`, tmpFileName, source, tmpFileName) log.Println(tmpFileName) log.Println(scriptContent) - // Execute the script directly - cmdParts := []string{"/bin/sh", "-c", scriptContent} - return s.runCommand(ctx, cmdParts, fmt.Sprintf("exec %s", tmpFileName), false, "") + // "sh" is the script's $0 and execArgs are its "$@" + cmdParts := append([]string{"/bin/sh", "-c", scriptContent, "sh"}, execArgs...) + + return s.runCommand(ctx, cmdParts, "exec "+tmpFileName, opts.Stdin) } // Start is not implemented for Docker sandbox as it's stateless @@ -342,3 +376,25 @@ func (s *SandboxDocker) pullImageIfNeeded(ctx context.Context, imageName string) return nil } + +// attachStdin copies stdin to the standard input of a created container. The +// returned stop function closes the connection, which ends the copy at its +// next write; call it after the container exits. Like docker run -i, stop +// doesn't wait for a pending read from stdin, which may never return. +func (s *SandboxDocker) attachStdin(ctx context.Context, containerID string, stdin io.Reader) (func(), error) { + attach, err := s.dockerClient.ContainerAttach(ctx, containerID, client.ContainerAttachOptions{ + Stream: true, + Stdin: true, + }) + if err != nil { + return nil, fmt.Errorf("failed to attach container stdin: %w", err) + } + + go func() { + _, _ = io.Copy(attach.Conn, stdin) + // Closing the write side closes the container's stdin + _ = attach.CloseWrite() + }() + + return attach.Close, nil +} diff --git a/virtual_env_test.go b/virtual_env_test.go new file mode 100644 index 0000000..a3324d5 --- /dev/null +++ b/virtual_env_test.go @@ -0,0 +1,339 @@ +//nolint:depguard,exhaustruct,exhaustruct_v5 // no depguard allow list exists; zero values are intended +package gozero_test + +import ( + "bytes" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "os" + "os/exec" + "path/filepath" + "regexp" + "strings" + "sync" + "testing" + "time" + + "github.com/moby/moby/api/types/container" + "github.com/moby/moby/client" + "github.com/projectdiscovery/gozero" + "github.com/projectdiscovery/gozero/sandbox" + "github.com/projectdiscovery/gozero/types" + osutils "github.com/projectdiscovery/utils/os" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// TestEvalWithVirtualEnvDockerMatchesEval checks that the Docker container +// runs a source as Eval does: the engine gets Options.Args, the source gets +// the call arguments, the input is stdin, and its variables are environment +// variables. +// +//nolint:paralleltest // newFakeDockerd sets process-wide environment variables +func TestEvalWithVirtualEnvDockerMatchesEval(t *testing.T) { + skipOnWindows(t) + + engine := newEngine(t) + args := []string{"hello", "two words"} + + // With Options.Args -e, the engine stops at false. + const script = `echo "$# args: $*"; echo "FOO: $FOO"; read line; echo "stdin: $line"; false; echo "no -e"` + + want, err := engine.Eval(t.Context(), newSource(t, script), newInput(t), args...) + require.Error(t, err, "Eval of a source that exits 1") + require.Equal(t, "2 args: hello two words\nFOO: bar\nstdin: from-stdin\n", want.Stdout.String(), "Eval output") + + dockerd := newFakeDockerd(t) + _, err = engine.EvalWithVirtualEnv(t.Context(), gozero.VirtualEnvDocker, + newSource(t, script), newInput(t), dockerConfig(), args...) + require.NoError(t, err) + + created, stdin := dockerd.recorded() + assert.True(t, created.AttachStdin && created.OpenStdin && created.StdinOnce, + "container stdin is not attached, open and closed after the input") + + output, exitCode := runLikeContainer(t, created, stdin) + assert.Equal(t, want.Stdout.String(), output, "container output of command %q", created.Cmd) + assert.Equal(t, want.GetExitCode(), exitCode, "container exit code") + assert.True(t, dockerd.removed(), "container is not removed") +} + +// TestEvalWithVirtualEnvDockerShebang checks that, unlike Eval, Docker runs a +// source with a shebang directly, without the engine and Options.Args, and +// still passes the call arguments and stdin. +// +//nolint:paralleltest // newFakeDockerd sets process-wide environment variables +func TestEvalWithVirtualEnvDockerShebang(t *testing.T) { + skipOnWindows(t) + + const script = "#!/bin/sh\n" + `echo "$# args: $*"; read line; echo "stdin: $line"; false; echo "no -e"` + + dockerd := newFakeDockerd(t) + _, err := newEngine(t).EvalWithVirtualEnv(t.Context(), gozero.VirtualEnvDocker, + newSource(t, script), newInput(t), dockerConfig(), "hello", "two words") + require.NoError(t, err) + + created, stdin := dockerd.recorded() + output, _ := runLikeContainer(t, created, stdin) + assert.Equal(t, "2 args: hello two words\nstdin: from-stdin\nno -e\n", output, + "container output of command %q", created.Cmd) +} + +// TestDockerRunSourceInterpreterArgs checks that RunSource still accepts an +// interpreter with arguments, and runs the source without stdin. +// +//nolint:paralleltest // newFakeDockerd sets process-wide environment variables +func TestDockerRunSourceInterpreterArgs(t *testing.T) { + skipOnWindows(t) + + dockerd := newFakeDockerd(t) + + docker, err := sandbox.NewDockerSandbox(t.Context(), dockerConfig()) + require.NoError(t, err) + t.Cleanup(func() { _ = docker.Clear() }) + + _, err = docker.RunSource(t.Context(), `echo start; false; echo "no -e"`, "sh -e") + require.NoError(t, err) + + created, _ := dockerd.recorded() + assert.False(t, created.OpenStdin, "container stdin is open") + + output, _ := runLikeContainer(t, created, "") + assert.Equal(t, "start\n", output, "container output of command %q", created.Cmd) +} + +func skipOnWindows(t *testing.T) { + t.Helper() + + if osutils.IsWindows() { + t.Skip("the fake docker CLI is a POSIX shell script") + } +} + +// newEngine returns an executor that runs sources with sh -e. +func newEngine(t *testing.T) *gozero.Gozero { + t.Helper() + + engine, err := gozero.New(&gozero.Options{Engines: []string{"sh"}, Args: []string{"-e"}}) + require.NoError(t, err) + + return engine +} + +// newSource returns a source with content that is removed when the test ends. +func newSource(t *testing.T, content string) *gozero.Source { + t.Helper() + + src, err := gozero.NewSourceWithString(content, "", "") + require.NoError(t, err) + t.Cleanup(func() { _ = src.Cleanup() }) + + return src +} + +// newInput returns an input source with one line of stdin and the variable +// FOO=bar. +func newInput(t *testing.T) *gozero.Source { + t.Helper() + + input := newSource(t, "from-stdin\n") + input.AddVariable(types.Variable{Name: "FOO", Value: "bar"}) + + return input +} + +// dockerConfig returns a sandbox configuration for the fake Docker API. +// EvalWithVirtualEnv changes the configuration, so each call needs its own. +func dockerConfig() *sandbox.DockerConfiguration { + return &sandbox.DockerConfiguration{Image: "alpine:3", WorkingDir: "/tmp", Timeout: 10 * time.Second} +} + +var containerScriptPath = regexp.MustCompile(`/tmp/gozero_script_\d+`) + +// runLikeContainer runs the command of the created container with the host's +// /bin/sh, its environment and stdin, and returns the standard output and the +// exit code. +func runLikeContainer(t *testing.T, created container.Config, stdin string) (string, int) { + t.Helper() + require.NotEmpty(t, created.Cmd, "container command") + + for _, arg := range created.Cmd { + for _, path := range containerScriptPath.FindAllString(arg, -1) { + t.Cleanup(func() { _ = os.Remove(path) }) + } + } + + var stdout bytes.Buffer + + //nolint:gosec // runs the command that the sandbox sent to the fake Docker API + cmd := exec.CommandContext(t.Context(), created.Cmd[0], created.Cmd[1:]...) + cmd.Env = append(os.Environ(), created.Env...) + cmd.Stdin = strings.NewReader(stdin) + cmd.Stdout = &stdout + + err := cmd.Run() + if err != nil { + require.ErrorAs(t, err, new(*exec.ExitError), "run container command") + } + + return stdout.String(), cmd.ProcessState.ExitCode() +} + +// fakeDockerd is a minimal Docker Engine API for one container. It records +// the container-create request and the bytes sent to the container's stdin. +// Like a process that reads all of its input, the container exits only after +// stdin is closed, when a client is attached to it. +type fakeDockerd struct { + mu sync.Mutex + created container.Config + attached bool + stdin []byte + stdinDone chan struct{} + isRemoved bool +} + +const fakeContainerID = "gozero-test" + +var apiVersionPrefix = regexp.MustCompile(`^/v[0-9.]+`) + +// newFakeDockerd starts a fakeDockerd and points the Docker client and the +// docker CLI check in sandbox.NewDockerSandbox at it. +func newFakeDockerd(t *testing.T) *fakeDockerd { + t.Helper() + + dockerd := &fakeDockerd{stdinDone: make(chan struct{})} + srv := httptest.NewServer(dockerd.handler()) + t.Cleanup(srv.Close) + t.Setenv("DOCKER_HOST", "tcp://"+srv.Listener.Addr().String()) + t.Setenv("DOCKER_CERT_PATH", "") + t.Setenv("DOCKER_API_VERSION", "") + + // NewDockerSandbox runs "docker info" before it connects to the API. + bin := t.TempDir() + //nolint:gosec // the fake docker CLI must be executable + err := os.WriteFile(filepath.Join(bin, "docker"), []byte("#!/bin/sh\nexit 0\n"), 0o755) + require.NoError(t, err) + t.Setenv("PATH", bin+string(os.PathListSeparator)+os.Getenv("PATH")) + + return dockerd +} + +// handler routes the Docker Engine API requests that the sandbox sends. +func (d *fakeDockerd) handler() http.Handler { + const containerPath = "/containers/" + fakeContainerID + + mux := http.NewServeMux() + mux.HandleFunc("/_ping", func(writer http.ResponseWriter, _ *http.Request) { + writer.Header().Set("Api-Version", client.MaxAPIVersion) + _, _ = io.WriteString(writer, "OK") + }) + mux.HandleFunc("GET /images/alpine:3/json", func(writer http.ResponseWriter, _ *http.Request) { + _, _ = io.WriteString(writer, `{"Id":"sha256:gozero-test"}`) + }) + mux.HandleFunc("POST /containers/create", d.create) + mux.HandleFunc("POST "+containerPath+"/attach", d.attach) + mux.HandleFunc("POST "+containerPath+"/start", noContent) + mux.HandleFunc("POST "+containerPath+"/wait", d.wait) + // No output: the test runs the command itself. + mux.HandleFunc("GET "+containerPath+"/logs", func(http.ResponseWriter, *http.Request) {}) + mux.HandleFunc("DELETE "+containerPath, d.remove) + + // The client prefixes request paths with the API version, such as /v1.54. + return http.HandlerFunc(func(writer http.ResponseWriter, req *http.Request) { + req.URL.Path = apiVersionPrefix.ReplaceAllString(req.URL.Path, "") + req.URL.RawPath = "" + mux.ServeHTTP(writer, req) + }) +} + +// recorded returns the container-create request and the stdin bytes. +func (d *fakeDockerd) recorded() (container.Config, string) { + d.mu.Lock() + defer d.mu.Unlock() + + return d.created, string(d.stdin) +} + +// removed reports whether the container was removed. +func (d *fakeDockerd) removed() bool { + d.mu.Lock() + defer d.mu.Unlock() + + return d.isRemoved +} + +func (d *fakeDockerd) create(writer http.ResponseWriter, req *http.Request) { + d.mu.Lock() + err := json.NewDecoder(req.Body).Decode(&d.created) + d.mu.Unlock() + + if err != nil { + http.Error(writer, err.Error(), http.StatusBadRequest) + + return + } + + writer.WriteHeader(http.StatusCreated) + _, _ = io.WriteString(writer, `{"Id":"`+fakeContainerID+`"}`) +} + +// attach upgrades the connection to a raw stream and reads the container's +// stdin until the client closes its write side. +func (d *fakeDockerd) attach(writer http.ResponseWriter, _ *http.Request) { + conn, stream, err := http.NewResponseController(writer).Hijack() + if err != nil { + http.Error(writer, err.Error(), http.StatusInternalServerError) + + return + } + + defer func() { _ = conn.Close() }() + + d.mu.Lock() + d.attached = true + d.mu.Unlock() + + _, _ = stream.WriteString("HTTP/1.1 101 UPGRADED\r\n" + + "Content-Type: application/vnd.docker.raw-stream\r\n" + + "Connection: Upgrade\r\nUpgrade: tcp\r\n\r\n") + _ = stream.Flush() + + stdin, _ := io.ReadAll(stream) + + d.mu.Lock() + d.stdin = stdin + d.mu.Unlock() + close(d.stdinDone) +} + +// wait answers when the container exits: at once, or after its stdin is +// closed when a client is attached. +func (d *fakeDockerd) wait(writer http.ResponseWriter, req *http.Request) { + d.mu.Lock() + attached := d.attached + d.mu.Unlock() + + if attached { + select { + case <-d.stdinDone: + case <-req.Context().Done(): + return + } + } + + _, _ = io.WriteString(writer, `{"StatusCode":0}`) +} + +func (d *fakeDockerd) remove(writer http.ResponseWriter, _ *http.Request) { + d.mu.Lock() + d.isRemoved = true + d.mu.Unlock() + + writer.WriteHeader(http.StatusNoContent) +} + +func noContent(writer http.ResponseWriter, _ *http.Request) { + writer.WriteHeader(http.StatusNoContent) +} From 0dcd6880b48280ac501d2c92f14366b1fda13764 Mon Sep 17 00:00:00 2001 From: Dwi Siswanto Date: Wed, 4 Nov 2026 13:06:25 +0700 Subject: [PATCH 2/2] refactor(sandbox): split container setup out of runCommand runCommand built the container and host configs, ran the container and read its logs in one function, and repeated the container removal on every exit path. The configs and the log reading move to containerConfig, hostConfig and collectResult, and one deferred call removes the container, so a new exit path can't skip the cleanup. The empty-command error becomes a sentinel, the environment slice is preallocated, and the log buffer size is a named constant. Behavior is unchanged. Signed-off-by: Dwi Siswanto --- sandbox/error.go | 2 + sandbox/virtual_env_docker.go | 282 ++++++++++++++++++---------------- 2 files changed, 155 insertions(+), 129 deletions(-) diff --git a/sandbox/error.go b/sandbox/error.go index 752adc6..31bbee2 100644 --- a/sandbox/error.go +++ b/sandbox/error.go @@ -5,4 +5,6 @@ import "errors" var ( ErrNotImplemented = errors.New("not implemented") ErrAgentRequired = errors.New("requires agent installed on the sandbox") + + errEmptyCommand = errors.New("empty command") ) diff --git a/sandbox/virtual_env_docker.go b/sandbox/virtual_env_docker.go index 74147ef..561f905 100644 --- a/sandbox/virtual_env_docker.go +++ b/sandbox/virtual_env_docker.go @@ -92,135 +92,6 @@ type DockerSourceOptions struct { Stdin io.Reader } -// runCommand executes a command in the Docker container with the given command parts -func (s *SandboxDocker) runCommand(ctx context.Context, cmdParts []string, command string, stdin io.Reader) (*types.Result, error) { - if len(cmdParts) == 0 { - return nil, fmt.Errorf("empty command") - } - - // Create a new context with timeout - runCtx, cancel := context.WithTimeout(ctx, s.config.Timeout) - defer cancel() - - // Prepare environment variables - env := []string{} - for key, value := range s.config.Environment { - env = append(env, fmt.Sprintf("%s=%s", key, value)) - } - - // Create container configuration - containerConfig := &container.Config{ - Image: s.config.Image, - Cmd: cmdParts, - WorkingDir: s.config.WorkingDir, - Env: env, - User: s.config.User, - AttachStdout: true, - AttachStderr: true, - // Stdin stays open until the attached client closes it - AttachStdin: stdin != nil, - OpenStdin: stdin != nil, - StdinOnce: stdin != nil, - } - - // Create host configuration - hostConfig := &container.HostConfig{ - AutoRemove: false, // Don't auto-remove so we can get logs - } - - // Set network configuration - if s.config.NetworkDisabled { - hostConfig.NetworkMode = "none" - } else if s.config.NetworkMode != "" { - hostConfig.NetworkMode = container.NetworkMode(s.config.NetworkMode) - } - - // Set resource limits if specified - if s.config.Memory != "" { - hostConfig.Memory = parseMemoryLimit(s.config.Memory) - } - - // Pull image if it doesn't exist locally - err := s.pullImageIfNeeded(runCtx, s.config.Image) - if err != nil { - return nil, fmt.Errorf("failed to pull image %s: %w", s.config.Image, err) - } - - // Create container - createResp, err := s.dockerClient.ContainerCreate(runCtx, client.ContainerCreateOptions{Config: containerConfig, HostConfig: hostConfig}) - if err != nil { - return nil, fmt.Errorf("failed to create container: %w", err) - } - - containerID := createResp.ID - - // Attach stdin before the start, as docker run -i does, so it is - // connected for the whole run - if stdin != nil { - stopStdin, err := s.attachStdin(runCtx, containerID, stdin) - if err != nil { - _, _ = s.dockerClient.ContainerRemove(runCtx, containerID, client.ContainerRemoveOptions{Force: true}) - - return nil, err - } - - defer stopStdin() - } - - // Start container - _, err = s.dockerClient.ContainerStart(runCtx, containerID, client.ContainerStartOptions{}) - if err != nil { - _, _ = s.dockerClient.ContainerRemove(runCtx, containerID, client.ContainerRemoveOptions{Force: true}) - return nil, fmt.Errorf("failed to start container: %w", err) - } - - // Wait for container to finish - waitRes := s.dockerClient.ContainerWait(runCtx, containerID, client.ContainerWaitOptions{Condition: container.WaitConditionNotRunning}) - - select { - case err := <-waitRes.Error: - _, _ = s.dockerClient.ContainerRemove(runCtx, containerID, client.ContainerRemoveOptions{Force: true}) - return nil, fmt.Errorf("container wait error: %w", err) - case result := <-waitRes.Result: - // Get container logs - logs, err := s.dockerClient.ContainerLogs(runCtx, containerID, client.ContainerLogsOptions{ - ShowStdout: true, - ShowStderr: true, - }) - if err != nil { - _, _ = s.dockerClient.ContainerRemove(runCtx, containerID, client.ContainerRemoveOptions{Force: true}) - return nil, fmt.Errorf("failed to get container logs: %w", err) - } - defer func() { - _ = logs.Close() - }() - - // Read logs - logData := make([]byte, 1024*1024) // 1MB buffer - n, err := logs.Read(logData) - if err != nil && err.Error() != "EOF" { - _, _ = s.dockerClient.ContainerRemove(runCtx, containerID, client.ContainerRemoveOptions{Force: true}) - return nil, fmt.Errorf("failed to read container logs: %w", err) - } - - // Create result - cmdResult := &types.Result{ - Command: command, - } - cmdResult.Stdout.Write(logData[:n]) - - // Set exit code - if result.StatusCode != 0 { - cmdResult.SetExitError(&exec.ExitError{}) - } - - // Always clean up container manually - _, _ = s.dockerClient.ContainerRemove(runCtx, containerID, client.ContainerRemoveOptions{Force: true}) - - return cmdResult, nil - } -} - // Run executes a command in the Docker container (synchronous execution) func (s *SandboxDocker) Run(ctx context.Context, cmd string) (*types.Result, error) { // Parse command into parts @@ -377,6 +248,116 @@ func (s *SandboxDocker) pullImageIfNeeded(ctx context.Context, imageName string) return nil } +// runCommand executes a command in the Docker container with the given command parts. +func (s *SandboxDocker) runCommand( + ctx context.Context, cmdParts []string, command string, stdin io.Reader, +) (*types.Result, error) { + if len(cmdParts) == 0 { + return nil, errEmptyCommand + } + + // Create a new context with timeout + runCtx, cancel := context.WithTimeout(ctx, s.config.Timeout) + defer cancel() + + // Pull image if it doesn't exist locally + err := s.pullImageIfNeeded(runCtx, s.config.Image) + if err != nil { + return nil, fmt.Errorf("failed to pull image %s: %w", s.config.Image, err) + } + + // Create container + createResp, err := s.dockerClient.ContainerCreate(runCtx, client.ContainerCreateOptions{ + Config: s.containerConfig(cmdParts, stdin != nil), + HostConfig: s.hostConfig(), + }) + if err != nil { + return nil, fmt.Errorf("failed to create container: %w", err) + } + + containerID := createResp.ID + + // Always clean up the container, which isn't auto-removed so that its + // logs can be read + defer func() { + _, _ = s.dockerClient.ContainerRemove(runCtx, containerID, client.ContainerRemoveOptions{Force: true}) + }() + + // Attach stdin before the start, as docker run -i does, so it is + // connected for the whole run + if stdin != nil { + stopStdin, err := s.attachStdin(runCtx, containerID, stdin) + if err != nil { + return nil, err + } + + defer stopStdin() + } + + // Start container + _, err = s.dockerClient.ContainerStart(runCtx, containerID, client.ContainerStartOptions{}) + if err != nil { + return nil, fmt.Errorf("failed to start container: %w", err) + } + + // Wait for container to finish + waitRes := s.dockerClient.ContainerWait(runCtx, containerID, client.ContainerWaitOptions{ + Condition: container.WaitConditionNotRunning, + }) + + select { + case err := <-waitRes.Error: + return nil, fmt.Errorf("container wait error: %w", err) + case result := <-waitRes.Result: + return s.collectResult(runCtx, containerID, command, result.StatusCode) + } +} + +// containerConfig returns the configuration of a container that runs cmd. +func (s *SandboxDocker) containerConfig(cmd []string, withStdin bool) *container.Config { + // Prepare environment variables + env := make([]string, 0, len(s.config.Environment)) + for key, value := range s.config.Environment { + env = append(env, key+"="+value) + } + + return &container.Config{ + Image: s.config.Image, + Cmd: cmd, + WorkingDir: s.config.WorkingDir, + Env: env, + User: s.config.User, + AttachStdout: true, + AttachStderr: true, + // Stdin stays open until the attached client closes it + AttachStdin: withStdin, + OpenStdin: withStdin, + StdinOnce: withStdin, + } +} + +// hostConfig returns the host configuration of a container, with the network +// mode and memory limit of the sandbox. +func (s *SandboxDocker) hostConfig() *container.HostConfig { + hostConfig := &container.HostConfig{ + AutoRemove: false, // Don't auto-remove so we can get logs + } + + // Set network configuration + if s.config.NetworkDisabled { + hostConfig.NetworkMode = "none" + } else if s.config.NetworkMode != "" { + hostConfig.NetworkMode = container.NetworkMode(s.config.NetworkMode) + } + + // Set resource limits if specified + if s.config.Memory != "" { + hostConfig.Memory = parseMemoryLimit(s.config.Memory) + } + + return hostConfig +} + // attachStdin copies stdin to the standard input of a created container. The // returned stop function closes the connection, which ends the copy at its // next write; call it after the container exits. Like docker run -i, stop @@ -398,3 +379,46 @@ func (s *SandboxDocker) attachStdin(ctx context.Context, containerID string, std return attach.Close, nil } + +// logBufferSize is the size of the buffer that collectResult reads container +// logs into. +const logBufferSize = 1024 * 1024 // 1MB + +// collectResult reads the output of an exited container into a Result. +func (s *SandboxDocker) collectResult( + ctx context.Context, containerID, command string, statusCode int64, +) (*types.Result, error) { + // Get container logs + logs, err := s.dockerClient.ContainerLogs(ctx, containerID, client.ContainerLogsOptions{ + ShowStdout: true, + ShowStderr: true, + }) + if err != nil { + return nil, fmt.Errorf("failed to get container logs: %w", err) + } + + defer func() { + _ = logs.Close() + }() + + // Read logs + logData := make([]byte, logBufferSize) + + size, err := logs.Read(logData) + if err != nil && err.Error() != "EOF" { + return nil, fmt.Errorf("failed to read container logs: %w", err) + } + + // Create result + cmdResult := &types.Result{ + Command: command, + } + cmdResult.Stdout.Write(logData[:size]) + + // Set exit code + if statusCode != 0 { + cmdResult.SetExitError(&exec.ExitError{}) + } + + return cmdResult, nil +}