feat: support different versions of the same box
This commit is contained in:
2
go.mod
2
go.mod
@@ -1,3 +1,3 @@
|
|||||||
module github.com/nalgeon/codapi
|
module github.com/nalgeon/codapi
|
||||||
|
|
||||||
go 1.20
|
go 1.21
|
||||||
|
|||||||
@@ -59,7 +59,8 @@ type Box struct {
|
|||||||
Runtime string `json:"runtime"`
|
Runtime string `json:"runtime"`
|
||||||
Host
|
Host
|
||||||
|
|
||||||
Files []string `json:"files"`
|
Versions []string `json:"versions"`
|
||||||
|
Files []string `json:"files"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// A Host describes container Host attributes.
|
// A Host describes container Host attributes.
|
||||||
@@ -96,6 +97,7 @@ type Command struct {
|
|||||||
// A Step describes a single step of a command.
|
// A Step describes a single step of a command.
|
||||||
type Step struct {
|
type Step struct {
|
||||||
Box string `json:"box"`
|
Box string `json:"box"`
|
||||||
|
Version string `json:"version"`
|
||||||
User string `json:"user"`
|
User string `json:"user"`
|
||||||
Action string `json:"action"`
|
Action string `json:"action"`
|
||||||
Stdin bool `json:"stdin"`
|
Stdin bool `json:"stdin"`
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import (
|
|||||||
"os"
|
"os"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"slices"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
@@ -62,7 +63,7 @@ func (e *Docker) Exec(req Request) Execution {
|
|||||||
|
|
||||||
// initialization step
|
// initialization step
|
||||||
if e.cmd.Before != nil {
|
if e.cmd.Before != nil {
|
||||||
out := e.execStep(e.cmd.Before, req.ID, dir, nil)
|
out := e.execStep(e.cmd.Before, req, dir, nil)
|
||||||
if !out.OK {
|
if !out.OK {
|
||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
@@ -70,14 +71,14 @@ func (e *Docker) Exec(req Request) Execution {
|
|||||||
|
|
||||||
// the first step is required
|
// the first step is required
|
||||||
first, rest := e.cmd.Steps[0], e.cmd.Steps[1:]
|
first, rest := e.cmd.Steps[0], e.cmd.Steps[1:]
|
||||||
out := e.execStep(first, req.ID, dir, req.Files)
|
out := e.execStep(first, req, dir, req.Files)
|
||||||
|
|
||||||
// the rest are optional
|
// the rest are optional
|
||||||
if out.OK && len(rest) > 0 {
|
if out.OK && len(rest) > 0 {
|
||||||
// each step operates on the results of the previous one,
|
// each step operates on the results of the previous one,
|
||||||
// without using the source files - hence `nil` instead of `files`
|
// without using the source files - hence `nil` instead of `files`
|
||||||
for _, step := range rest {
|
for _, step := range rest {
|
||||||
out = e.execStep(step, req.ID, dir, nil)
|
out = e.execStep(step, req, dir, nil)
|
||||||
if !out.OK {
|
if !out.OK {
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
@@ -86,7 +87,7 @@ func (e *Docker) Exec(req Request) Execution {
|
|||||||
|
|
||||||
// cleanup step
|
// cleanup step
|
||||||
if e.cmd.After != nil {
|
if e.cmd.After != nil {
|
||||||
afterOut := e.execStep(e.cmd.After, req.ID, dir, nil)
|
afterOut := e.execStep(e.cmd.After, req, dir, nil)
|
||||||
if out.OK && !afterOut.OK {
|
if out.OK && !afterOut.OK {
|
||||||
return afterOut
|
return afterOut
|
||||||
}
|
}
|
||||||
@@ -96,27 +97,44 @@ func (e *Docker) Exec(req Request) Execution {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// execStep executes a step using the docker container.
|
// execStep executes a step using the docker container.
|
||||||
func (e *Docker) execStep(step *config.Step, reqID, dir string, files Files) Execution {
|
func (e *Docker) execStep(step *config.Step, req Request, dir string, files Files) Execution {
|
||||||
box := e.cfg.Boxes[step.Box]
|
box := e.cfg.Boxes[step.Box]
|
||||||
err := e.copyFiles(box, dir)
|
err := e.validateVersion(box, step, req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
err = NewExecutionError("copy files to temp dir", err)
|
return Fail(req.ID, err)
|
||||||
return Fail(reqID, err)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
stdout, stderr, err := e.exec(box, step, reqID, dir, files)
|
err = e.copyFiles(box, dir)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return Fail(reqID, err)
|
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{
|
return Execution{
|
||||||
ID: reqID,
|
ID: req.ID,
|
||||||
OK: true,
|
OK: true,
|
||||||
Stdout: stdout,
|
Stdout: stdout,
|
||||||
Stderr: stderr,
|
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.
|
// copyFiles copies box files to the temporary directory.
|
||||||
func (e *Docker) copyFiles(box *config.Box, dir string) error {
|
func (e *Docker) copyFiles(box *config.Box, dir string) error {
|
||||||
if box == nil || len(box.Files) == 0 {
|
if box == nil || len(box.Files) == 0 {
|
||||||
@@ -147,18 +165,18 @@ func (e *Docker) writeFiles(dir string, files Files) error {
|
|||||||
|
|
||||||
// exec executes the step in the docker container
|
// exec executes the step in the docker container
|
||||||
// using the files from in the temporary directory.
|
// using the files from in the temporary directory.
|
||||||
func (e *Docker) exec(box *config.Box, step *config.Step, reqID, dir string, files Files) (stdout string, stderr string, err error) {
|
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
|
// limit the stdout/stderr size
|
||||||
prog := NewProgram(step.Timeout, int64(step.NOutput))
|
prog := NewProgram(step.Timeout, int64(step.NOutput))
|
||||||
args := e.buildArgs(box, step, reqID, dir)
|
args := e.buildArgs(box, step, req, dir)
|
||||||
|
|
||||||
if step.Stdin {
|
if step.Stdin {
|
||||||
// pass files to container from stdin
|
// pass files to container from stdin
|
||||||
stdin := filesReader(files)
|
stdin := filesReader(files)
|
||||||
stdout, stderr, err = prog.RunStdin(stdin, reqID, "docker", args...)
|
stdout, stderr, err = prog.RunStdin(stdin, req.ID, "docker", args...)
|
||||||
} else {
|
} else {
|
||||||
// pass files to container from temp directory
|
// pass files to container from temp directory
|
||||||
stdout, stderr, err = prog.Run(reqID, "docker", args...)
|
stdout, stderr, err = prog.Run(req.ID, "docker", args...)
|
||||||
}
|
}
|
||||||
|
|
||||||
if err == nil {
|
if err == nil {
|
||||||
@@ -172,11 +190,11 @@ func (e *Docker) exec(box *config.Box, step *config.Step, reqID, dir string, fil
|
|||||||
// inside the container is not related to the "docker run" process,
|
// inside the container is not related to the "docker run" process,
|
||||||
// and will hang forever after the "docker run" process is killed
|
// and will hang forever after the "docker run" process is killed
|
||||||
go func() {
|
go func() {
|
||||||
err = dockerKill(reqID)
|
err = dockerKill(req.ID)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
logx.Debug("%s: docker kill ok", reqID)
|
logx.Debug("%s: docker kill ok", req.ID)
|
||||||
} else {
|
} else {
|
||||||
logx.Log("%s: docker kill failed: %v", reqID, err)
|
logx.Log("%s: docker kill failed: %v", req.ID, err)
|
||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
}
|
}
|
||||||
@@ -202,10 +220,10 @@ func (e *Docker) exec(box *config.Box, step *config.Step, reqID, dir string, fil
|
|||||||
}
|
}
|
||||||
|
|
||||||
// buildArgs prepares the arguments for the `docker` command.
|
// buildArgs prepares the arguments for the `docker` command.
|
||||||
func (e *Docker) buildArgs(box *config.Box, step *config.Step, name, dir string) []string {
|
func (e *Docker) buildArgs(box *config.Box, step *config.Step, req Request, dir string) []string {
|
||||||
var args []string
|
var args []string
|
||||||
if step.Action == actionRun {
|
if step.Action == actionRun {
|
||||||
args = dockerRunArgs(box, step, name, dir)
|
args = dockerRunArgs(box, step, req, dir)
|
||||||
} else if step.Action == actionExec {
|
} else if step.Action == actionExec {
|
||||||
args = dockerExecArgs(step)
|
args = dockerExecArgs(step)
|
||||||
} else {
|
} else {
|
||||||
@@ -213,17 +231,17 @@ func (e *Docker) buildArgs(box *config.Box, step *config.Step, name, dir string)
|
|||||||
args = []string{"version"}
|
args = []string{"version"}
|
||||||
}
|
}
|
||||||
|
|
||||||
command := expandVars(step.Command, name)
|
command := expandVars(step.Command, req.ID)
|
||||||
args = append(args, command...)
|
args = append(args, command...)
|
||||||
logx.Debug("%v", args)
|
logx.Debug("%v", args)
|
||||||
return args
|
return args
|
||||||
}
|
}
|
||||||
|
|
||||||
// buildArgs prepares the arguments for the `docker run` command.
|
// buildArgs prepares the arguments for the `docker run` command.
|
||||||
func dockerRunArgs(box *config.Box, step *config.Step, name, dir string) []string {
|
func dockerRunArgs(box *config.Box, step *config.Step, req Request, dir string) []string {
|
||||||
args := []string{
|
args := []string{
|
||||||
actionRun, "--rm",
|
actionRun, "--rm",
|
||||||
"--name", name,
|
"--name", req.ID,
|
||||||
"--runtime", box.Runtime,
|
"--runtime", box.Runtime,
|
||||||
"--cpus", strconv.Itoa(box.CPU),
|
"--cpus", strconv.Itoa(box.CPU),
|
||||||
"--memory", fmt.Sprintf("%dm", box.Memory),
|
"--memory", fmt.Sprintf("%dm", box.Memory),
|
||||||
@@ -255,7 +273,17 @@ func dockerRunArgs(box *config.Box, step *config.Step, name, dir string) []strin
|
|||||||
for _, lim := range box.Ulimit {
|
for _, lim := range box.Ulimit {
|
||||||
args = append(args, "--ulimit", lim)
|
args = append(args, "--ulimit", lim)
|
||||||
}
|
}
|
||||||
args = append(args, box.Image)
|
|
||||||
|
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
|
return args
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -11,6 +11,25 @@ import (
|
|||||||
|
|
||||||
var dockerCfg = &config.Config{
|
var dockerCfg = &config.Config{
|
||||||
Boxes: map[string]*config.Box{
|
Boxes: map[string]*config.Box{
|
||||||
|
"alpine": {
|
||||||
|
Image: "codapi/alpine",
|
||||||
|
Runtime: "runc",
|
||||||
|
Host: config.Host{
|
||||||
|
CPU: 1, Memory: 64, Network: "none",
|
||||||
|
Volume: "%s:/sandbox:ro",
|
||||||
|
NProc: 64,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"go": {
|
||||||
|
Image: "codapi/go",
|
||||||
|
Runtime: "runc",
|
||||||
|
Host: config.Host{
|
||||||
|
CPU: 1, Memory: 64, Network: "none",
|
||||||
|
Volume: "%s:/sandbox:ro",
|
||||||
|
NProc: 64,
|
||||||
|
},
|
||||||
|
Versions: []string{"dev"},
|
||||||
|
},
|
||||||
"postgresql": {
|
"postgresql": {
|
||||||
Image: "codapi/postgresql",
|
Image: "codapi/postgresql",
|
||||||
Runtime: "runc",
|
Runtime: "runc",
|
||||||
@@ -28,9 +47,28 @@ var dockerCfg = &config.Config{
|
|||||||
Volume: "%s:/sandbox:ro",
|
Volume: "%s:/sandbox:ro",
|
||||||
NProc: 64,
|
NProc: 64,
|
||||||
},
|
},
|
||||||
|
Versions: []string{"dev"},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
Commands: map[string]config.SandboxCommands{
|
Commands: map[string]config.SandboxCommands{
|
||||||
|
"go": map[string]*config.Command{
|
||||||
|
"run": {
|
||||||
|
Engine: "docker",
|
||||||
|
Steps: []*config.Step{
|
||||||
|
{
|
||||||
|
Box: "go", User: "sandbox", Action: "run",
|
||||||
|
Command: []string{"go", "build"},
|
||||||
|
NOutput: 4096,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Box: "alpine", Version: "latest",
|
||||||
|
User: "sandbox", Action: "run",
|
||||||
|
Command: []string{"./main"},
|
||||||
|
NOutput: 4096,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
"postgresql": map[string]*config.Command{
|
"postgresql": map[string]*config.Command{
|
||||||
"run": {
|
"run": {
|
||||||
Engine: "docker",
|
Engine: "docker",
|
||||||
@@ -75,9 +113,10 @@ func TestDockerRun(t *testing.T) {
|
|||||||
"docker run": {Stdout: "hello world", Stderr: "", Err: nil},
|
"docker run": {Stdout: "hello world", Stderr: "", Err: nil},
|
||||||
}
|
}
|
||||||
mem := execy.Mock(commands)
|
mem := execy.Mock(commands)
|
||||||
engine := NewDocker(dockerCfg, "python", "run")
|
|
||||||
|
|
||||||
t.Run("success", func(t *testing.T) {
|
t.Run("success", func(t *testing.T) {
|
||||||
|
mem.Clear()
|
||||||
|
engine := NewDocker(dockerCfg, "python", "run")
|
||||||
req := Request{
|
req := Request{
|
||||||
ID: "http_42",
|
ID: "http_42",
|
||||||
Sandbox: "python",
|
Sandbox: "python",
|
||||||
@@ -103,8 +142,88 @@ func TestDockerRun(t *testing.T) {
|
|||||||
if out.Err != nil {
|
if out.Err != nil {
|
||||||
t.Errorf("Err: expected nil, got %v", out.Err)
|
t.Errorf("Err: expected nil, got %v", out.Err)
|
||||||
}
|
}
|
||||||
|
mem.MustHave(t, "codapi/python")
|
||||||
mem.MustHave(t, "python main.py")
|
mem.MustHave(t, "python main.py")
|
||||||
})
|
})
|
||||||
|
|
||||||
|
t.Run("latest version", func(t *testing.T) {
|
||||||
|
mem.Clear()
|
||||||
|
engine := NewDocker(dockerCfg, "python", "run")
|
||||||
|
req := Request{
|
||||||
|
ID: "http_42",
|
||||||
|
Sandbox: "python",
|
||||||
|
Command: "run",
|
||||||
|
Files: map[string]string{
|
||||||
|
"": "print('hello world')",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
out := engine.Exec(req)
|
||||||
|
if !out.OK {
|
||||||
|
t.Error("OK: expected true")
|
||||||
|
}
|
||||||
|
mem.MustHave(t, "codapi/python")
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("custom version", func(t *testing.T) {
|
||||||
|
mem.Clear()
|
||||||
|
engine := NewDocker(dockerCfg, "python", "run")
|
||||||
|
req := Request{
|
||||||
|
ID: "http_42",
|
||||||
|
Sandbox: "python",
|
||||||
|
Version: "dev",
|
||||||
|
Command: "run",
|
||||||
|
Files: map[string]string{
|
||||||
|
"": "print('hello world')",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
out := engine.Exec(req)
|
||||||
|
if !out.OK {
|
||||||
|
t.Error("OK: expected true")
|
||||||
|
}
|
||||||
|
mem.MustHave(t, "codapi/python:dev")
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("step version", func(t *testing.T) {
|
||||||
|
mem.Clear()
|
||||||
|
engine := NewDocker(dockerCfg, "go", "run")
|
||||||
|
req := Request{
|
||||||
|
ID: "http_42",
|
||||||
|
Sandbox: "go",
|
||||||
|
Version: "dev",
|
||||||
|
Command: "run",
|
||||||
|
Files: map[string]string{
|
||||||
|
"": "var n = 42",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
out := engine.Exec(req)
|
||||||
|
if !out.OK {
|
||||||
|
t.Error("OK: expected true")
|
||||||
|
}
|
||||||
|
mem.MustHave(t, "codapi/go:dev")
|
||||||
|
mem.MustHave(t, "codapi/alpine:latest")
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("unsupported version", func(t *testing.T) {
|
||||||
|
mem.Clear()
|
||||||
|
engine := NewDocker(dockerCfg, "python", "run")
|
||||||
|
req := Request{
|
||||||
|
ID: "http_42",
|
||||||
|
Sandbox: "python",
|
||||||
|
Version: "42",
|
||||||
|
Command: "run",
|
||||||
|
Files: map[string]string{
|
||||||
|
"": "print('hello world')",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
out := engine.Exec(req)
|
||||||
|
if out.OK {
|
||||||
|
t.Error("OK: expected false")
|
||||||
|
}
|
||||||
|
want := "box python does not support version 42"
|
||||||
|
if out.Stderr != want {
|
||||||
|
t.Errorf("Stderr: unexpected value: %s", out.Stderr)
|
||||||
|
}
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestDockerExec(t *testing.T) {
|
func TestDockerExec(t *testing.T) {
|
||||||
|
|||||||
@@ -12,13 +12,18 @@ import (
|
|||||||
type Request struct {
|
type Request struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
Sandbox string `json:"sandbox"`
|
Sandbox string `json:"sandbox"`
|
||||||
|
Version string `json:"version,omitempty"`
|
||||||
Command string `json:"command"`
|
Command string `json:"command"`
|
||||||
Files Files `json:"files"`
|
Files Files `json:"files"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// GenerateID() sets a unique ID for the request.
|
// GenerateID() sets a unique ID for the request.
|
||||||
func (r *Request) GenerateID() {
|
func (r *Request) GenerateID() {
|
||||||
r.ID = fmt.Sprintf("%s_%s_%s", r.Sandbox, r.Command, stringx.RandString(8))
|
if r.Version != "" {
|
||||||
|
r.ID = fmt.Sprintf("%s.%s_%s_%s", r.Sandbox, r.Version, r.Command, stringx.RandString(8))
|
||||||
|
} else {
|
||||||
|
r.ID = fmt.Sprintf("%s_%s_%s", r.Sandbox, r.Command, stringx.RandString(8))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// An Execution is an output from the code execution engine.
|
// An Execution is an output from the code execution engine.
|
||||||
|
|||||||
@@ -4,9 +4,34 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"reflect"
|
"reflect"
|
||||||
"sort"
|
"sort"
|
||||||
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
func TestGenerateID(t *testing.T) {
|
||||||
|
t.Run("with version", func(t *testing.T) {
|
||||||
|
req := Request{
|
||||||
|
Sandbox: "python",
|
||||||
|
Version: "dev",
|
||||||
|
Command: "run",
|
||||||
|
}
|
||||||
|
req.GenerateID()
|
||||||
|
if !strings.HasPrefix(req.ID, "python.dev_run_") {
|
||||||
|
t.Errorf("ID: unexpected prefix %s", req.ID)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
t.Run("without version", func(t *testing.T) {
|
||||||
|
req := Request{
|
||||||
|
Sandbox: "python",
|
||||||
|
Command: "run",
|
||||||
|
}
|
||||||
|
req.GenerateID()
|
||||||
|
if !strings.HasPrefix(req.ID, "python_run_") {
|
||||||
|
t.Errorf("ID: unexpected prefix %s", req.ID)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
func TestExecutionError(t *testing.T) {
|
func TestExecutionError(t *testing.T) {
|
||||||
inner := errors.New("inner error")
|
inner := errors.New("inner error")
|
||||||
err := NewExecutionError("failed", inner)
|
err := NewExecutionError("failed", inner)
|
||||||
|
|||||||
@@ -53,9 +53,14 @@ func (m *Memory) MustNotHave(t *testing.T, msg string) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Clear cleares the memory.
|
||||||
|
func (m *Memory) Clear() {
|
||||||
|
m.Lines = []string{}
|
||||||
|
}
|
||||||
|
|
||||||
// Print prints memory lines to stdout.
|
// Print prints memory lines to stdout.
|
||||||
func (m *Memory) Print() {
|
func (m *Memory) Print() {
|
||||||
for _, line := range m.Lines {
|
for _, line := range m.Lines {
|
||||||
fmt.Print(line)
|
fmt.Println(line)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user