326 lines
8.4 KiB
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)
|
|
}
|