Files
codapi/internal/engine/docker.go

326 lines
8.4 KiB
Go

// Execute commands using Docker.
package engine
import (
"context"
"errors"
"fmt"
"io"
"os"
"os/exec"
"path/filepath"
"slices"
"strconv"
"strings"
"time"
"github.com/nalgeon/codapi/internal/config"
"github.com/nalgeon/codapi/internal/execy"
"github.com/nalgeon/codapi/internal/fileio"
"github.com/nalgeon/codapi/internal/logx"
)
var killTimeout = 5 * time.Second
const (
actionRun = "run"
actionExec = "exec"
)
// A Docker engine executes a specific sandbox command
// using Docker `run` or `exec` actions.
type Docker struct {
cfg *config.Config
cmd *config.Command
}
// NewDocker creates a new Docker engine for a specific command.
func NewDocker(cfg *config.Config, sandbox, command string) Engine {
cmd := cfg.Commands[sandbox][command]
return &Docker{cfg, cmd}
}
// Exec executes the command and returns the output.
func (e *Docker) Exec(req Request) Execution {
// all steps operate in the same temp directory
dir, err := os.MkdirTemp("", "")
if err != nil {
err = NewExecutionError("create temp dir", err)
return Fail(req.ID, err)
}
defer os.RemoveAll(dir)
// if the command entry point file is not defined,
// there is no need to store request files in the temp directory
if e.cmd.Entry != "" {
// write request files to the temp directory
err = e.writeFiles(dir, req.Files)
if err != nil {
err = NewExecutionError("write files to temp dir", err)
return Fail(req.ID, err)
}
}
// initialization step
if e.cmd.Before != nil {
out := e.execStep(e.cmd.Before, req, dir, nil)
if !out.OK {
return out
}
}
// the first step is required
first, rest := e.cmd.Steps[0], e.cmd.Steps[1:]
out := e.execStep(first, req, dir, req.Files)
// the rest are optional
if out.OK && len(rest) > 0 {
// each step operates on the results of the previous one,
// without using the source files - hence `nil` instead of `files`
for _, step := range rest {
out = e.execStep(step, req, dir, nil)
if !out.OK {
break
}
}
}
// cleanup step
if e.cmd.After != nil {
afterOut := e.execStep(e.cmd.After, req, dir, nil)
if out.OK && !afterOut.OK {
return afterOut
}
}
return out
}
// execStep executes a step using the docker container.
func (e *Docker) execStep(step *config.Step, req Request, dir string, files Files) Execution {
box := e.cfg.Boxes[step.Box]
err := e.validateVersion(box, step, req)
if err != nil {
return Fail(req.ID, err)
}
err = e.copyFiles(box, dir)
if err != nil {
err = NewExecutionError("copy files to temp dir", err)
return Fail(req.ID, err)
}
stdout, stderr, err := e.exec(box, step, req, dir, files)
if err != nil {
return Fail(req.ID, err)
}
return Execution{
ID: req.ID,
OK: true,
Stdout: stdout,
Stderr: stderr,
}
}
func (e *Docker) validateVersion(box *config.Box, step *config.Step, req Request) error {
// If the version is set in the step config, use it.
// If the version isn't set in the request, use the latest one.
// Otherwise, check that the version in the request is supported
// according to the box config.
if step.Version == "" && req.Version != "" && !slices.Contains(box.Versions, req.Version) {
err := fmt.Errorf("box %s does not support version %s", step.Box, req.Version)
return err
}
return nil
}
// copyFiles copies box files to the temporary directory.
func (e *Docker) copyFiles(box *config.Box, dir string) error {
if box == nil || len(box.Files) == 0 {
return nil
}
for _, pattern := range box.Files {
err := fileio.CopyFiles(pattern, dir)
if err != nil {
return err
}
}
return nil
}
// writeFiles writes request files to the temporary directory.
func (e *Docker) writeFiles(dir string, files Files) error {
var err error
files.Range(func(name, content string) bool {
if name == "" {
name = e.cmd.Entry
}
path := filepath.Join(dir, name)
err = fileio.WriteFile(path, content, 0444)
return err == nil
})
return err
}
// exec executes the step in the docker container
// using the files from in the temporary directory.
func (e *Docker) exec(box *config.Box, step *config.Step, req Request, dir string, files Files) (stdout string, stderr string, err error) {
// limit the stdout/stderr size
prog := NewProgram(step.Timeout, int64(step.NOutput))
args := e.buildArgs(box, step, req, dir)
if step.Stdin {
// pass files to container from stdin
stdin := filesReader(files)
stdout, stderr, err = prog.RunStdin(stdin, req.ID, "docker", args...)
} else {
// pass files to container from temp directory
stdout, stderr, err = prog.Run(req.ID, "docker", args...)
}
if err == nil {
// success
return
}
if err.Error() == "signal: killed" {
if step.Action == actionRun {
// we have to "docker kill" the container here, because the proccess
// inside the container is not related to the "docker run" process,
// and will hang forever after the "docker run" process is killed
go func() {
err = dockerKill(req.ID)
if err == nil {
logx.Debug("%s: docker kill ok", req.ID)
} else {
logx.Log("%s: docker kill failed: %v", req.ID, err)
}
}()
}
// context timeout
err = ErrTimeout
return
}
exitErr := new(exec.ExitError)
if errors.As(err, &exitErr) {
// the problem (if any) is the code, not the execution
// so we return the error without wrapping into ExecutionError
stderr, stdout = stdout+stderr, ""
if stderr != "" {
err = fmt.Errorf("%s (%s)", stderr, err)
}
return
}
// other execution error
err = NewExecutionError("execute code", err)
return
}
// buildArgs prepares the arguments for the `docker` command.
func (e *Docker) buildArgs(box *config.Box, step *config.Step, req Request, dir string) []string {
var args []string
if step.Action == actionRun {
args = dockerRunArgs(box, step, req, dir)
} else if step.Action == actionExec {
args = dockerExecArgs(step)
} else {
// should never happen if the config is valid
args = []string{"version"}
}
command := expandVars(step.Command, req.ID)
args = append(args, command...)
logx.Debug("%v", args)
return args
}
// buildArgs prepares the arguments for the `docker run` command.
func dockerRunArgs(box *config.Box, step *config.Step, req Request, dir string) []string {
args := []string{
actionRun, "--rm",
"--name", req.ID,
"--runtime", box.Runtime,
"--cpus", strconv.Itoa(box.CPU),
"--memory", fmt.Sprintf("%dm", box.Memory),
"--network", box.Network,
"--pids-limit", strconv.Itoa(box.NProc),
"--user", step.User,
}
if !box.Writable {
args = append(args, "--read-only")
}
if step.Stdin {
args = append(args, "--interactive")
}
if box.Storage != "" {
args = append(args, "--storage-opt", fmt.Sprintf("size=%s", box.Storage))
}
if dir != "" {
args = append(args, "--volume", fmt.Sprintf(box.Volume, dir))
}
for _, fs := range box.Tmpfs {
args = append(args, "--tmpfs", fs)
}
for _, cap := range box.CapAdd {
args = append(args, "--cap-add", cap)
}
for _, cap := range box.CapDrop {
args = append(args, "--cap-drop", cap)
}
for _, lim := range box.Ulimit {
args = append(args, "--ulimit", lim)
}
if step.Version != "" {
// if the version is set in the step config, use it
args = append(args, box.Image+":"+step.Version)
} else if req.Version != "" {
// if the version is set in the request, use it
args = append(args, box.Image+":"+req.Version)
} else {
// otherwise, use the latest version
args = append(args, box.Image)
}
return args
}
// dockerExecArgs prepares the arguments for the `docker exec` command.
func dockerExecArgs(step *config.Step) []string {
return []string{
actionExec, "--interactive",
"--user", step.User,
step.Box,
}
}
// filesReader creates a reader over an in-memory collection of files.
func filesReader(files Files) io.Reader {
var input strings.Builder
for _, content := range files {
input.WriteString(content)
}
return strings.NewReader(input.String())
}
// expandVars replaces variables in command arguments with values.
// The only supported variable is :name = container name.
func expandVars(command []string, name string) []string {
expanded := make([]string, len(command))
copy(expanded, command)
for i, cmd := range expanded {
expanded[i] = strings.Replace(cmd, ":name", name, 1)
}
return expanded
}
// dockerKill kills the container with the specified id/name.
func dockerKill(id string) error {
ctx, cancel := context.WithTimeout(context.Background(), killTimeout)
defer cancel()
cmd := exec.CommandContext(ctx, "docker", "kill", id)
return execy.Run(cmd)
}