feat: initial public version

This commit is contained in:
Anton
2023-11-25 04:02:45 +05:00
parent ebd1d47fc6
commit 8447197d0f
64 changed files with 3880 additions and 4 deletions

43
sandbox/config.go Normal file
View 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
View 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
View 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
View 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
View 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
View 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()
})
}