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

165
config/config.go Normal file
View File

@@ -0,0 +1,165 @@
// Package config reads application config.
package config
import (
"encoding/json"
"sort"
)
// A Config describes application cofig.
type Config struct {
PoolSize int `json:"pool_size"`
Verbose bool `json:"verbose"`
Box *Box `json:"box"`
Step *Step `json:"step"`
HTTP *HTTP `json:"http"`
// These are the available containers ("boxes").
Boxes map[string]*Box `json:"boxes"`
// These are the "sandboxes". Each sandbox can contain
// multiple commands, and each command can contain
// multiple steps. Each step is executed in a specific box.
Commands map[string]SandboxCommands `json:"commands"`
}
// BoxNames returns configured box names.
func (cfg *Config) BoxNames() []string {
names := make([]string, 0, len(cfg.Boxes))
for name := range cfg.Boxes {
names = append(names, name)
}
sort.Strings(names)
return names
}
// CommandNames returns configured command names.
func (cfg *Config) CommandNames() []string {
names := make([]string, 0, len(cfg.Commands))
for name := range cfg.Commands {
names = append(names, name)
}
sort.Strings(names)
return names
}
// ToJSON returns JSON-encoded config with indentation.
func (cfg *Config) ToJSON() string {
data, _ := json.MarshalIndent(cfg, "", " ")
return string(data)
}
// A Box describes a specific container.
// There is an important difference between a "sandbox" and a "box".
// A box is a single container. A sandbox is an environment in which we run commands.
// A sandbox command can contain multiple steps, each of which runs in a separate box.
// So the relation sandbox -> box is 1 -> 1+.
type Box struct {
Image string `json:"image"`
Runtime string `json:"runtime"`
Host
Files []string `json:"files"`
}
// A Host describes container Host attributes.
type Host struct {
CPU int `json:"cpu"`
Memory int `json:"memory"`
Storage string `json:"storage"`
Network string `json:"network"`
Writable bool `json:"writable"`
Volume string `json:"volume"`
Tmpfs []string `json:"tmpfs"`
CapAdd []string `json:"cap_add"`
CapDrop []string `json:"cap_drop"`
Ulimit []string `json:"ulimit"`
// do not use the ulimit nproc because it is
// a per-user setting, not a per-container setting
NProc int `json:"nproc"`
}
// SandboxCommands describes all commands available for a sandbox.
// command name : command
type SandboxCommands map[string]*Command
// A Command describes a specific set of actions to take
// when executing a command in a sandbox.
type Command struct {
Engine string `json:"engine"`
Entry string `json:"entry"`
Before *Step `json:"before"`
Steps []*Step `json:"steps"`
After *Step `json:"after"`
}
// A Step describes a single step of a command.
type Step struct {
Box string `json:"box"`
User string `json:"user"`
Action string `json:"action"`
Stdin bool `json:"stdin"`
Command []string `json:"command"`
Timeout int `json:"timeout"`
NOutput int `json:"noutput"`
}
// An HTTP describes HTTP engine settings.
type HTTP struct {
Hosts map[string]string `json:"hosts"`
}
// setBoxDefaults sets default box properties
// instead of zero values.
func setBoxDefaults(box, defs *Box) {
if box.Runtime == "" {
box.Runtime = defs.Runtime
}
if box.CPU == 0 {
box.CPU = defs.CPU
}
if box.Memory == 0 {
box.Memory = defs.Memory
}
if box.Storage == "" {
box.Storage = defs.Storage
}
if box.Network == "" {
box.Network = defs.Network
}
if box.Volume == "" {
box.Volume = defs.Volume
}
if box.Tmpfs == nil {
box.Tmpfs = defs.Tmpfs
}
if box.CapAdd == nil {
box.CapAdd = defs.CapAdd
}
if box.CapDrop == nil {
box.CapDrop = defs.CapDrop
}
if box.Ulimit == nil {
box.Ulimit = defs.Ulimit
}
if box.NProc == 0 {
box.NProc = defs.NProc
}
}
// setStepDefaults sets default command step
// properties instead of zero values.
func setStepDefaults(step, defs *Step) {
if step.User == "" {
step.User = defs.User
}
if step.Action == "" {
step.Action = defs.Action
}
if step.Timeout == 0 {
step.Timeout = defs.Timeout
}
if step.NOutput == 0 {
step.NOutput = defs.NOutput
}
}

157
config/config_test.go Normal file
View File

@@ -0,0 +1,157 @@
package config
import (
"reflect"
"strings"
"testing"
)
func TestConfig_BoxNames(t *testing.T) {
cfg := &Config{
Boxes: map[string]*Box{
"go": {},
"python": {},
},
}
want := []string{"go", "python"}
got := cfg.BoxNames()
if !reflect.DeepEqual(got, want) {
t.Errorf("BoxNames: expected %v, got %v", want, got)
}
}
func TestConfig_CommandNames(t *testing.T) {
cfg := &Config{
Commands: map[string]SandboxCommands{
"go": map[string]*Command{
"run": {},
},
"python": map[string]*Command{
"run": {},
"test": {},
},
},
}
want := []string{"go", "python"}
got := cfg.CommandNames()
if !reflect.DeepEqual(got, want) {
t.Errorf("CommandNames: expected %v, got %v", want, got)
}
}
func TestConfig_ToJSON(t *testing.T) {
cfg := &Config{
PoolSize: 8,
Boxes: map[string]*Box{
"go": {},
"python": {},
},
Commands: map[string]SandboxCommands{
"go": map[string]*Command{
"run": {},
},
"python": map[string]*Command{
"run": {},
"test": {},
},
},
}
got := cfg.ToJSON()
if !strings.Contains(got, `"pool_size": 8`) {
t.Error("ToJSON: expected pool_size = 8")
}
}
func Test_setBoxDefaults(t *testing.T) {
box := &Box{}
defs := &Box{
Image: "codapi/python",
Runtime: "runc",
Host: Host{
CPU: 1, Memory: 64, Storage: "16m",
Network: "none", Writable: true,
Volume: "%s:/sandbox:ro",
Tmpfs: []string{"/tmp:rw,size=16m"},
CapAdd: []string{"all"},
CapDrop: []string{"none"},
Ulimit: []string{"nofile=96"},
NProc: 96,
},
Files: []string{"config.py"},
}
setBoxDefaults(box, defs)
if box.Image != "" {
t.Error("Image: should not set default value")
}
if box.Runtime != defs.Runtime {
t.Errorf("Runtime: expected %s, got %s", defs.Runtime, box.Runtime)
}
if box.CPU != defs.CPU {
t.Errorf("CPU: expected %d, got %d", defs.CPU, box.CPU)
}
if box.Memory != defs.Memory {
t.Errorf("Memory: expected %d, got %d", defs.Memory, box.Memory)
}
if box.Storage != defs.Storage {
t.Errorf("Storage: expected %s, got %s", defs.Storage, box.Storage)
}
if box.Network != defs.Network {
t.Errorf("Network: expected %s, got %s", defs.Network, box.Network)
}
if box.Volume != defs.Volume {
t.Errorf("Volume: expected %s, got %s", defs.Volume, box.Volume)
}
if !reflect.DeepEqual(box.Tmpfs, defs.Tmpfs) {
t.Errorf("Tmpfs: expected %v, got %v", defs.Tmpfs, box.Tmpfs)
}
if !reflect.DeepEqual(box.CapAdd, defs.CapAdd) {
t.Errorf("CapAdd: expected %v, got %v", defs.CapAdd, box.CapAdd)
}
if !reflect.DeepEqual(box.CapDrop, defs.CapDrop) {
t.Errorf("CapDrop: expected %v, got %v", defs.CapDrop, box.CapDrop)
}
if !reflect.DeepEqual(box.Ulimit, defs.Ulimit) {
t.Errorf("Ulimit: expected %v, got %v", defs.Ulimit, box.Ulimit)
}
if box.NProc != defs.NProc {
t.Errorf("NProc: expected %d, got %d", defs.NProc, box.NProc)
}
if len(box.Files) != 0 {
t.Error("Files: should not set default value")
}
}
func Test_setStepDefaults(t *testing.T) {
step := &Step{}
defs := &Step{
Box: "python",
User: "sandbox",
Action: "run",
Command: []string{"python", "main.py"},
Timeout: 3,
NOutput: 4096,
}
setStepDefaults(step, defs)
if step.Box != "" {
t.Error("Box: should not set default value")
}
if step.User != defs.User {
t.Errorf("User: expected %s, got %s", defs.User, step.User)
}
if step.Action != defs.Action {
t.Errorf("Action: expected %s, got %s", defs.Action, step.Action)
}
if len(step.Command) != 0 {
t.Error("Command: should not set default value")
}
if step.Timeout != defs.Timeout {
t.Errorf("Timeout: expected %d, got %d", defs.Timeout, step.Timeout)
}
if step.NOutput != defs.NOutput {
t.Errorf("NOutput: expected %d, got %d", defs.NOutput, step.NOutput)
}
}

94
config/load.go Normal file
View File

@@ -0,0 +1,94 @@
package config
import (
"encoding/json"
"os"
)
// Read reads application config from JSON files.
func Read(cfgPath, boxPath, cmdPath string) (*Config, error) {
cfg, err := ReadConfig(cfgPath)
if err != nil {
return nil, err
}
cfg, err = ReadBoxes(cfg, boxPath)
if err != nil {
return nil, err
}
cfg, err = ReadCommands(cfg, cmdPath)
if err != nil {
return nil, err
}
return cfg, err
}
// ReadConfig reads application config from a JSON file.
func ReadConfig(path string) (*Config, error) {
data, err := os.ReadFile(path)
if err != nil {
return nil, err
}
cfg := &Config{}
err = json.Unmarshal(data, cfg)
if err != nil {
return nil, err
}
return cfg, err
}
// ReadBoxes reads boxes config from a JSON file.
func ReadBoxes(cfg *Config, path string) (*Config, error) {
data, err := os.ReadFile(path)
if err != nil {
return nil, err
}
boxes := make(map[string]*Box)
err = json.Unmarshal(data, &boxes)
if err != nil {
return nil, err
}
for _, box := range boxes {
setBoxDefaults(box, cfg.Box)
}
cfg.Boxes = boxes
return cfg, err
}
// ReadCommands reads commands config from a JSON file.
func ReadCommands(cfg *Config, path string) (*Config, error) {
data, err := os.ReadFile(path)
if err != nil {
return nil, err
}
commands := make(map[string]SandboxCommands)
err = json.Unmarshal(data, &commands)
if err != nil {
return nil, err
}
for _, playCmds := range commands {
for _, cmd := range playCmds {
if cmd.Before != nil {
setStepDefaults(cmd.Before, cfg.Step)
}
for _, step := range cmd.Steps {
setStepDefaults(step, cfg.Step)
}
if cmd.After != nil {
setStepDefaults(cmd.After, cfg.Step)
}
}
}
cfg.Commands = commands
return cfg, err
}

38
config/load_test.go Normal file
View File

@@ -0,0 +1,38 @@
package config
import (
"path/filepath"
"testing"
)
func TestRead(t *testing.T) {
cfgPath := filepath.Join("testdata", "config.json")
boxPath := filepath.Join("testdata", "boxes.json")
cmdPath := filepath.Join("testdata", "commands.json")
cfg, err := Read(cfgPath, boxPath, cmdPath)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if cfg.PoolSize != 8 {
t.Errorf("PoolSize: expected 8, got %d", cfg.PoolSize)
}
if !cfg.Verbose {
t.Error("Verbose: expected true")
}
if cfg.Box.Memory != 64 {
t.Errorf("Box.Memory: expected 64, got %d", cfg.Box.Memory)
}
if cfg.Step.User != "sandbox" {
t.Errorf("Step.User: expected sandbox, got %s", cfg.Step.User)
}
if _, ok := cfg.Boxes["python"]; !ok {
t.Error("Boxes: missing python box")
}
if _, ok := cfg.Commands["python"]; !ok {
t.Error("Commands: missing python sandbox")
}
if _, ok := cfg.Commands["python"]["run"]; !ok {
t.Error("Commands[python]: missing run command")
}
}

5
config/testdata/boxes.json vendored Normal file
View File

@@ -0,0 +1,5 @@
{
"python": {
"image": "codapi/python"
}
}

25
config/testdata/commands.json vendored Normal file
View File

@@ -0,0 +1,25 @@
{
"python": {
"run": {
"engine": "docker",
"entry": "main.py",
"steps": [
{
"box": "python",
"command": ["python", "main.py"]
}
]
},
"test": {
"engine": "docker",
"entry": "test_main.py",
"steps": [
{
"box": "python",
"command": ["python", "-m", "unittest"],
"noutput": 8192
}
]
}
}
}

10
config/testdata/config.json vendored Normal file
View File

@@ -0,0 +1,10 @@
{
"pool_size": 8,
"verbose": true,
"box": {
"memory": 64
},
"step": {
"user": "sandbox"
}
}