feat: initial public version
This commit is contained in:
43
sandbox/config.go
Normal file
43
sandbox/config.go
Normal file
@@ -0,0 +1,43 @@
|
||||
// Creates sandboxes according to the configuration.
|
||||
package sandbox
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/nalgeon/codapi/config"
|
||||
"github.com/nalgeon/codapi/engine"
|
||||
)
|
||||
|
||||
// A semaphore represents available concurrent workers
|
||||
// that are responsible for executing code in sandboxes.
|
||||
// The workers themselves are external to this package
|
||||
// (the calling goroutines are workers).
|
||||
var semaphore *Semaphore
|
||||
|
||||
var engineConstr = map[string]func(*config.Config, string, string) engine.Engine{
|
||||
"docker": engine.NewDocker,
|
||||
"http": engine.NewHTTP,
|
||||
}
|
||||
|
||||
// engines is the registry of command executors.
|
||||
// Each engine executes a specific command in a specifix sandbox.
|
||||
// sandbox : command : engine
|
||||
// TODO: Maybe it's better to create a single instance of each engine
|
||||
// and pass the sandbox and command as arguments to the Exec.
|
||||
var engines = map[string]map[string]engine.Engine{}
|
||||
|
||||
// ApplyConfig fills engine registry according to the configuration.
|
||||
func ApplyConfig(cfg *config.Config) error {
|
||||
semaphore = NewSemaphore(cfg.PoolSize)
|
||||
for sandName, sandCmds := range cfg.Commands {
|
||||
engines[sandName] = make(map[string]engine.Engine)
|
||||
for cmdName, cmd := range sandCmds {
|
||||
constructor, ok := engineConstr[cmd.Engine]
|
||||
if !ok {
|
||||
return fmt.Errorf("unknown engine: %s", cmd.Engine)
|
||||
}
|
||||
engines[sandName][cmdName] = constructor(cfg, sandName, cmdName)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
59
sandbox/config_test.go
Normal file
59
sandbox/config_test.go
Normal file
@@ -0,0 +1,59 @@
|
||||
package sandbox
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/nalgeon/codapi/config"
|
||||
"github.com/nalgeon/codapi/engine"
|
||||
)
|
||||
|
||||
var cfg = &config.Config{
|
||||
PoolSize: 8,
|
||||
HTTP: &config.HTTP{
|
||||
Hosts: map[string]string{"localhost": "localhost"},
|
||||
},
|
||||
Boxes: map[string]*config.Box{
|
||||
"http": {},
|
||||
"python": {},
|
||||
},
|
||||
Commands: map[string]config.SandboxCommands{
|
||||
"http": map[string]*config.Command{
|
||||
"run": {Engine: "http"},
|
||||
},
|
||||
"python": map[string]*config.Command{
|
||||
"run": {
|
||||
Engine: "docker",
|
||||
Entry: "main.py",
|
||||
Steps: []*config.Step{
|
||||
{Box: "python", Action: "run", NOutput: 4096},
|
||||
},
|
||||
},
|
||||
"test": {Engine: "docker"},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
func TestApplyConfig(t *testing.T) {
|
||||
err := ApplyConfig(cfg)
|
||||
if err != nil {
|
||||
t.Fatalf("ApplyConfig: expected nil err, got %v", err)
|
||||
}
|
||||
if semaphore.Size() != cfg.PoolSize {
|
||||
t.Errorf("semaphore.Size: expected %d, got %d", cfg.PoolSize, semaphore.Size())
|
||||
}
|
||||
if len(engines) != 2 {
|
||||
t.Errorf("len(engines): expected 2, got %d", len(engines))
|
||||
}
|
||||
if len(engines["http"]) != 1 {
|
||||
t.Errorf("len(engine = http): expected 1, got %d", len(engines["http"]))
|
||||
}
|
||||
if _, ok := engines["http"]["run"].(*engine.HTTP); !ok {
|
||||
t.Error("engine = http: expected HTTP engine")
|
||||
}
|
||||
if len(engines["python"]) != 2 {
|
||||
t.Errorf("len(engine = python): expected 2, got %d", len(engines["python"]))
|
||||
}
|
||||
if _, ok := engines["python"]["run"].(*engine.Docker); !ok {
|
||||
t.Error("engine = python: expected Docker engine")
|
||||
}
|
||||
}
|
||||
47
sandbox/sandbox.go
Normal file
47
sandbox/sandbox.go
Normal file
@@ -0,0 +1,47 @@
|
||||
// Package sandbox provides a registry of sandboxes
|
||||
// for code execution.
|
||||
package sandbox
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/nalgeon/codapi/engine"
|
||||
)
|
||||
|
||||
var ErrUnknownSandbox = errors.New("unknown sandbox")
|
||||
var ErrUnknownCommand = errors.New("unknown command")
|
||||
var ErrEmptyRequest = errors.New("empty request")
|
||||
|
||||
// Validate checks if the code execution request is valid.
|
||||
func Validate(in engine.Request) error {
|
||||
box, ok := engines[in.Sandbox]
|
||||
if !ok {
|
||||
return ErrUnknownSandbox
|
||||
}
|
||||
_, ok = box[in.Command]
|
||||
if !ok {
|
||||
return ErrUnknownCommand
|
||||
}
|
||||
if len(in.Files) < 2 && strings.TrimSpace(in.Files.First()) == "" {
|
||||
return ErrEmptyRequest
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Exec executes the code using the appropriate sandbox.
|
||||
// Allows no more than pool.Size() concurrent workers at any given time.
|
||||
// The request must already be validated by Validate().
|
||||
func Exec(in engine.Request) engine.Execution {
|
||||
err := semaphore.Acquire()
|
||||
defer semaphore.Release()
|
||||
if err == ErrBusy {
|
||||
return engine.Fail(in.ID, engine.ErrBusy)
|
||||
}
|
||||
start := time.Now()
|
||||
engine := engines[in.Sandbox][in.Command]
|
||||
out := engine.Exec(in)
|
||||
out.Duration = int(time.Since(start).Milliseconds())
|
||||
return out
|
||||
}
|
||||
115
sandbox/sandbox_test.go
Normal file
115
sandbox/sandbox_test.go
Normal file
@@ -0,0 +1,115 @@
|
||||
package sandbox
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"github.com/nalgeon/codapi/engine"
|
||||
"github.com/nalgeon/codapi/execy"
|
||||
)
|
||||
|
||||
func TestValidate(t *testing.T) {
|
||||
_ = ApplyConfig(cfg)
|
||||
t.Run("valid", func(t *testing.T) {
|
||||
|
||||
req := engine.Request{
|
||||
ID: "http_42",
|
||||
Sandbox: "python",
|
||||
Command: "run",
|
||||
Files: map[string]string{
|
||||
"": "print('hello')",
|
||||
},
|
||||
}
|
||||
err := Validate(req)
|
||||
if err != nil {
|
||||
t.Errorf("Validate: expected nil err, got %v", err)
|
||||
}
|
||||
})
|
||||
t.Run("unknown sandbox", func(t *testing.T) {
|
||||
req := engine.Request{
|
||||
ID: "http_42",
|
||||
Sandbox: "rust",
|
||||
Command: "run",
|
||||
Files: nil,
|
||||
}
|
||||
err := Validate(req)
|
||||
if !errors.Is(err, ErrUnknownSandbox) {
|
||||
t.Errorf("Validate: expected ErrUnknownSandbox, got %T(%s)", err, err)
|
||||
}
|
||||
})
|
||||
t.Run("unknown command", func(t *testing.T) {
|
||||
req := engine.Request{
|
||||
ID: "http_42",
|
||||
Sandbox: "python",
|
||||
Command: "deploy",
|
||||
Files: nil,
|
||||
}
|
||||
err := Validate(req)
|
||||
if !errors.Is(err, ErrUnknownCommand) {
|
||||
t.Errorf("Validate: expected ErrUnknownCommand, got %T(%s)", err, err)
|
||||
}
|
||||
})
|
||||
t.Run("empty request", func(t *testing.T) {
|
||||
req := engine.Request{
|
||||
ID: "http_42",
|
||||
Sandbox: "python",
|
||||
Command: "run",
|
||||
Files: nil,
|
||||
}
|
||||
err := Validate(req)
|
||||
if !errors.Is(err, ErrEmptyRequest) {
|
||||
t.Errorf("Validate: expected ErrEmptyRequest, got %T(%s)", err, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestExec(t *testing.T) {
|
||||
_ = ApplyConfig(cfg)
|
||||
t.Run("exec", func(t *testing.T) {
|
||||
execy.Mock(map[string]execy.CmdOut{
|
||||
"docker run": {Stdout: "hello"},
|
||||
})
|
||||
req := engine.Request{
|
||||
ID: "http_42",
|
||||
Sandbox: "python",
|
||||
Command: "run",
|
||||
Files: map[string]string{
|
||||
"": "print('hello')",
|
||||
},
|
||||
}
|
||||
out := Exec(req)
|
||||
if out.ID != req.ID {
|
||||
t.Errorf("ID: expected %s, got %s", req.ID, out.ID)
|
||||
}
|
||||
if !out.OK {
|
||||
t.Error("OK: expected true")
|
||||
}
|
||||
if out.Stdout != "hello" {
|
||||
t.Errorf("Stdout: expected hello, got %s", out.Stdout)
|
||||
}
|
||||
if out.Stderr != "" {
|
||||
t.Errorf("Stderr: expected empty string, got %s", out.Stderr)
|
||||
}
|
||||
if out.Err != nil {
|
||||
t.Errorf("Err: expected nil, got %v", out.Err)
|
||||
}
|
||||
})
|
||||
t.Run("busy", func(t *testing.T) {
|
||||
for i := 0; i < cfg.PoolSize; i++ {
|
||||
_ = semaphore.Acquire()
|
||||
}
|
||||
req := engine.Request{
|
||||
ID: "http_42",
|
||||
Sandbox: "python",
|
||||
Command: "run",
|
||||
Files: map[string]string{
|
||||
"": "print('hello')",
|
||||
},
|
||||
}
|
||||
out := Exec(req)
|
||||
if out.Err != engine.ErrBusy {
|
||||
t.Errorf("Err: expected ErrBusy, got %v", out.Err)
|
||||
}
|
||||
})
|
||||
|
||||
}
|
||||
45
sandbox/semaphore.go
Normal file
45
sandbox/semaphore.go
Normal file
@@ -0,0 +1,45 @@
|
||||
package sandbox
|
||||
|
||||
import "errors"
|
||||
|
||||
var ErrBusy = errors.New("busy")
|
||||
|
||||
// A Semaphore manages a limited number of tokens
|
||||
// that can be acquired or released.
|
||||
type Semaphore struct {
|
||||
tokens chan struct{}
|
||||
}
|
||||
|
||||
// NewSemaphore creates a new semaphore of the specified size.
|
||||
func NewSemaphore(size int) *Semaphore {
|
||||
tokens := make(chan struct{}, size)
|
||||
for i := 0; i < size; i++ {
|
||||
tokens <- struct{}{}
|
||||
}
|
||||
return &Semaphore{tokens}
|
||||
}
|
||||
|
||||
// Acquire acquires a token. Returns ErrBusy if no tokens are available.
|
||||
func (q *Semaphore) Acquire() error {
|
||||
select {
|
||||
case <-q.tokens:
|
||||
return nil
|
||||
default:
|
||||
return ErrBusy
|
||||
}
|
||||
}
|
||||
|
||||
// Release releases a token.
|
||||
func (q *Semaphore) Release() {
|
||||
select {
|
||||
case q.tokens <- struct{}{}:
|
||||
return
|
||||
default:
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Size returns the size of the semaphore.
|
||||
func (q *Semaphore) Size() int {
|
||||
return len(q.tokens)
|
||||
}
|
||||
45
sandbox/semaphore_test.go
Normal file
45
sandbox/semaphore_test.go
Normal file
@@ -0,0 +1,45 @@
|
||||
package sandbox
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestSemaphore(t *testing.T) {
|
||||
t.Run("size", func(t *testing.T) {
|
||||
sem := NewSemaphore(3)
|
||||
if sem.Size() != 3 {
|
||||
t.Errorf("Size: expected 3, got %d", sem.Size())
|
||||
}
|
||||
})
|
||||
t.Run("acquire", func(t *testing.T) {
|
||||
sem := NewSemaphore(2)
|
||||
err := sem.Acquire()
|
||||
if err != nil {
|
||||
t.Fatalf("acquire #1: expected nil err")
|
||||
}
|
||||
err = sem.Acquire()
|
||||
if err != nil {
|
||||
t.Fatalf("acquire #2: expected nil err")
|
||||
}
|
||||
err = sem.Acquire()
|
||||
if err != ErrBusy {
|
||||
t.Fatalf("acquire #3: expected ErrBusy")
|
||||
}
|
||||
})
|
||||
t.Run("release", func(t *testing.T) {
|
||||
sem := NewSemaphore(2)
|
||||
_ = sem.Acquire()
|
||||
_ = sem.Acquire()
|
||||
_ = sem.Acquire()
|
||||
|
||||
sem.Release()
|
||||
err := sem.Acquire()
|
||||
if err != nil {
|
||||
t.Fatalf("acquire after release: expected nil err")
|
||||
}
|
||||
})
|
||||
t.Run("release free", func(t *testing.T) {
|
||||
sem := NewSemaphore(2)
|
||||
sem.Release()
|
||||
sem.Release()
|
||||
sem.Release()
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user