feat: initial public version
This commit is contained in:
41
.github/workflows/build.yml
vendored
Normal file
41
.github/workflows/build.yml
vendored
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
name: build
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [main]
|
||||||
|
paths-ignore:
|
||||||
|
- "docs/**"
|
||||||
|
- Makefile
|
||||||
|
- README.md
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
defaults:
|
||||||
|
run:
|
||||||
|
shell: bash
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v3
|
||||||
|
|
||||||
|
- name: Setup Go
|
||||||
|
uses: actions/setup-go@v4
|
||||||
|
with:
|
||||||
|
go-version: "stable"
|
||||||
|
|
||||||
|
- name: Test and build
|
||||||
|
run: make test build
|
||||||
|
|
||||||
|
- name: Upload artifact
|
||||||
|
uses: actions/upload-artifact@v3
|
||||||
|
with:
|
||||||
|
name: codapi
|
||||||
|
path: |
|
||||||
|
build/codapi
|
||||||
|
images/
|
||||||
|
*.json
|
||||||
|
codapi.service
|
||||||
|
Makefile
|
||||||
|
retention-days: 7
|
||||||
4
LICENSE
4
LICENSE
@@ -629,8 +629,8 @@ to attach them to the start of each source file to most effectively
|
|||||||
state the exclusion of warranty; and each file should have at least
|
state the exclusion of warranty; and each file should have at least
|
||||||
the "copyright" line and a pointer to where the full notice is found.
|
the "copyright" line and a pointer to where the full notice is found.
|
||||||
|
|
||||||
<one line to give the program's name and a brief idea of what it does.>
|
Codapi - Embeddable code playgrounds
|
||||||
Copyright (C) <year> <name of author>
|
Copyright (C) 2023+ Anton Zhiyanov <https://antonz.org>
|
||||||
|
|
||||||
This program is free software: you can redistribute it and/or modify
|
This program is free software: you can redistribute it and/or modify
|
||||||
it under the terms of the GNU Affero General Public License as published
|
it under the terms of the GNU Affero General Public License as published
|
||||||
|
|||||||
62
Makefile
Normal file
62
Makefile
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
.PHONY: build images
|
||||||
|
|
||||||
|
# Development
|
||||||
|
|
||||||
|
build_rev := "main"
|
||||||
|
ifneq ($(wildcard .git),)
|
||||||
|
build_rev := $(shell git rev-parse --short HEAD)
|
||||||
|
endif
|
||||||
|
|
||||||
|
build_date := $(shell date -u '+%Y%m%d')
|
||||||
|
version := $(build_date):$(build_rev)
|
||||||
|
|
||||||
|
setup:
|
||||||
|
@go mod download
|
||||||
|
|
||||||
|
lint:
|
||||||
|
@golangci-lint run --print-issued-lines=false --out-format=colored-line-number ./...
|
||||||
|
|
||||||
|
vet:
|
||||||
|
@go vet ./...
|
||||||
|
|
||||||
|
test:
|
||||||
|
@go test ./... -v
|
||||||
|
|
||||||
|
|
||||||
|
build:
|
||||||
|
@go build -ldflags "-X main.Version=$(version)" -o build/codapi -v cmd/main.go
|
||||||
|
|
||||||
|
run:
|
||||||
|
@./build/codapi
|
||||||
|
|
||||||
|
|
||||||
|
# Containers
|
||||||
|
|
||||||
|
images:
|
||||||
|
docker build --file images/alpine/Dockerfile --tag codapi/alpine:latest images/alpine/
|
||||||
|
|
||||||
|
network:
|
||||||
|
docker network create --internal codapi
|
||||||
|
|
||||||
|
# Host OS
|
||||||
|
|
||||||
|
mount-tmp:
|
||||||
|
mount -t tmpfs tmpfs /tmp -o rw,exec,nosuid,nodev,size=64m,mode=1777
|
||||||
|
|
||||||
|
# Deployment
|
||||||
|
|
||||||
|
app-download:
|
||||||
|
@curl -L -o codapi.zip "https://api.github.com/repos/nalgeon/codapi/actions/artifacts/$(id)/zip"
|
||||||
|
@unzip -ou codapi.zip
|
||||||
|
@chmod +x build/codapi
|
||||||
|
@rm -f codapi.zip
|
||||||
|
@echo "OK"
|
||||||
|
|
||||||
|
app-start:
|
||||||
|
@nohup build/codapi > codapi.log 2>&1 & echo $$! > codapi.pid
|
||||||
|
@echo "started codapi"
|
||||||
|
|
||||||
|
app-stop:
|
||||||
|
@kill $(shell cat codapi.pid)
|
||||||
|
@rm -f codapi.pid
|
||||||
|
@echo "stopped codapi"
|
||||||
99
README.md
99
README.md
@@ -1,2 +1,97 @@
|
|||||||
# codapi
|
# Embeddable code playgrounds
|
||||||
Embeddable code playgrounds.
|
|
||||||
|
> For education, documentation, and fun.
|
||||||
|
|
||||||
|
Codapi is a platform for embedding interactive code snippets directly into your product documentation, online course, or blog post.
|
||||||
|
|
||||||
|
```
|
||||||
|
python
|
||||||
|
┌───────────────────────────────┐
|
||||||
|
│ msg = "Hello, World!" │
|
||||||
|
│ print(msg) │
|
||||||
|
│ │
|
||||||
|
│ │
|
||||||
|
│ run ► │
|
||||||
|
└───────────────────────────────┘
|
||||||
|
✓ took 387 ms
|
||||||
|
┌───────────────────────────────┐
|
||||||
|
│ Hello, World! │
|
||||||
|
└───────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
Codapi manages sandboxes (isolated execution environments) and provides an API to execute code in these sandboxes. It also provides a JavaScript widget [codapi-js](https://github.com/nalgeon/codapi-js) for easier integration.
|
||||||
|
|
||||||
|
Highlights:
|
||||||
|
|
||||||
|
- Supports dozens of playgrounds out of the box, plus custom sandboxes if you need them.
|
||||||
|
- Available as a cloud service and as a self-hosted version.
|
||||||
|
- Open source. Uses the AGPL license. Committed to remaining open source forever.
|
||||||
|
- Lightweight and easy to integrate.
|
||||||
|
|
||||||
|
Learn more at [**codapi.org**](https://codapi.org/)
|
||||||
|
|
||||||
|
## Installation
|
||||||
|
|
||||||
|
See [Installing Codapi](docs/install.md) for details.
|
||||||
|
|
||||||
|
## Usage (API)
|
||||||
|
|
||||||
|
Call `/v1/exec` to run the code in a sandbox:
|
||||||
|
|
||||||
|
```http
|
||||||
|
POST https://api.codapi.org/v1/exec
|
||||||
|
content-type: application/json
|
||||||
|
|
||||||
|
{
|
||||||
|
"sandbox": "python",
|
||||||
|
"command": "run",
|
||||||
|
"files": {
|
||||||
|
"": "print('hello world')"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`sandbox` is the name of the pre-configured sandbox, and `command` is the name of a command supported by that sandbox. See [Configuration](docs/config.md) for details.
|
||||||
|
|
||||||
|
`files` is a map, where the key is a filename and the value is its contents. When executing a single file, it should either be named as the `command` expects, or be an empty string (as in the example above).
|
||||||
|
|
||||||
|
Response:
|
||||||
|
|
||||||
|
```http
|
||||||
|
HTTP/1.1 200 OK
|
||||||
|
Content-Type: application/json
|
||||||
|
|
||||||
|
```
|
||||||
|
|
||||||
|
- `id` is the unique execution identifier.
|
||||||
|
- `ok` is `true` if the code executed without errors, or `false` otherwise.
|
||||||
|
- `duration` is the execution time in milliseconds.
|
||||||
|
- `stdout` is what the code printed to the standard output.
|
||||||
|
- `stderr` is what the code printed to the standard error, or a compiler/os error (if any).
|
||||||
|
|
||||||
|
## Usage (JavaScript)
|
||||||
|
|
||||||
|
See [codapi-js](https://github.com/nalgeon/codapi-js) to embed the JavaScript widget into a web page. The widget uses exactly the same API as described above.
|
||||||
|
|
||||||
|
## Contributing
|
||||||
|
|
||||||
|
Pull requests are welcome. For new features or improvements, please first open an issue to discuss what you would like to change.
|
||||||
|
|
||||||
|
Be sure to add or update tests as appropriate.
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
Copyright 2023+ [Anton Zhiyanov](https://antonz.org/).
|
||||||
|
|
||||||
|
The software is available under the AGPL License.
|
||||||
|
|
||||||
|
## Stay tuned
|
||||||
|
|
||||||
|
[**★ Subscribe**](https://antonz.org/subscribe/) to stay on top of new features.
|
||||||
|
Copyright 2023+ [Anton Zhiyanov](https://antonz.org/).
|
||||||
|
|
||||||
|
The software is available under the AGPL License.
|
||||||
|
|
||||||
|
## Stay tuned
|
||||||
|
|
||||||
|
[**★ Subscribe**](https://antonz.org/subscribe/) to stay on top of new features.
|
||||||
|
|||||||
5
boxes.json
Normal file
5
boxes.json
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
{
|
||||||
|
"alpine": {
|
||||||
|
"image": "codapi/alpine"
|
||||||
|
}
|
||||||
|
}
|
||||||
64
cmd/main.go
Normal file
64
cmd/main.go
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
// Codapi safely executes code snippets using sandboxes.
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"flag"
|
||||||
|
"os"
|
||||||
|
"os/signal"
|
||||||
|
"syscall"
|
||||||
|
|
||||||
|
"github.com/nalgeon/codapi/config"
|
||||||
|
"github.com/nalgeon/codapi/logx"
|
||||||
|
"github.com/nalgeon/codapi/sandbox"
|
||||||
|
"github.com/nalgeon/codapi/server"
|
||||||
|
)
|
||||||
|
|
||||||
|
var Version string = "main"
|
||||||
|
|
||||||
|
// startServer starts the HTTP API sandbox server.
|
||||||
|
func startServer(port int) *server.Server {
|
||||||
|
logx.Log("codapi %s", Version)
|
||||||
|
logx.Log("listening on port %d...", port)
|
||||||
|
router := server.NewRouter()
|
||||||
|
srv := server.NewServer(port, router)
|
||||||
|
srv.Start()
|
||||||
|
return srv
|
||||||
|
}
|
||||||
|
|
||||||
|
// listenSignals listens for termination signals
|
||||||
|
// and performs graceful shutdown.
|
||||||
|
func listenSignals(srv *server.Server) {
|
||||||
|
sigs := make(chan os.Signal, 1)
|
||||||
|
signal.Notify(sigs, syscall.SIGINT, syscall.SIGTERM)
|
||||||
|
<-sigs
|
||||||
|
logx.Log("stopping...")
|
||||||
|
err := srv.Stop()
|
||||||
|
if err != nil {
|
||||||
|
logx.Log("failed to stop: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
port := flag.Int("port", 1313, "server port")
|
||||||
|
flag.Parse()
|
||||||
|
|
||||||
|
cfg, err := config.Read("config.json", "boxes.json", "commands.json")
|
||||||
|
if err != nil {
|
||||||
|
logx.Log("missing config file")
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
err = sandbox.ApplyConfig(cfg)
|
||||||
|
if err != nil {
|
||||||
|
logx.Log("invalid config: %v", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
srv := startServer(*port)
|
||||||
|
logx.Verbose = cfg.Verbose
|
||||||
|
logx.Log("workers: %d", cfg.PoolSize)
|
||||||
|
logx.Log("boxes: %v", cfg.BoxNames())
|
||||||
|
logx.Log("commands: %v", cfg.CommandNames())
|
||||||
|
|
||||||
|
listenSignals(srv)
|
||||||
|
}
|
||||||
15
codapi.service
Normal file
15
codapi.service
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=Code playgrounds
|
||||||
|
After=network.target
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=simple
|
||||||
|
User=codapi
|
||||||
|
WorkingDirectory=/opt/codapi
|
||||||
|
ExecStart=/opt/codapi/build/codapi
|
||||||
|
Restart=on-failure
|
||||||
|
StandardOutput=file:/opt/codapi/codapi.log
|
||||||
|
StandardError=file:/opt/codapi/codapi.log
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
14
commands.json
Normal file
14
commands.json
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
{
|
||||||
|
"sh": {
|
||||||
|
"run": {
|
||||||
|
"engine": "docker",
|
||||||
|
"entry": "main.sh",
|
||||||
|
"steps": [
|
||||||
|
{
|
||||||
|
"box": "alpine",
|
||||||
|
"command": ["sh", "main.sh"]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
21
config.json
Normal file
21
config.json
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
{
|
||||||
|
"pool_size": 8,
|
||||||
|
"verbose": true,
|
||||||
|
"box": {
|
||||||
|
"runtime": "runc",
|
||||||
|
"cpu": 1,
|
||||||
|
"memory": 64,
|
||||||
|
"network": "none",
|
||||||
|
"writable": false,
|
||||||
|
"volume": "%s:/sandbox:ro",
|
||||||
|
"cap_drop": ["all"],
|
||||||
|
"ulimit": ["nofile=96"],
|
||||||
|
"nproc": 64
|
||||||
|
},
|
||||||
|
"step": {
|
||||||
|
"user": "sandbox",
|
||||||
|
"action": "run",
|
||||||
|
"timeout": 5,
|
||||||
|
"noutput": 4096
|
||||||
|
}
|
||||||
|
}
|
||||||
165
config/config.go
Normal file
165
config/config.go
Normal 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
157
config/config_test.go
Normal 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
94
config/load.go
Normal 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
38
config/load_test.go
Normal 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
5
config/testdata/boxes.json
vendored
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
{
|
||||||
|
"python": {
|
||||||
|
"image": "codapi/python"
|
||||||
|
}
|
||||||
|
}
|
||||||
25
config/testdata/commands.json
vendored
Normal file
25
config/testdata/commands.json
vendored
Normal 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
10
config/testdata/config.json
vendored
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
{
|
||||||
|
"pool_size": 8,
|
||||||
|
"verbose": true,
|
||||||
|
"box": {
|
||||||
|
"memory": 64
|
||||||
|
},
|
||||||
|
"step": {
|
||||||
|
"user": "sandbox"
|
||||||
|
}
|
||||||
|
}
|
||||||
70
docs/docker-xfs.md
Normal file
70
docs/docker-xfs.md
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
# XFS filesystem for Docker
|
||||||
|
|
||||||
|
1. Install the necessary packages:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo apt-get update
|
||||||
|
sudo apt-get install xfsprogs
|
||||||
|
```
|
||||||
|
|
||||||
|
2. Identify the disk or partition you want to use for the new filesystem. You can use the `lsblk` or `fdisk -l` command to list the available disks and partitions. Make sure you select the correct one as this process will erase all data on it.
|
||||||
|
|
||||||
|
3. If the partition you want to use is not formatted, format it with an XFS filesystem. Replace `/dev/sdX` with the appropriate device identifier:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo mkfs.xfs /dev/sdX
|
||||||
|
```
|
||||||
|
|
||||||
|
4. Once the partition is formatted, create a mount point. This will be the directory where the new filesystem will be mounted:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo mkdir /mnt/docker
|
||||||
|
```
|
||||||
|
|
||||||
|
5. Update the `/etc/fstab` file to automatically mount the new filesystem at boot:
|
||||||
|
|
||||||
|
```
|
||||||
|
/dev/sdX /mnt/docker xfs defaults,nofail,discard,noatime,quota,prjquota,pquota,gquota 0 2
|
||||||
|
```
|
||||||
|
|
||||||
|
6. Mount the new filesystem and verify that it is working:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo mount -a
|
||||||
|
df -h
|
||||||
|
```
|
||||||
|
|
||||||
|
The output of `df -h` should show the new filesystem mounted at `/mnt/docker`.
|
||||||
|
|
||||||
|
7. Stop the docker daemon:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
systemctl stop docker
|
||||||
|
```
|
||||||
|
|
||||||
|
8. Update the `/etc/docker/daemon.json` file to point docker to the new mount point:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"data-root": "/mnt/docker"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
9. Start the docker daemon:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
systemctl start docker
|
||||||
|
```
|
||||||
|
|
||||||
|
10. Build the images:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
su - codapi
|
||||||
|
make images
|
||||||
|
```
|
||||||
|
|
||||||
|
11. Verify that docker can now limit the storage size:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker run -it --storage-opt size=16m codapi/alpine /bin/df -h | grep overlay
|
||||||
|
```
|
||||||
101
docs/install.md
Normal file
101
docs/install.md
Normal file
@@ -0,0 +1,101 @@
|
|||||||
|
# Installing Codapi
|
||||||
|
|
||||||
|
Steps for Debian (11/12) or Ubuntu (20.04/22.04).
|
||||||
|
|
||||||
|
1. Install necessary packages (as root):
|
||||||
|
|
||||||
|
```sh
|
||||||
|
apt update && apt install -y ca-certificates curl docker.io make unzip
|
||||||
|
systemctl enable docker.service
|
||||||
|
systemctl restart docker.service
|
||||||
|
```
|
||||||
|
|
||||||
|
2. Create Codapi user (as root):
|
||||||
|
|
||||||
|
```sh
|
||||||
|
useradd --groups docker --shell /usr/bin/bash --create-home --home /opt/codapi codapi
|
||||||
|
```
|
||||||
|
|
||||||
|
3. Verify that Docker is working (as codapi):
|
||||||
|
|
||||||
|
```sh
|
||||||
|
docker run hello-world
|
||||||
|
```
|
||||||
|
|
||||||
|
4. Install Codapi (as codapi):
|
||||||
|
|
||||||
|
```sh
|
||||||
|
cd /opt/codapi
|
||||||
|
curl -L -o codapi.zip "https://api.github.com/repos/nalgeon/codapi/actions/artifacts/926428361/zip"
|
||||||
|
unzip -u codapi.zip
|
||||||
|
chmod +x build/codapi
|
||||||
|
rm -f codapi.zip
|
||||||
|
```
|
||||||
|
|
||||||
|
6. Build Docker images (as codapi):
|
||||||
|
|
||||||
|
```sh
|
||||||
|
cd /opt/codapi
|
||||||
|
make images
|
||||||
|
```
|
||||||
|
|
||||||
|
7. Verify that Codapi starts without errors (as codapi):
|
||||||
|
|
||||||
|
```sh
|
||||||
|
cd /opt/codapi
|
||||||
|
./build/codapi
|
||||||
|
```
|
||||||
|
|
||||||
|
Should print the `alpine` box and the `sh` command:
|
||||||
|
|
||||||
|
```
|
||||||
|
2023/09/16 15:18:05 codapi 20230915:691d224
|
||||||
|
2023/09/16 15:18:05 listening on port 1313...
|
||||||
|
2023/09/16 15:18:05 workers: 8
|
||||||
|
2023/09/16 15:18:05 boxes: [alpine]
|
||||||
|
2023/09/16 15:18:05 commands: [sh]
|
||||||
|
```
|
||||||
|
|
||||||
|
Stop it with Ctrl+C.
|
||||||
|
|
||||||
|
8. Configure Codapi as systemd service (as root):
|
||||||
|
|
||||||
|
```sh
|
||||||
|
mv /opt/codapi/codapi.service /etc/systemd/system/
|
||||||
|
chown root:root /etc/systemd/system/codapi.service
|
||||||
|
systemctl enable codapi.service
|
||||||
|
systemctl start codapi.service
|
||||||
|
```
|
||||||
|
|
||||||
|
Verify that the Codapi service is running:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
systemctl status codapi.service
|
||||||
|
```
|
||||||
|
|
||||||
|
Should print `active (running)`:
|
||||||
|
|
||||||
|
```
|
||||||
|
codapi.service - Code playgrounds
|
||||||
|
Loaded: loaded (/etc/systemd/system/codapi.service; enabled; preset: enabled)
|
||||||
|
Active: active (running)
|
||||||
|
...
|
||||||
|
```
|
||||||
|
|
||||||
|
9. Verify that Codapi is working:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
curl -H "content-type: application/json" -d '{ "sandbox": "sh", "command": "run", "files": {"": "echo hello" }}' http://localhost:1313/v1/exec
|
||||||
|
```
|
||||||
|
|
||||||
|
Should print `ok` = `true`:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"id": "sh_run_dd27ed27",
|
||||||
|
"ok": true,
|
||||||
|
"duration": 650,
|
||||||
|
"stdout": "hello\n",
|
||||||
|
"stderr": ""
|
||||||
|
}
|
||||||
|
```
|
||||||
297
engine/docker.go
Normal file
297
engine/docker.go
Normal file
@@ -0,0 +1,297 @@
|
|||||||
|
// Execute commands using Docker.
|
||||||
|
package engine
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/nalgeon/codapi/config"
|
||||||
|
"github.com/nalgeon/codapi/execy"
|
||||||
|
"github.com/nalgeon/codapi/fileio"
|
||||||
|
"github.com/nalgeon/codapi/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.ID, 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.ID, 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.ID, dir, nil)
|
||||||
|
if !out.OK {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// cleanup step
|
||||||
|
if e.cmd.After != nil {
|
||||||
|
afterOut := e.execStep(e.cmd.After, req.ID, 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, reqID, dir string, files Files) Execution {
|
||||||
|
box := e.cfg.Boxes[step.Box]
|
||||||
|
err := e.copyFiles(box, dir)
|
||||||
|
if err != nil {
|
||||||
|
err = NewExecutionError("copy files to temp dir", err)
|
||||||
|
return Fail(reqID, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
stdout, stderr, err := e.exec(box, step, reqID, dir, files)
|
||||||
|
if err != nil {
|
||||||
|
return Fail(reqID, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return Execution{
|
||||||
|
ID: reqID,
|
||||||
|
OK: true,
|
||||||
|
Stdout: stdout,
|
||||||
|
Stderr: stderr,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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 = os.WriteFile(path, []byte(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, reqID, 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, reqID, dir)
|
||||||
|
|
||||||
|
if step.Stdin {
|
||||||
|
// pass files to container from stdin
|
||||||
|
stdin := filesReader(files)
|
||||||
|
stdout, stderr, err = prog.RunStdin(stdin, reqID, "docker", args...)
|
||||||
|
} else {
|
||||||
|
// pass files to container from temp directory
|
||||||
|
stdout, stderr, err = prog.Run(reqID, "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(reqID)
|
||||||
|
if err == nil {
|
||||||
|
logx.Debug("%s: docker kill ok", reqID)
|
||||||
|
} else {
|
||||||
|
logx.Log("%s: docker kill failed: %v", reqID, 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, name, dir string) []string {
|
||||||
|
var args []string
|
||||||
|
if step.Action == actionRun {
|
||||||
|
args = dockerRunArgs(box, step, name, 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, name)
|
||||||
|
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, name, dir string) []string {
|
||||||
|
args := []string{
|
||||||
|
actionRun, "--rm",
|
||||||
|
"--name", name,
|
||||||
|
"--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)
|
||||||
|
}
|
||||||
|
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)
|
||||||
|
}
|
||||||
164
engine/docker_test.go
Normal file
164
engine/docker_test.go
Normal file
@@ -0,0 +1,164 @@
|
|||||||
|
package engine
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/nalgeon/codapi/config"
|
||||||
|
"github.com/nalgeon/codapi/execy"
|
||||||
|
"github.com/nalgeon/codapi/logx"
|
||||||
|
)
|
||||||
|
|
||||||
|
var dockerCfg = &config.Config{
|
||||||
|
Boxes: map[string]*config.Box{
|
||||||
|
"postgresql": {
|
||||||
|
Image: "codapi/postgresql",
|
||||||
|
Runtime: "runc",
|
||||||
|
Host: config.Host{
|
||||||
|
CPU: 1, Memory: 64, Network: "none",
|
||||||
|
Volume: "%s:/sandbox:ro",
|
||||||
|
NProc: 64,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"python": {
|
||||||
|
Image: "codapi/python",
|
||||||
|
Runtime: "runc",
|
||||||
|
Host: config.Host{
|
||||||
|
CPU: 1, Memory: 64, Network: "none",
|
||||||
|
Volume: "%s:/sandbox:ro",
|
||||||
|
NProc: 64,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Commands: map[string]config.SandboxCommands{
|
||||||
|
"postgresql": map[string]*config.Command{
|
||||||
|
"run": {
|
||||||
|
Engine: "docker",
|
||||||
|
Before: &config.Step{
|
||||||
|
Box: "postgres", User: "sandbox", Action: "exec",
|
||||||
|
Command: []string{"psql", "-f", "create.sql"},
|
||||||
|
NOutput: 4096,
|
||||||
|
},
|
||||||
|
Steps: []*config.Step{
|
||||||
|
{
|
||||||
|
Box: "postgres", User: "sandbox", Action: "exec", Stdin: true,
|
||||||
|
Command: []string{"psql", "--user=:name"},
|
||||||
|
NOutput: 4096,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
After: &config.Step{
|
||||||
|
Box: "postgres", User: "sandbox", Action: "exec",
|
||||||
|
Command: []string{"psql", "-f", "drop.sql"},
|
||||||
|
NOutput: 4096,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"python": map[string]*config.Command{
|
||||||
|
"run": {
|
||||||
|
Engine: "docker",
|
||||||
|
Entry: "main.py",
|
||||||
|
Steps: []*config.Step{
|
||||||
|
{
|
||||||
|
Box: "python", User: "sandbox", Action: "run",
|
||||||
|
Command: []string{"python", "main.py"},
|
||||||
|
NOutput: 4096,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDockerRun(t *testing.T) {
|
||||||
|
logx.Mock()
|
||||||
|
commands := map[string]execy.CmdOut{
|
||||||
|
"docker run": {Stdout: "hello world", Stderr: "", Err: nil},
|
||||||
|
}
|
||||||
|
mem := execy.Mock(commands)
|
||||||
|
engine := NewDocker(dockerCfg, "python", "run")
|
||||||
|
|
||||||
|
t.Run("success", func(t *testing.T) {
|
||||||
|
req := Request{
|
||||||
|
ID: "http_42",
|
||||||
|
Sandbox: "python",
|
||||||
|
Command: "run",
|
||||||
|
Files: map[string]string{
|
||||||
|
"": "print('hello world')",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
out := engine.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")
|
||||||
|
}
|
||||||
|
want := "hello world"
|
||||||
|
if out.Stdout != want {
|
||||||
|
t.Errorf("Stdout: expected %q, got %q", want, out.Stdout)
|
||||||
|
}
|
||||||
|
if out.Stderr != "" {
|
||||||
|
t.Errorf("Stderr: expected %q, got %q", "", out.Stdout)
|
||||||
|
}
|
||||||
|
if out.Err != nil {
|
||||||
|
t.Errorf("Err: expected nil, got %v", out.Err)
|
||||||
|
}
|
||||||
|
mem.MustHave(t, "python main.py")
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDockerExec(t *testing.T) {
|
||||||
|
logx.Mock()
|
||||||
|
commands := map[string]execy.CmdOut{
|
||||||
|
"docker exec": {Stdout: "hello world", Stderr: "", Err: nil},
|
||||||
|
}
|
||||||
|
mem := execy.Mock(commands)
|
||||||
|
engine := NewDocker(dockerCfg, "postgresql", "run")
|
||||||
|
|
||||||
|
t.Run("success", func(t *testing.T) {
|
||||||
|
req := Request{
|
||||||
|
ID: "http_42",
|
||||||
|
Sandbox: "postgresql",
|
||||||
|
Command: "run",
|
||||||
|
Files: map[string]string{
|
||||||
|
"": "select 'hello world'",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
out := engine.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")
|
||||||
|
}
|
||||||
|
want := "hello world"
|
||||||
|
if out.Stdout != want {
|
||||||
|
t.Errorf("Stdout: expected %q, got %q", want, out.Stdout)
|
||||||
|
}
|
||||||
|
if out.Stderr != "" {
|
||||||
|
t.Errorf("Stderr: expected %q, got %q", "", out.Stdout)
|
||||||
|
}
|
||||||
|
if out.Err != nil {
|
||||||
|
t.Errorf("Err: expected nil, got %v", out.Err)
|
||||||
|
}
|
||||||
|
mem.MustHave(t, "psql -f create.sql")
|
||||||
|
mem.MustHave(t, "psql --user=http_42")
|
||||||
|
mem.MustHave(t, "psql -f drop.sql")
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func Test_expandVars(t *testing.T) {
|
||||||
|
const name = "codapi_01"
|
||||||
|
commands := map[string]string{
|
||||||
|
"python main.py": "python main.py",
|
||||||
|
"sh create.sh :name": "sh create.sh " + name,
|
||||||
|
}
|
||||||
|
for cmd, want := range commands {
|
||||||
|
src := strings.Fields(cmd)
|
||||||
|
exp := expandVars(src, name)
|
||||||
|
got := strings.Join(exp, " ")
|
||||||
|
if got != want {
|
||||||
|
t.Errorf("%q: expected %q, got %q", cmd, got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
116
engine/engine.go
Normal file
116
engine/engine.go
Normal file
@@ -0,0 +1,116 @@
|
|||||||
|
// Package engine provides code execution engines.
|
||||||
|
package engine
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"github.com/nalgeon/codapi/stringx"
|
||||||
|
)
|
||||||
|
|
||||||
|
// A Request initiates code execution.
|
||||||
|
type Request struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Sandbox string `json:"sandbox"`
|
||||||
|
Command string `json:"command"`
|
||||||
|
Files Files `json:"files"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// GenerateID() sets a unique ID for the request.
|
||||||
|
func (r *Request) GenerateID() {
|
||||||
|
r.ID = fmt.Sprintf("%s_%s_%s", r.Sandbox, r.Command, stringx.RandString(8))
|
||||||
|
}
|
||||||
|
|
||||||
|
// An Execution is an output from the code execution engine.
|
||||||
|
type Execution struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
OK bool `json:"ok"`
|
||||||
|
Duration int `json:"duration"`
|
||||||
|
Stdout string `json:"stdout"`
|
||||||
|
Stderr string `json:"stderr"`
|
||||||
|
Err error `json:"-"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// An ErrTimeout is returned if code execution did not complete
|
||||||
|
// in the allowed timeframe.
|
||||||
|
var ErrTimeout = errors.New("code execution timeout")
|
||||||
|
|
||||||
|
// An ErrBusy is returned when there are no engines available.
|
||||||
|
var ErrBusy = errors.New("busy: try again later")
|
||||||
|
|
||||||
|
// An ExecutionError is returned if code execution failed
|
||||||
|
// due to the application problems, not due to the problems with the code.
|
||||||
|
type ExecutionError struct {
|
||||||
|
msg string
|
||||||
|
inner error
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewExecutionError(msg string, err error) ExecutionError {
|
||||||
|
return ExecutionError{msg: msg, inner: err}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (err ExecutionError) Error() string {
|
||||||
|
return err.msg + ": " + err.inner.Error()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (err ExecutionError) Unwrap() error {
|
||||||
|
return err.inner
|
||||||
|
}
|
||||||
|
|
||||||
|
// Files are a collection of files to be executed by the engine.
|
||||||
|
type Files map[string]string
|
||||||
|
|
||||||
|
// First returns the contents of the first file.
|
||||||
|
func (f Files) First() string {
|
||||||
|
for _, content := range f {
|
||||||
|
return content
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// Range iterates over the files, calling fn for each one.
|
||||||
|
func (f Files) Range(fn func(name, content string) bool) {
|
||||||
|
for name, content := range f {
|
||||||
|
ok := fn(name, content)
|
||||||
|
if !ok {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Count returns the number of files.
|
||||||
|
func (f Files) Count() int {
|
||||||
|
return len(f)
|
||||||
|
}
|
||||||
|
|
||||||
|
// An Engine executes a specific sandbox command on the code.
|
||||||
|
// Engines must be concurrent-safe, since they can be accessed by multiple goroutines.
|
||||||
|
type Engine interface {
|
||||||
|
// Exec executes the command and returns the output.
|
||||||
|
Exec(req Request) Execution
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fail creates an output from an error.
|
||||||
|
func Fail(id string, err error) Execution {
|
||||||
|
if _, ok := err.(ExecutionError); ok {
|
||||||
|
return Execution{
|
||||||
|
ID: id,
|
||||||
|
OK: false,
|
||||||
|
Stderr: "internal error",
|
||||||
|
Err: err,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if errors.Is(err, ErrBusy) {
|
||||||
|
return Execution{
|
||||||
|
ID: id,
|
||||||
|
OK: false,
|
||||||
|
Stderr: err.Error(),
|
||||||
|
Err: err,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Execution{
|
||||||
|
ID: id,
|
||||||
|
OK: false,
|
||||||
|
Stderr: err.Error(),
|
||||||
|
}
|
||||||
|
}
|
||||||
137
engine/engine_test.go
Normal file
137
engine/engine_test.go
Normal file
@@ -0,0 +1,137 @@
|
|||||||
|
package engine
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"reflect"
|
||||||
|
"sort"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestExecutionError(t *testing.T) {
|
||||||
|
inner := errors.New("inner error")
|
||||||
|
err := NewExecutionError("failed", inner)
|
||||||
|
if err.Error() != "failed: inner error" {
|
||||||
|
t.Errorf("Error: expected %q, got %q", "failed: inner error", err.Error())
|
||||||
|
}
|
||||||
|
unwrapped := err.Unwrap()
|
||||||
|
if unwrapped != inner {
|
||||||
|
t.Errorf("Unwrap: expected %#v, got %#v", inner, unwrapped)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFiles_Count(t *testing.T) {
|
||||||
|
var files Files = map[string]string{
|
||||||
|
"first": "alice",
|
||||||
|
"second": "bob",
|
||||||
|
"third": "cindy",
|
||||||
|
}
|
||||||
|
if files.Count() != 3 {
|
||||||
|
t.Errorf("Count: expected 3, got %d", files.Count())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFiles_Range(t *testing.T) {
|
||||||
|
var files Files = map[string]string{
|
||||||
|
"first": "alice",
|
||||||
|
"second": "bob",
|
||||||
|
"third": "cindy",
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Run("range", func(t *testing.T) {
|
||||||
|
names := []string{}
|
||||||
|
contents := []string{}
|
||||||
|
files.Range(func(name, content string) bool {
|
||||||
|
names = append(names, name)
|
||||||
|
contents = append(contents, content)
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
sort.Strings(names)
|
||||||
|
if !reflect.DeepEqual(names, []string{"first", "second", "third"}) {
|
||||||
|
t.Errorf("unexpected names: %v", names)
|
||||||
|
}
|
||||||
|
sort.Strings(contents)
|
||||||
|
if !reflect.DeepEqual(contents, []string{"alice", "bob", "cindy"}) {
|
||||||
|
t.Errorf("unexpected contents: %v", contents)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("break", func(t *testing.T) {
|
||||||
|
names := []string{}
|
||||||
|
contents := []string{}
|
||||||
|
files.Range(func(name, content string) bool {
|
||||||
|
names = append(names, name)
|
||||||
|
contents = append(contents, content)
|
||||||
|
return false
|
||||||
|
})
|
||||||
|
if len(names) != 1 {
|
||||||
|
t.Fatalf("expected names len = 1, got %d", len(names))
|
||||||
|
}
|
||||||
|
if len(contents) != 1 {
|
||||||
|
t.Fatalf("expected contents len = 1, got %d", len(contents))
|
||||||
|
}
|
||||||
|
if files[names[0]] != contents[0] {
|
||||||
|
t.Fatalf("name does not match content: %v -> %v", names[0], contents[0])
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFail(t *testing.T) {
|
||||||
|
t.Run("ExecutionError", func(t *testing.T) {
|
||||||
|
err := NewExecutionError("failed", errors.New("inner error"))
|
||||||
|
out := Fail("42", err)
|
||||||
|
if out.ID != "42" {
|
||||||
|
t.Errorf("ID: expected 42, got %v", out.ID)
|
||||||
|
}
|
||||||
|
if out.OK {
|
||||||
|
t.Error("OK: expected false")
|
||||||
|
}
|
||||||
|
if out.Stderr != "internal error" {
|
||||||
|
t.Errorf("Stderr: expected %q, got %q", "internal error", out.Stderr)
|
||||||
|
}
|
||||||
|
if out.Stdout != "" {
|
||||||
|
t.Errorf("Stdout: expected empty, got %q", out.Stdout)
|
||||||
|
}
|
||||||
|
if out.Err != err {
|
||||||
|
t.Errorf("Err: expected %#v, got %#v", err, out.Err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
t.Run("ErrBusy", func(t *testing.T) {
|
||||||
|
err := ErrBusy
|
||||||
|
out := Fail("42", err)
|
||||||
|
if out.ID != "42" {
|
||||||
|
t.Errorf("ID: expected 42, got %v", out.ID)
|
||||||
|
}
|
||||||
|
if out.OK {
|
||||||
|
t.Error("OK: expected false")
|
||||||
|
}
|
||||||
|
if out.Stderr != err.Error() {
|
||||||
|
t.Errorf("Stderr: expected %q, got %q", err.Error(), out.Stderr)
|
||||||
|
}
|
||||||
|
if out.Stdout != "" {
|
||||||
|
t.Errorf("Stdout: expected empty, got %q", out.Stdout)
|
||||||
|
}
|
||||||
|
if out.Err != err {
|
||||||
|
t.Errorf("Err: expected %#v, got %#v", err, out.Err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
t.Run("Error", func(t *testing.T) {
|
||||||
|
err := errors.New("user error")
|
||||||
|
out := Fail("42", err)
|
||||||
|
if out.ID != "42" {
|
||||||
|
t.Errorf("ID: expected 42, got %v", out.ID)
|
||||||
|
}
|
||||||
|
if out.OK {
|
||||||
|
t.Error("OK: expected false")
|
||||||
|
}
|
||||||
|
if out.Stderr != err.Error() {
|
||||||
|
t.Errorf("Stderr: expected %q, got %q", err.Error(), out.Stderr)
|
||||||
|
}
|
||||||
|
if out.Stdout != "" {
|
||||||
|
t.Errorf("Stdout: expected empty, got %q", out.Stdout)
|
||||||
|
}
|
||||||
|
if out.Err != nil {
|
||||||
|
t.Errorf("Err: expected nil, got %#v", out.Err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
}
|
||||||
54
engine/exec.go
Normal file
54
engine/exec.go
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
package engine
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"io"
|
||||||
|
"os/exec"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/nalgeon/codapi/execy"
|
||||||
|
"github.com/nalgeon/codapi/logx"
|
||||||
|
)
|
||||||
|
|
||||||
|
// A Program is an executable program.
|
||||||
|
type Program struct {
|
||||||
|
timeout time.Duration
|
||||||
|
nOutput int64
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewProgram creates a new program.
|
||||||
|
func NewProgram(timeoutSec int, nOutput int64) *Program {
|
||||||
|
return &Program{
|
||||||
|
timeout: time.Duration(timeoutSec) * time.Second,
|
||||||
|
nOutput: nOutput,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run starts the program and waits for it to complete (or timeout).
|
||||||
|
func (p *Program) Run(id, name string, arg ...string) (stdout string, stderr string, err error) {
|
||||||
|
return p.RunStdin(nil, id, name, arg...)
|
||||||
|
}
|
||||||
|
|
||||||
|
// RunStdin starts the program with data from stdin
|
||||||
|
// and waits for it to complete (or timeout).
|
||||||
|
func (p *Program) RunStdin(stdin io.Reader, id, name string, arg ...string) (stdout string, stderr string, err error) {
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), p.timeout)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
var cmdout, cmderr strings.Builder
|
||||||
|
cmd := exec.CommandContext(ctx, name, arg...)
|
||||||
|
cmd.Cancel = func() error {
|
||||||
|
err := cmd.Process.Kill()
|
||||||
|
logx.Debug("%s: execution timeout, killed process=%d, err=%v", id, cmd.Process.Pid, err)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd.Stdin = stdin
|
||||||
|
cmd.Stdout = LimitWriter(&cmdout, p.nOutput)
|
||||||
|
cmd.Stderr = LimitWriter(&cmderr, p.nOutput)
|
||||||
|
err = execy.Run(cmd)
|
||||||
|
stdout = cmdout.String()
|
||||||
|
stderr = cmderr.String()
|
||||||
|
return
|
||||||
|
}
|
||||||
74
engine/exec_test.go
Normal file
74
engine/exec_test.go
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
package engine
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/nalgeon/codapi/execy"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestProgram_Run(t *testing.T) {
|
||||||
|
commands := map[string]execy.CmdOut{
|
||||||
|
"mock stdout": {Stdout: "stdout", Stderr: "", Err: nil},
|
||||||
|
"mock stderr": {Stdout: "", Stderr: "stderr", Err: nil},
|
||||||
|
"mock outerr": {Stdout: "stdout", Stderr: "stderr", Err: nil},
|
||||||
|
"mock err": {Stdout: "", Stderr: "stderr", Err: errors.New("error")},
|
||||||
|
}
|
||||||
|
mem := execy.Mock(commands)
|
||||||
|
|
||||||
|
for key, want := range commands {
|
||||||
|
t.Run(key, func(t *testing.T) {
|
||||||
|
p := NewProgram(3, 100)
|
||||||
|
name, arg, _ := strings.Cut(key, " ")
|
||||||
|
stdout, stderr, err := p.Run("mock_42", name, arg)
|
||||||
|
if !mem.Has(key) {
|
||||||
|
t.Errorf("Run: command %q not run", key)
|
||||||
|
}
|
||||||
|
if stdout != want.Stdout {
|
||||||
|
t.Errorf("stdout: want %#v, got %#v", want.Stdout, stdout)
|
||||||
|
}
|
||||||
|
if stderr != want.Stderr {
|
||||||
|
t.Errorf("stderr: want %#v, got %#v", want.Stderr, stderr)
|
||||||
|
}
|
||||||
|
if err != want.Err {
|
||||||
|
t.Errorf("err: want %#v, got %#v", want.Err, err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestProgram_LimitOutput(t *testing.T) {
|
||||||
|
commands := map[string]execy.CmdOut{
|
||||||
|
"mock stdout": {Stdout: "1234567890", Stderr: ""},
|
||||||
|
"mock stderr": {Stdout: "", Stderr: "1234567890"},
|
||||||
|
"mock outerr": {Stdout: "1234567890", Stderr: "1234567890"},
|
||||||
|
}
|
||||||
|
execy.Mock(commands)
|
||||||
|
|
||||||
|
const nOutput = 5
|
||||||
|
{
|
||||||
|
p := NewProgram(3, nOutput)
|
||||||
|
stdout, _, _ := p.Run("mock_42", "mock", "stdout")
|
||||||
|
if stdout != "12345" {
|
||||||
|
t.Errorf("stdout: want %#v, got %#v", "12345", stdout)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
{
|
||||||
|
p := NewProgram(3, nOutput)
|
||||||
|
_, stderr, _ := p.Run("mock_42", "mock", "stderr")
|
||||||
|
if stderr != "12345" {
|
||||||
|
t.Errorf("stderr: want %#v, got %#v", "12345", stderr)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
{
|
||||||
|
p := NewProgram(3, nOutput)
|
||||||
|
stdout, stderr, _ := p.Run("mock_42", "mock", "outerr")
|
||||||
|
if stdout != "12345" {
|
||||||
|
t.Errorf("stdout: want %#v, got %#v", "12345", stdout)
|
||||||
|
}
|
||||||
|
if stderr != "12345" {
|
||||||
|
t.Errorf("stderr: want %#v, got %#v", "12345", stderr)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
166
engine/http.go
Normal file
166
engine/http.go
Normal file
@@ -0,0 +1,166 @@
|
|||||||
|
// Send HTTP request according to the specification.
|
||||||
|
package engine
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/nalgeon/codapi/config"
|
||||||
|
"github.com/nalgeon/codapi/httpx"
|
||||||
|
"github.com/nalgeon/codapi/logx"
|
||||||
|
)
|
||||||
|
|
||||||
|
// An HTTP engine sends HTTP requests.
|
||||||
|
type HTTP struct {
|
||||||
|
hosts map[string]string
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewHTTP creates a new HTTP engine.
|
||||||
|
func NewHTTP(cfg *config.Config, sandbox, command string) Engine {
|
||||||
|
if len(cfg.HTTP.Hosts) == 0 {
|
||||||
|
msg := fmt.Sprintf("%s %s: http engine requires at least one allowed URL", sandbox, command)
|
||||||
|
panic(msg)
|
||||||
|
}
|
||||||
|
return &HTTP{hosts: cfg.HTTP.Hosts}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Exec sends an HTTP request according to the spec
|
||||||
|
// and returns the response as text with status, headers and body.
|
||||||
|
func (e *HTTP) Exec(req Request) Execution {
|
||||||
|
// build request from spec
|
||||||
|
httpReq, err := e.parse(req.Files.First())
|
||||||
|
if err != nil {
|
||||||
|
err = fmt.Errorf("parse spec: %w", err)
|
||||||
|
return Fail(req.ID, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// send request and receive response
|
||||||
|
allowed := e.translateHost(httpReq)
|
||||||
|
if !allowed {
|
||||||
|
err = fmt.Errorf("host not allowed: %s", httpReq.Host)
|
||||||
|
return Fail(req.ID, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
logx.Log("%s: %s %s", req.ID, httpReq.Method, httpReq.URL.String())
|
||||||
|
resp, err := httpx.Do(httpReq)
|
||||||
|
if err != nil {
|
||||||
|
err = fmt.Errorf("http request: %w", err)
|
||||||
|
return Fail(req.ID, err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
// read response body
|
||||||
|
body, err := io.ReadAll(resp.Body)
|
||||||
|
if err != nil {
|
||||||
|
err = NewExecutionError("read response", err)
|
||||||
|
return Fail(req.ID, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// build text representation of request
|
||||||
|
stdout := e.responseText(resp, body)
|
||||||
|
return Execution{
|
||||||
|
ID: req.ID,
|
||||||
|
OK: true,
|
||||||
|
Stdout: stdout,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// parse parses the request specification.
|
||||||
|
func (e *HTTP) parse(text string) (*http.Request, error) {
|
||||||
|
lines := strings.Split(text, "\n")
|
||||||
|
if len(lines) == 0 {
|
||||||
|
return nil, errors.New("empty request")
|
||||||
|
}
|
||||||
|
|
||||||
|
lineIdx := 0
|
||||||
|
|
||||||
|
// parse method and URL
|
||||||
|
var method, url string
|
||||||
|
methodURL := strings.Fields(lines[0])
|
||||||
|
if len(methodURL) >= 2 {
|
||||||
|
method = methodURL[0]
|
||||||
|
url = methodURL[1]
|
||||||
|
} else {
|
||||||
|
method = http.MethodGet
|
||||||
|
url = methodURL[0]
|
||||||
|
}
|
||||||
|
|
||||||
|
lineIdx++
|
||||||
|
|
||||||
|
// parse URL parameters
|
||||||
|
var urlParams strings.Builder
|
||||||
|
for i := lineIdx; i < len(lines); i++ {
|
||||||
|
line := strings.TrimSpace(lines[i])
|
||||||
|
if strings.HasPrefix(line, "?") || strings.HasPrefix(line, "&") {
|
||||||
|
urlParams.WriteString(line)
|
||||||
|
lineIdx++
|
||||||
|
} else {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// parse headers
|
||||||
|
headers := make(http.Header)
|
||||||
|
for i := lineIdx; i < len(lines); i++ {
|
||||||
|
line := strings.TrimSpace(lines[i])
|
||||||
|
if line == "" {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
headerParts := strings.SplitN(line, ":", 2)
|
||||||
|
if len(headerParts) == 2 {
|
||||||
|
headers.Add(strings.TrimSpace(headerParts[0]), strings.TrimSpace(headerParts[1]))
|
||||||
|
lineIdx++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
lineIdx += 1
|
||||||
|
|
||||||
|
// parse body
|
||||||
|
var bodyRdr io.Reader
|
||||||
|
if lineIdx < len(lines) {
|
||||||
|
body := strings.Join(lines[lineIdx:], "\n")
|
||||||
|
bodyRdr = strings.NewReader(body)
|
||||||
|
}
|
||||||
|
|
||||||
|
// create request
|
||||||
|
req, err := http.NewRequest(method, url+urlParams.String(), bodyRdr)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
req.Header = headers
|
||||||
|
return req, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// translateHost translates the requested host into the allowed one.
|
||||||
|
// Returns false if the requested host is not allowed.
|
||||||
|
func (e *HTTP) translateHost(req *http.Request) bool {
|
||||||
|
host := e.hosts[req.Host]
|
||||||
|
if host == "" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
req.URL.Host = host
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// responseText returns the response as text with status, headers and body.
|
||||||
|
func (e *HTTP) responseText(resp *http.Response, body []byte) string {
|
||||||
|
var b bytes.Buffer
|
||||||
|
// status line
|
||||||
|
b.WriteString(
|
||||||
|
fmt.Sprintf("%s %d %s\n", resp.Proto, resp.StatusCode, http.StatusText(resp.StatusCode)),
|
||||||
|
)
|
||||||
|
// headers
|
||||||
|
for name := range resp.Header {
|
||||||
|
b.WriteString(fmt.Sprintf("%s: %s\n", name, resp.Header.Get(name)))
|
||||||
|
}
|
||||||
|
// body
|
||||||
|
if len(body) > 0 {
|
||||||
|
b.WriteByte('\n')
|
||||||
|
b.Write(body)
|
||||||
|
}
|
||||||
|
return b.String()
|
||||||
|
}
|
||||||
179
engine/http_test.go
Normal file
179
engine/http_test.go
Normal file
@@ -0,0 +1,179 @@
|
|||||||
|
package engine
|
||||||
|
|
||||||
|
import (
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/nalgeon/codapi/config"
|
||||||
|
"github.com/nalgeon/codapi/httpx"
|
||||||
|
"github.com/nalgeon/codapi/logx"
|
||||||
|
)
|
||||||
|
|
||||||
|
var httpCfg = &config.Config{
|
||||||
|
HTTP: &config.HTTP{
|
||||||
|
Hosts: map[string]string{"codapi.org": "localhost"},
|
||||||
|
},
|
||||||
|
Commands: map[string]config.SandboxCommands{
|
||||||
|
"http": map[string]*config.Command{
|
||||||
|
"run": {Engine: "http"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHTTP_Exec(t *testing.T) {
|
||||||
|
logx.Mock()
|
||||||
|
httpx.Mock()
|
||||||
|
engine := NewHTTP(httpCfg, "http", "run")
|
||||||
|
|
||||||
|
t.Run("success", func(t *testing.T) {
|
||||||
|
req := Request{
|
||||||
|
ID: "http_42",
|
||||||
|
Sandbox: "http",
|
||||||
|
Command: "run",
|
||||||
|
Files: map[string]string{
|
||||||
|
"": "GET https://codapi.org/example.txt",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
out := engine.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")
|
||||||
|
}
|
||||||
|
want := `HTTP/1.1 200 OK
|
||||||
|
Content-Type: text/plain
|
||||||
|
|
||||||
|
hello`
|
||||||
|
if out.Stdout != want {
|
||||||
|
t.Errorf("Stdout: expected %q, got %q", want, out.Stdout)
|
||||||
|
}
|
||||||
|
if out.Stderr != "" {
|
||||||
|
t.Errorf("Stderr: expected %q, got %q", "", out.Stdout)
|
||||||
|
}
|
||||||
|
if out.Err != nil {
|
||||||
|
t.Errorf("Err: expected nil, got %#v", out.Err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
t.Run("hostname not allowed", func(t *testing.T) {
|
||||||
|
req := Request{
|
||||||
|
ID: "http_42",
|
||||||
|
Sandbox: "http",
|
||||||
|
Command: "run",
|
||||||
|
Files: map[string]string{
|
||||||
|
"": "GET https://example.com/get",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
out := engine.Exec(req)
|
||||||
|
if out.Err != nil {
|
||||||
|
t.Errorf("Err: expected nil, got %#v", out.Err)
|
||||||
|
}
|
||||||
|
if out.Stderr != "host not allowed: example.com" {
|
||||||
|
t.Errorf("Stderr: unexpected value %q", out.Stderr)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHTTP_parse(t *testing.T) {
|
||||||
|
logx.Mock()
|
||||||
|
httpx.Mock()
|
||||||
|
engine := NewHTTP(httpCfg, "http", "run").(*HTTP)
|
||||||
|
|
||||||
|
t.Run("request line", func(t *testing.T) {
|
||||||
|
const uri = "https://codapi.org/head"
|
||||||
|
text := "HEAD " + uri
|
||||||
|
req, err := engine.parse(text)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error %#v", err)
|
||||||
|
}
|
||||||
|
if req.Method != http.MethodHead {
|
||||||
|
t.Errorf("Method: expected %s, got %s", http.MethodHead, req.Method)
|
||||||
|
}
|
||||||
|
if req.URL.String() != uri {
|
||||||
|
t.Errorf("URL: expected %q, got %q", uri, req.URL.String())
|
||||||
|
}
|
||||||
|
})
|
||||||
|
t.Run("headers", func(t *testing.T) {
|
||||||
|
const uri = "https://codapi.org/get"
|
||||||
|
text := "GET " + uri + "\naccept: text/plain\nx-secret: 42"
|
||||||
|
req, err := engine.parse(text)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error %#v", err)
|
||||||
|
}
|
||||||
|
if req.Method != http.MethodGet {
|
||||||
|
t.Errorf("Method: expected %s, got %s", http.MethodGet, req.Method)
|
||||||
|
}
|
||||||
|
if req.URL.String() != uri {
|
||||||
|
t.Errorf("URL: expected %q, got %q", uri, req.URL.String())
|
||||||
|
}
|
||||||
|
if len(req.Header) != 2 {
|
||||||
|
t.Fatalf("Header: expected 2 headers, got %d", len(req.Header))
|
||||||
|
}
|
||||||
|
if req.Header.Get("accept") != "text/plain" {
|
||||||
|
t.Fatalf("Header: expected accept = %q, got %q", "text/plain", req.Header.Get("accept"))
|
||||||
|
}
|
||||||
|
if req.Header.Get("x-secret") != "42" {
|
||||||
|
t.Fatalf("Header: expected x-secret = %q, got %q", "42", req.Header.Get("x-secret"))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
t.Run("body", func(t *testing.T) {
|
||||||
|
const uri = "https://codapi.org/post"
|
||||||
|
const body = "{\"name\":\"alice\"}"
|
||||||
|
text := "POST " + uri + "\ncontent-type: application/json\n\n" + body
|
||||||
|
req, err := engine.parse(text)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error %#v", err)
|
||||||
|
}
|
||||||
|
if req.Method != http.MethodPost {
|
||||||
|
t.Errorf("Method: expected %s, got %s", http.MethodPost, req.Method)
|
||||||
|
}
|
||||||
|
if req.URL.String() != uri {
|
||||||
|
t.Errorf("URL: expected %q, got %q", uri, req.URL.String())
|
||||||
|
}
|
||||||
|
if req.Header.Get("content-type") != "application/json" {
|
||||||
|
t.Errorf("Header: expected content-type = %q, got %q",
|
||||||
|
"application/json", req.Header.Get("content-type"))
|
||||||
|
}
|
||||||
|
b, _ := io.ReadAll(req.Body)
|
||||||
|
got := string(b)
|
||||||
|
if got != body {
|
||||||
|
t.Errorf("Body: expected %q, got %q", body, got)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
t.Run("invalid", func(t *testing.T) {
|
||||||
|
_, err := engine.parse("on,e two three")
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error, got nil")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHTTP_translateHost(t *testing.T) {
|
||||||
|
logx.Mock()
|
||||||
|
httpx.Mock()
|
||||||
|
engine := NewHTTP(httpCfg, "http", "run").(*HTTP)
|
||||||
|
|
||||||
|
t.Run("known url", func(t *testing.T) {
|
||||||
|
const uri = "http://codapi.org/get"
|
||||||
|
req, _ := http.NewRequest(http.MethodGet, uri, nil)
|
||||||
|
ok := engine.translateHost(req)
|
||||||
|
if !ok {
|
||||||
|
t.Errorf("%s: should be allowed", uri)
|
||||||
|
}
|
||||||
|
if req.URL.Hostname() != "localhost" {
|
||||||
|
t.Errorf("%s: expected %s, got %s", uri, "localhost", req.URL.Hostname())
|
||||||
|
}
|
||||||
|
})
|
||||||
|
t.Run("unknown url", func(t *testing.T) {
|
||||||
|
const uri = "http://example.com/get"
|
||||||
|
req, _ := http.NewRequest(http.MethodGet, uri, nil)
|
||||||
|
ok := engine.translateHost(req)
|
||||||
|
if ok {
|
||||||
|
t.Errorf("%s: should not be allowed", uri)
|
||||||
|
}
|
||||||
|
if req.URL.Hostname() != "example.com" {
|
||||||
|
t.Errorf("%s: expected %s, got %s", uri, "example.com", req.URL.Hostname())
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
31
engine/io.go
Normal file
31
engine/io.go
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
package engine
|
||||||
|
|
||||||
|
import "io"
|
||||||
|
|
||||||
|
// A LimitedWriter writes to w but limits the amount
|
||||||
|
// of data to only n bytes. After reaching the limit,
|
||||||
|
// silently discards the rest of the data without errors.
|
||||||
|
type LimitedWriter struct {
|
||||||
|
w io.Writer
|
||||||
|
n int64
|
||||||
|
}
|
||||||
|
|
||||||
|
// LimitWriter returns a writer that writes no more
|
||||||
|
// than n bytes and silently discards the rest.
|
||||||
|
func LimitWriter(w io.Writer, n int64) io.Writer {
|
||||||
|
return &LimitedWriter{w, n}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Write implements the io.Writer interface.
|
||||||
|
func (w *LimitedWriter) Write(p []byte) (int, error) {
|
||||||
|
lenp := len(p)
|
||||||
|
if w.n <= 0 {
|
||||||
|
return lenp, nil
|
||||||
|
}
|
||||||
|
if int64(lenp) > w.n {
|
||||||
|
p = p[:w.n]
|
||||||
|
}
|
||||||
|
n, err := w.w.Write(p)
|
||||||
|
w.n -= int64(n)
|
||||||
|
return lenp, err
|
||||||
|
}
|
||||||
56
engine/io_test.go
Normal file
56
engine/io_test.go
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
package engine
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"reflect"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestLimitedWriter(t *testing.T) {
|
||||||
|
var b bytes.Buffer
|
||||||
|
w := LimitWriter(&b, 5)
|
||||||
|
|
||||||
|
{
|
||||||
|
src := []byte{1, 2, 3}
|
||||||
|
n, err := w.Write(src)
|
||||||
|
if n != 3 {
|
||||||
|
t.Fatalf("write(1,2,3): expected n = 3, got %d", n)
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("write(1,2,3): expected nil err, got %v", err)
|
||||||
|
}
|
||||||
|
if !reflect.DeepEqual(b.Bytes(), src) {
|
||||||
|
t.Fatalf("write(1,2,3): expected %v, got %v", src, b.Bytes())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
{
|
||||||
|
src := []byte{4, 5}
|
||||||
|
n, err := w.Write(src)
|
||||||
|
if n != 2 {
|
||||||
|
t.Fatalf("+write(4,5): expected n = 2, got %d", n)
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("+write(4,5): expected nil err, got %v", err)
|
||||||
|
}
|
||||||
|
want := []byte{1, 2, 3, 4, 5}
|
||||||
|
if !reflect.DeepEqual(b.Bytes(), want) {
|
||||||
|
t.Fatalf("+write(4,5): expected %v, got %v", want, b.Bytes())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
{
|
||||||
|
src := []byte{6, 7, 8}
|
||||||
|
n, err := w.Write(src)
|
||||||
|
if n != 3 {
|
||||||
|
t.Fatalf("+write(6,7,8): expected n = 3, got %d", n)
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("+write(6,7,8): expected nil err, got %v", err)
|
||||||
|
}
|
||||||
|
want := []byte{1, 2, 3, 4, 5}
|
||||||
|
if !reflect.DeepEqual(b.Bytes(), want) {
|
||||||
|
t.Fatalf("+write(6,7,8): expected %v, got %v", want, b.Bytes())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
1
engine/testdata/example.txt
vendored
Normal file
1
engine/testdata/example.txt
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
hello
|
||||||
31
execy/execy.go
Normal file
31
execy/execy.go
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
// Package execy runs external commands.
|
||||||
|
package execy
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os/exec"
|
||||||
|
)
|
||||||
|
|
||||||
|
var runner = Runner(&osRunner{})
|
||||||
|
|
||||||
|
// Runner executes external commands.
|
||||||
|
type Runner interface {
|
||||||
|
Run(cmd *exec.Cmd) error
|
||||||
|
}
|
||||||
|
|
||||||
|
// osRunner runs OS programs.
|
||||||
|
type osRunner struct{}
|
||||||
|
|
||||||
|
func (r *osRunner) Run(cmd *exec.Cmd) error {
|
||||||
|
return cmd.Run()
|
||||||
|
}
|
||||||
|
|
||||||
|
func Run(cmd *exec.Cmd) error {
|
||||||
|
return runner.Run(cmd)
|
||||||
|
}
|
||||||
|
|
||||||
|
// CmdOut represents the result of the command run.
|
||||||
|
type CmdOut struct {
|
||||||
|
Stdout string
|
||||||
|
Stderr string
|
||||||
|
Err error
|
||||||
|
}
|
||||||
29
execy/execy_test.go
Normal file
29
execy/execy_test.go
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
package execy
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"os/exec"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestRunner(t *testing.T) {
|
||||||
|
const want = "hello world"
|
||||||
|
ctx := context.Background()
|
||||||
|
cmd := exec.CommandContext(ctx, "echo", "-n", want)
|
||||||
|
outb := new(strings.Builder)
|
||||||
|
errb := new(strings.Builder)
|
||||||
|
cmd.Stdout = outb
|
||||||
|
cmd.Stderr = errb
|
||||||
|
|
||||||
|
err := Run(cmd)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Err: expected nil, got %v", err)
|
||||||
|
}
|
||||||
|
if outb.String() != want {
|
||||||
|
t.Errorf("Stdout: expected %q, got %q", want, outb.String())
|
||||||
|
}
|
||||||
|
if errb.String() != "" {
|
||||||
|
t.Errorf("Stderr: expected %q, got %q", "", errb.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
44
execy/mock.go
Normal file
44
execy/mock.go
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
package execy
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os/exec"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/nalgeon/codapi/logx"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Mock installs mock outputs for given commands.
|
||||||
|
func Mock(commands map[string]CmdOut) *logx.Memory {
|
||||||
|
if commands != nil {
|
||||||
|
mockCommands = commands
|
||||||
|
}
|
||||||
|
mem := logx.NewMemory("exec")
|
||||||
|
runner = &mockRunner{mem}
|
||||||
|
return mem
|
||||||
|
}
|
||||||
|
|
||||||
|
// mockRunner returns mock outputs
|
||||||
|
// without running OS programs.
|
||||||
|
type mockRunner struct {
|
||||||
|
mem *logx.Memory
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run returns a mock output from the registry
|
||||||
|
// that matches the given command name and argument.
|
||||||
|
func (r *mockRunner) Run(cmd *exec.Cmd) error {
|
||||||
|
cmdStr := strings.Join(cmd.Args, " ")
|
||||||
|
r.mem.WriteString(cmdStr)
|
||||||
|
|
||||||
|
key := cmd.Args[0] + " " + cmd.Args[1]
|
||||||
|
out, ok := mockCommands[key]
|
||||||
|
if !ok {
|
||||||
|
// command is not in the registry,
|
||||||
|
// so let's return an empty "success" result
|
||||||
|
out = CmdOut{}
|
||||||
|
}
|
||||||
|
_, _ = cmd.Stdout.Write([]byte(out.Stdout))
|
||||||
|
_, _ = cmd.Stderr.Write([]byte(out.Stderr))
|
||||||
|
return out.Err
|
||||||
|
}
|
||||||
|
|
||||||
|
var mockCommands map[string]CmdOut = map[string]CmdOut{}
|
||||||
33
execy/mock_test.go
Normal file
33
execy/mock_test.go
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
package execy
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"os/exec"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestMock(t *testing.T) {
|
||||||
|
const want = "hello world"
|
||||||
|
out := CmdOut{Stdout: want, Stderr: "", Err: nil}
|
||||||
|
mem := Mock(map[string]CmdOut{"echo -n": out})
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
cmd := exec.CommandContext(ctx, "echo", "-n", want)
|
||||||
|
outb := new(strings.Builder)
|
||||||
|
errb := new(strings.Builder)
|
||||||
|
cmd.Stdout = outb
|
||||||
|
cmd.Stderr = errb
|
||||||
|
|
||||||
|
err := Run(cmd)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Err: expected nil, got %v", err)
|
||||||
|
}
|
||||||
|
if outb.String() != want {
|
||||||
|
t.Errorf("Stdout: expected %q, got %q", want, outb.String())
|
||||||
|
}
|
||||||
|
if errb.String() != "" {
|
||||||
|
t.Errorf("Stderr: expected %q, got %q", "", errb.String())
|
||||||
|
}
|
||||||
|
mem.MustHave(t, "echo -n hello world")
|
||||||
|
}
|
||||||
39
fileio/fileio.go
Normal file
39
fileio/fileio.go
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
// Package fileio provides high-level file operations.
|
||||||
|
package fileio
|
||||||
|
|
||||||
|
import (
|
||||||
|
"io"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
)
|
||||||
|
|
||||||
|
// CopyFile copies all files matching the pattern
|
||||||
|
// to the destination directory.
|
||||||
|
func CopyFiles(pattern string, dstDir string) error {
|
||||||
|
matches, err := filepath.Glob(pattern)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, match := range matches {
|
||||||
|
src, err := os.Open(match)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer src.Close()
|
||||||
|
|
||||||
|
dstFile := filepath.Join(dstDir, filepath.Base(match))
|
||||||
|
dst, err := os.Create(dstFile)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer dst.Close()
|
||||||
|
|
||||||
|
_, err = io.Copy(dst, src)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
56
fileio/fileio_test.go
Normal file
56
fileio/fileio_test.go
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
package fileio
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestCopyFiles(t *testing.T) {
|
||||||
|
// Create a temporary directory for testing
|
||||||
|
srcDir, err := os.MkdirTemp("", "src")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer os.RemoveAll(srcDir)
|
||||||
|
|
||||||
|
// Create a source file
|
||||||
|
srcFile := filepath.Join(srcDir, "source.txt")
|
||||||
|
err = os.WriteFile(srcFile, []byte("test data"), 0644)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Specify the destination directory
|
||||||
|
dstDir, err := os.MkdirTemp("", "dst")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer os.RemoveAll(dstDir)
|
||||||
|
|
||||||
|
// Call the CopyFiles function
|
||||||
|
pattern := filepath.Join(srcDir, "*.txt")
|
||||||
|
err = CopyFiles(pattern, dstDir)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify that the file was copied correctly
|
||||||
|
dstFile := filepath.Join(dstDir, "source.txt")
|
||||||
|
_, err = os.Stat(dstFile)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("file not copied: %s", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read the contents of the copied file
|
||||||
|
data, err := os.ReadFile(dstFile)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify the contents of the copied file
|
||||||
|
expected := []byte("test data")
|
||||||
|
if string(data) != string(expected) {
|
||||||
|
t.Errorf("unexpected file content: got %q, want %q", data, expected)
|
||||||
|
}
|
||||||
|
}
|
||||||
19
httpx/httpx.go
Normal file
19
httpx/httpx.go
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
// Package httpx provides helper functions for making HTTP requests.
|
||||||
|
package httpx
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
var client = Client(&http.Client{Timeout: 5 * time.Second})
|
||||||
|
|
||||||
|
// Client is something that can send HTTP requests.
|
||||||
|
type Client interface {
|
||||||
|
Do(req *http.Request) (*http.Response, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Do sends an HTTP request and returns an HTTP response.
|
||||||
|
func Do(req *http.Request) (*http.Response, error) {
|
||||||
|
return client.Do(req)
|
||||||
|
}
|
||||||
40
httpx/httpx_test.go
Normal file
40
httpx/httpx_test.go
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
package httpx
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestDo(t *testing.T) {
|
||||||
|
srv := MockServer()
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
t.Run("ok", func(t *testing.T) {
|
||||||
|
uri := srv.URL + "/example.json"
|
||||||
|
req, _ := http.NewRequest(http.MethodGet, uri, nil)
|
||||||
|
|
||||||
|
resp, err := Do(req)
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("Do: unexpected error %v", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
t.Errorf("Do: expected status=%d, got %v", http.StatusOK, resp.StatusCode)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
t.Run("not found", func(t *testing.T) {
|
||||||
|
uri := srv.URL + "/not-found.json"
|
||||||
|
req, _ := http.NewRequest(http.MethodGet, uri, nil)
|
||||||
|
|
||||||
|
resp, err := Do(req)
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("Do: unexpected error %v", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode != http.StatusNotFound {
|
||||||
|
t.Errorf("Do: expected status=%d, got %v", http.StatusNotFound, resp.StatusCode)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
96
httpx/mock.go
Normal file
96
httpx/mock.go
Normal file
@@ -0,0 +1,96 @@
|
|||||||
|
package httpx
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"bytes"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"os"
|
||||||
|
"path"
|
||||||
|
"path/filepath"
|
||||||
|
)
|
||||||
|
|
||||||
|
var contentTypes = map[string]string{
|
||||||
|
".json": "application/json",
|
||||||
|
".txt": "text/plain",
|
||||||
|
}
|
||||||
|
|
||||||
|
// MockClient serves responses from the file system instead of remote calls.
|
||||||
|
// Should be used for testing purposes only.
|
||||||
|
type MockClient struct {
|
||||||
|
dir string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mock creates a new MockClient and installs it instead of the default one.
|
||||||
|
func Mock(path ...string) *MockClient {
|
||||||
|
dir := filepath.Join("testdata", filepath.Join(path...))
|
||||||
|
c := &MockClient{dir: dir}
|
||||||
|
client = c
|
||||||
|
return c
|
||||||
|
}
|
||||||
|
|
||||||
|
// Do serves the file according to the request URL.
|
||||||
|
func (c *MockClient) Do(req *http.Request) (*http.Response, error) {
|
||||||
|
filename := filepath.Join(c.dir, path.Base(req.URL.Path))
|
||||||
|
|
||||||
|
data, err := os.ReadFile(filename)
|
||||||
|
if err != nil {
|
||||||
|
resp := http.Response{
|
||||||
|
Status: http.StatusText(http.StatusNotFound),
|
||||||
|
StatusCode: http.StatusNotFound,
|
||||||
|
}
|
||||||
|
return &resp, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
cType, ok := contentTypes[path.Ext(filename)]
|
||||||
|
if !ok {
|
||||||
|
cType = "application/octet-stream"
|
||||||
|
}
|
||||||
|
rdr := respond(cType, data)
|
||||||
|
resp, err := http.ReadResponse(bufio.NewReader(rdr), req)
|
||||||
|
if err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
return resp, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func respond(cType string, data []byte) io.Reader {
|
||||||
|
buf := bytes.Buffer{}
|
||||||
|
buf.WriteString("HTTP/1.1 200 OK\n")
|
||||||
|
buf.WriteString(fmt.Sprintf("Content-Type: %s\n\n", cType))
|
||||||
|
_, err := buf.Write(data)
|
||||||
|
if err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
return &buf
|
||||||
|
}
|
||||||
|
|
||||||
|
// MockServer creates a mock HTTP server and installs its client
|
||||||
|
// instead of the default one. Serves responses from the file system
|
||||||
|
// instead of remote calls. Should be used for testing purposes only.
|
||||||
|
func MockServer() *httptest.Server {
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
filename := filepath.Join("testdata", path.Base(r.URL.Path))
|
||||||
|
|
||||||
|
data, err := os.ReadFile(filename)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, http.StatusText(http.StatusNotFound), http.StatusNotFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
cType, ok := contentTypes[path.Ext(filename)]
|
||||||
|
if !ok {
|
||||||
|
cType = "application/octet-stream"
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set("content-type", cType)
|
||||||
|
_, err = w.Write(data)
|
||||||
|
if err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
client = srv.Client()
|
||||||
|
return srv
|
||||||
|
}
|
||||||
34
httpx/mock_test.go
Normal file
34
httpx/mock_test.go
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
package httpx
|
||||||
|
|
||||||
|
import (
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestMockClient(t *testing.T) {
|
||||||
|
Mock()
|
||||||
|
|
||||||
|
const url = "https://codapi.org/example.txt"
|
||||||
|
req, _ := http.NewRequest("GET", url, nil)
|
||||||
|
|
||||||
|
resp, err := Do(req)
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("Do: unexpected error %v", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
t.Errorf("Do: expected status code %d, got %d", http.StatusOK, resp.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
body, err := io.ReadAll(resp.Body)
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("io.ReadAll: unexpected error %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
want := "hello"
|
||||||
|
if string(body) != want {
|
||||||
|
t.Errorf("Do: expected %v, got %v", want, string(body))
|
||||||
|
}
|
||||||
|
}
|
||||||
1
httpx/testdata/example.json
vendored
Normal file
1
httpx/testdata/example.json
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
{ "name": "alice" }
|
||||||
1
httpx/testdata/example.txt
vendored
Normal file
1
httpx/testdata/example.txt
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
hello
|
||||||
6
images/alpine/Dockerfile
Normal file
6
images/alpine/Dockerfile
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
FROM alpine:3.18
|
||||||
|
|
||||||
|
RUN adduser --home /sandbox --disabled-password sandbox
|
||||||
|
|
||||||
|
USER sandbox
|
||||||
|
WORKDIR /sandbox
|
||||||
52
logx/logx.go
Normal file
52
logx/logx.go
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
// Package logx provides helper functions for logging.
|
||||||
|
package logx
|
||||||
|
|
||||||
|
import (
|
||||||
|
"io"
|
||||||
|
"log"
|
||||||
|
"os"
|
||||||
|
)
|
||||||
|
|
||||||
|
var logger = log.New(os.Stderr, "", log.LstdFlags)
|
||||||
|
var Verbose = false
|
||||||
|
|
||||||
|
// SetOutput sets the output destination.
|
||||||
|
func SetOutput(w io.Writer) {
|
||||||
|
logger.SetOutput(w)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Printf prints a formatted message.
|
||||||
|
func Printf(format string, v ...any) {
|
||||||
|
logger.Printf(format, v...)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Println prints a message.
|
||||||
|
func Println(v ...any) {
|
||||||
|
logger.Println(v...)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Log prints a message.
|
||||||
|
func Log(message string, args ...any) {
|
||||||
|
if len(args) == 0 {
|
||||||
|
logger.Println(message)
|
||||||
|
} else {
|
||||||
|
logger.Printf(message+"\n", args...)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Debug prints a message if the verbose mode is on.
|
||||||
|
func Debug(message string, args ...any) {
|
||||||
|
if !Verbose {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
Log(message, args...)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mock creates a new Memory and installs it as the logger output
|
||||||
|
// instead of the default one. Should be used for testing purposes only.
|
||||||
|
func Mock(path ...string) *Memory {
|
||||||
|
memory := NewMemory("log")
|
||||||
|
SetOutput(memory)
|
||||||
|
Verbose = true
|
||||||
|
return memory
|
||||||
|
}
|
||||||
70
logx/logx_test.go
Normal file
70
logx/logx_test.go
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
package logx
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
func TestSetOutput(t *testing.T) {
|
||||||
|
mem := NewMemory("log")
|
||||||
|
SetOutput(mem)
|
||||||
|
Log("hello")
|
||||||
|
if !mem.Has("hello") {
|
||||||
|
t.Error("SetOutput: memory not set as output")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLog(t *testing.T) {
|
||||||
|
mem := NewMemory("log")
|
||||||
|
SetOutput(mem)
|
||||||
|
{
|
||||||
|
Log("value: %d", 42)
|
||||||
|
if len(mem.Lines) != 1 {
|
||||||
|
t.Errorf("Log: expected line count %v", len(mem.Lines))
|
||||||
|
}
|
||||||
|
if !mem.Has("value: 42") {
|
||||||
|
t.Errorf("Log: expected output: %v", mem.Lines)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
{
|
||||||
|
Log("value: %d", 84)
|
||||||
|
if len(mem.Lines) != 2 {
|
||||||
|
t.Errorf("Log: expected line count %v", len(mem.Lines))
|
||||||
|
}
|
||||||
|
if !mem.Has("value: 42") || !mem.Has("value: 84") {
|
||||||
|
t.Errorf("Log: expected output: %v", mem.Lines)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDebug(t *testing.T) {
|
||||||
|
t.Run("enabled", func(t *testing.T) {
|
||||||
|
mem := NewMemory("log")
|
||||||
|
SetOutput(mem)
|
||||||
|
Verbose = true
|
||||||
|
{
|
||||||
|
Debug("value: %d", 42)
|
||||||
|
if len(mem.Lines) != 1 {
|
||||||
|
t.Errorf("Log: expected line count %v", len(mem.Lines))
|
||||||
|
}
|
||||||
|
if !mem.Has("value: 42") {
|
||||||
|
t.Errorf("Log: expected output: %v", mem.Lines)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
{
|
||||||
|
Debug("value: %d", 84)
|
||||||
|
if len(mem.Lines) != 2 {
|
||||||
|
t.Errorf("Log: expected line count %v", len(mem.Lines))
|
||||||
|
}
|
||||||
|
if !mem.Has("value: 42") || !mem.Has("value: 84") {
|
||||||
|
t.Errorf("Log: expected output: %v", mem.Lines)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
t.Run("disabled", func(t *testing.T) {
|
||||||
|
mem := NewMemory("log")
|
||||||
|
SetOutput(mem)
|
||||||
|
Verbose = false
|
||||||
|
Debug("value: %d", 42)
|
||||||
|
if len(mem.Lines) != 0 {
|
||||||
|
t.Errorf("Log: expected line count %v", len(mem.Lines))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
61
logx/memory.go
Normal file
61
logx/memory.go
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
package logx
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Memory stores logged messages in a slice.
|
||||||
|
type Memory struct {
|
||||||
|
Name string
|
||||||
|
Lines []string
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewMemory creates a new memory destination.
|
||||||
|
func NewMemory(name string) *Memory {
|
||||||
|
return &Memory{Name: name, Lines: []string{}}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Write implements the io.Writer interface.
|
||||||
|
func (m *Memory) Write(p []byte) (n int, err error) {
|
||||||
|
msg := string(p)
|
||||||
|
m.Lines = append(m.Lines, msg)
|
||||||
|
return len(p), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// WriteString writes a string to the memory.
|
||||||
|
func (m *Memory) WriteString(s string) {
|
||||||
|
m.Lines = append(m.Lines, s)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Has returns true if the memory has the message.
|
||||||
|
func (m *Memory) Has(msg string) bool {
|
||||||
|
for _, line := range m.Lines {
|
||||||
|
if strings.Contains(line, msg) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// MustHave checks if the memory has the message.
|
||||||
|
func (m *Memory) MustHave(t *testing.T, msg string) {
|
||||||
|
if !m.Has(msg) {
|
||||||
|
t.Errorf("%s must have: %s", m.Name, msg)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MustNotHave checks if the memory does not have the message.
|
||||||
|
func (m *Memory) MustNotHave(t *testing.T, msg string) {
|
||||||
|
if m.Has(msg) {
|
||||||
|
t.Errorf("%s must NOT have: %s", m.Name, msg)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Print prints memory lines to stdout.
|
||||||
|
func (m *Memory) Print() {
|
||||||
|
for _, line := range m.Lines {
|
||||||
|
fmt.Print(line)
|
||||||
|
}
|
||||||
|
}
|
||||||
43
logx/memory_test.go
Normal file
43
logx/memory_test.go
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
package logx
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
func TestMemory_Name(t *testing.T) {
|
||||||
|
mem := NewMemory("log")
|
||||||
|
if mem.Name != "log" {
|
||||||
|
t.Errorf("Name: unexpected name %q", mem.Name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMemory_Write(t *testing.T) {
|
||||||
|
mem := NewMemory("log")
|
||||||
|
if len(mem.Lines) != 0 {
|
||||||
|
t.Fatalf("Write: unexpected line count %v", len(mem.Lines))
|
||||||
|
}
|
||||||
|
|
||||||
|
n, err := mem.Write([]byte("hello world"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Write: unexpected error %v", err)
|
||||||
|
}
|
||||||
|
if n != 11 {
|
||||||
|
t.Errorf("Write: unexpected byte count %v", n)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(mem.Lines) != 1 {
|
||||||
|
t.Fatalf("Write: unexpected line count %v", len(mem.Lines))
|
||||||
|
}
|
||||||
|
if mem.Lines[0] != "hello world" {
|
||||||
|
t.Errorf("Write: unexpected line #0 %q", mem.Lines[0])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMemory_Has(t *testing.T) {
|
||||||
|
mem := NewMemory("log")
|
||||||
|
if mem.Has("hello world") {
|
||||||
|
t.Error("Has: unexpected true")
|
||||||
|
}
|
||||||
|
_, _ = mem.Write([]byte("hello world"))
|
||||||
|
if !mem.Has("hello world") {
|
||||||
|
t.Error("Has: unexpected false")
|
||||||
|
}
|
||||||
|
}
|
||||||
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()
|
||||||
|
})
|
||||||
|
}
|
||||||
49
server/io.go
Normal file
49
server/io.go
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
// Reading requests and writing responses.
|
||||||
|
package server
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
)
|
||||||
|
|
||||||
|
// readJson decodes the request body from JSON.
|
||||||
|
func readJson[T any](r *http.Request) (T, error) {
|
||||||
|
var obj T
|
||||||
|
if r.Header.Get("content-type") != "application/json" {
|
||||||
|
return obj, errors.New(http.StatusText(http.StatusUnsupportedMediaType))
|
||||||
|
}
|
||||||
|
data, err := io.ReadAll(r.Body)
|
||||||
|
if err != nil {
|
||||||
|
return obj, err
|
||||||
|
}
|
||||||
|
err = json.Unmarshal(data, &obj)
|
||||||
|
if err != nil {
|
||||||
|
return obj, err
|
||||||
|
}
|
||||||
|
return obj, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// writeJson encodes an object into JSON and writes it to the response.
|
||||||
|
func writeJson(w http.ResponseWriter, obj any) error {
|
||||||
|
data, err := json.Marshal(obj)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set("content-type", "application/json")
|
||||||
|
_, err = w.Write(data)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// writeError encodes an error object into JSON and writes it to the response.
|
||||||
|
func writeError(w http.ResponseWriter, code int, obj any) {
|
||||||
|
data, _ := json.Marshal(obj)
|
||||||
|
w.Header().Set("content-type", "application/json")
|
||||||
|
w.WriteHeader(code)
|
||||||
|
w.Write(data) //nolint:errcheck
|
||||||
|
}
|
||||||
85
server/io_test.go
Normal file
85
server/io_test.go
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
package server
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"reflect"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/nalgeon/codapi/engine"
|
||||||
|
)
|
||||||
|
|
||||||
|
func Test_readJson(t *testing.T) {
|
||||||
|
t.Run("success", func(t *testing.T) {
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/example",
|
||||||
|
strings.NewReader(`{"sandbox": "python", "command": "run"}`))
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
|
||||||
|
got, err := readJson[engine.Request](req)
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("expected nil err, got %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
want := engine.Request{
|
||||||
|
Sandbox: "python", Command: "run",
|
||||||
|
}
|
||||||
|
if !reflect.DeepEqual(got, want) {
|
||||||
|
t.Errorf("expected %v, got %v", want, got)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
t.Run("unsupported media type", func(t *testing.T) {
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/example", nil)
|
||||||
|
req.Header.Set("Content-Type", "text/plain")
|
||||||
|
|
||||||
|
_, err := readJson[engine.Request](req)
|
||||||
|
if err == nil || err.Error() != "Unsupported Media Type" {
|
||||||
|
t.Errorf("unexpected error %v", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
t.Run("error", func(t *testing.T) {
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/example", strings.NewReader("hello world"))
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
|
||||||
|
_, err := readJson[engine.Request](req)
|
||||||
|
if err == nil {
|
||||||
|
t.Error("expected unmarshaling error")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func Test_writeJson(t *testing.T) {
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
obj := engine.Request{
|
||||||
|
ID: "42", Sandbox: "python", Command: "run",
|
||||||
|
}
|
||||||
|
|
||||||
|
err := writeJson(w, obj)
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("expected nil err, got %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
body := w.Body.String()
|
||||||
|
contentType := w.Header().Get("content-type")
|
||||||
|
if contentType != "application/json" {
|
||||||
|
t.Errorf("unexpected content-type header %s", contentType)
|
||||||
|
}
|
||||||
|
|
||||||
|
want := `{"id":"42","sandbox":"python","command":"run","files":null}`
|
||||||
|
if body != want {
|
||||||
|
t.Errorf("expected %s, got %s", body, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func Test_writeError(t *testing.T) {
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
obj := time.Date(2020, 10, 15, 0, 0, 0, 0, time.UTC)
|
||||||
|
writeError(w, http.StatusForbidden, obj)
|
||||||
|
if w.Code != http.StatusForbidden {
|
||||||
|
t.Errorf("expected status code %d, got %d", http.StatusForbidden, w.Code)
|
||||||
|
}
|
||||||
|
if w.Body.String() != `"2020-10-15T00:00:00Z"` {
|
||||||
|
t.Errorf("unexpected body %s", w.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
19
server/middleware.go
Normal file
19
server/middleware.go
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
// HTTP middlewares.
|
||||||
|
package server
|
||||||
|
|
||||||
|
import "net/http"
|
||||||
|
|
||||||
|
// enableCORS allows cross-site requests for a given handler.
|
||||||
|
func enableCORS(handler func(w http.ResponseWriter, r *http.Request)) func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Header().Set("access-control-allow-origin", "*")
|
||||||
|
w.Header().Set("access-control-allow-method", "post")
|
||||||
|
w.Header().Set("access-control-allow-headers", "authorization, content-type")
|
||||||
|
w.Header().Set("access-control-max-age", "3600")
|
||||||
|
if r.Method == http.MethodOptions {
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
handler(w, r)
|
||||||
|
}
|
||||||
|
}
|
||||||
45
server/middleware_test.go
Normal file
45
server/middleware_test.go
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
package server
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func Test_enableCORS(t *testing.T) {
|
||||||
|
t.Run("options", func(t *testing.T) {
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
r, _ := http.NewRequest("OPTIONS", "/v1/exec", nil)
|
||||||
|
handler := func(w http.ResponseWriter, r *http.Request) {}
|
||||||
|
fn := enableCORS(handler)
|
||||||
|
fn(w, r)
|
||||||
|
|
||||||
|
if w.Header().Get("access-control-allow-origin") != "*" {
|
||||||
|
t.Errorf("invalid access-control-allow-origin")
|
||||||
|
}
|
||||||
|
if w.Code != 200 {
|
||||||
|
t.Errorf("expected status code 200, got %d", w.Code)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
t.Run("post", func(t *testing.T) {
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
r, _ := http.NewRequest("POST", "/v1/exec", nil)
|
||||||
|
handler := func(w http.ResponseWriter, r *http.Request) {}
|
||||||
|
fn := enableCORS(handler)
|
||||||
|
fn(w, r)
|
||||||
|
|
||||||
|
if w.Header().Get("access-control-allow-origin") != "*" {
|
||||||
|
t.Errorf("invalid access-control-allow-origin")
|
||||||
|
}
|
||||||
|
if w.Header().Get("access-control-allow-method") != "post" {
|
||||||
|
t.Errorf("invalid access-control-allow-method")
|
||||||
|
}
|
||||||
|
if w.Header().Get("access-control-allow-headers") != "authorization, content-type" {
|
||||||
|
t.Errorf("invalid access-control-allow-headers")
|
||||||
|
}
|
||||||
|
if w.Header().Get("access-control-max-age") != "3600" {
|
||||||
|
t.Errorf("access-control-max-age")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
}
|
||||||
79
server/router.go
Normal file
79
server/router.go
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
// HTTP routes and handlers.
|
||||||
|
package server
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/nalgeon/codapi/engine"
|
||||||
|
"github.com/nalgeon/codapi/logx"
|
||||||
|
"github.com/nalgeon/codapi/sandbox"
|
||||||
|
"github.com/nalgeon/codapi/stringx"
|
||||||
|
)
|
||||||
|
|
||||||
|
// NewRouter creates HTTP routes and handlers for them.
|
||||||
|
func NewRouter() http.Handler {
|
||||||
|
mux := http.NewServeMux()
|
||||||
|
mux.HandleFunc("/v1/exec", enableCORS(exec))
|
||||||
|
return mux
|
||||||
|
}
|
||||||
|
|
||||||
|
// exec runs a sandbox command on the supplied code.
|
||||||
|
func exec(w http.ResponseWriter, r *http.Request) {
|
||||||
|
// only POST is allowed
|
||||||
|
if r.Method != http.MethodPost {
|
||||||
|
err := fmt.Errorf("unsupported method: %s", r.Method)
|
||||||
|
writeError(w, http.StatusMethodNotAllowed, engine.Fail("-", err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// read the input data - language, command, code
|
||||||
|
in, err := readJson[engine.Request](r)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, engine.Fail("-", err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
in.GenerateID()
|
||||||
|
|
||||||
|
// validate the input data
|
||||||
|
err = sandbox.Validate(in)
|
||||||
|
if errors.Is(err, sandbox.ErrUnknownSandbox) || errors.Is(err, sandbox.ErrUnknownCommand) {
|
||||||
|
writeError(w, http.StatusNotFound, engine.Fail(in.ID, err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, engine.Fail(in.ID, err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// execute the code using the sandbox
|
||||||
|
out := sandbox.Exec(in)
|
||||||
|
|
||||||
|
// fail on application error
|
||||||
|
if out.Err != nil {
|
||||||
|
logx.Log("✗ %s: %s", out.ID, out.Err)
|
||||||
|
if errors.Is(out.Err, engine.ErrBusy) {
|
||||||
|
writeError(w, http.StatusTooManyRequests, out)
|
||||||
|
} else {
|
||||||
|
writeError(w, http.StatusInternalServerError, out)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// log results
|
||||||
|
if out.OK {
|
||||||
|
logx.Log("✓ %s: took %d ms", out.ID, out.Duration)
|
||||||
|
} else {
|
||||||
|
msg := stringx.Compact(stringx.Shorten(out.Stderr, 80))
|
||||||
|
logx.Log("✗ %s: %s", out.ID, msg)
|
||||||
|
}
|
||||||
|
|
||||||
|
// write the response
|
||||||
|
err = writeJson(w, out)
|
||||||
|
if err != nil {
|
||||||
|
err = engine.NewExecutionError("write response", err)
|
||||||
|
writeError(w, http.StatusInternalServerError, engine.Fail(in.ID, err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
156
server/router_test.go
Normal file
156
server/router_test.go
Normal file
@@ -0,0 +1,156 @@
|
|||||||
|
package server
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/nalgeon/codapi/config"
|
||||||
|
"github.com/nalgeon/codapi/engine"
|
||||||
|
"github.com/nalgeon/codapi/execy"
|
||||||
|
"github.com/nalgeon/codapi/sandbox"
|
||||||
|
)
|
||||||
|
|
||||||
|
var cfg = &config.Config{
|
||||||
|
PoolSize: 8,
|
||||||
|
Boxes: map[string]*config.Box{
|
||||||
|
"python": {},
|
||||||
|
},
|
||||||
|
Commands: map[string]config.SandboxCommands{
|
||||||
|
"python": map[string]*config.Command{
|
||||||
|
"run": {
|
||||||
|
Engine: "docker",
|
||||||
|
Entry: "main.py",
|
||||||
|
Steps: []*config.Step{
|
||||||
|
{Box: "python", Action: "run", NOutput: 4096},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"test": {Engine: "docker"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
type server struct {
|
||||||
|
srv *httptest.Server
|
||||||
|
cli *http.Client
|
||||||
|
}
|
||||||
|
|
||||||
|
func newServer() *server {
|
||||||
|
router := NewRouter()
|
||||||
|
srv := httptest.NewServer(router)
|
||||||
|
return &server{srv, srv.Client()}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *server) post(uri string, val any) (*http.Response, error) {
|
||||||
|
body, _ := json.Marshal(val)
|
||||||
|
req, _ := http.NewRequest("POST", s.srv.URL+uri, bytes.NewReader(body))
|
||||||
|
req.Header.Set("content-type", "application/json")
|
||||||
|
return s.cli.Do(req)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *server) close() {
|
||||||
|
s.srv.Close()
|
||||||
|
}
|
||||||
|
|
||||||
|
func Test_exec(t *testing.T) {
|
||||||
|
_ = sandbox.ApplyConfig(cfg)
|
||||||
|
execy.Mock(map[string]execy.CmdOut{
|
||||||
|
"docker run": {Stdout: "hello"},
|
||||||
|
})
|
||||||
|
|
||||||
|
srv := newServer()
|
||||||
|
defer srv.close()
|
||||||
|
|
||||||
|
t.Run("success", func(t *testing.T) {
|
||||||
|
in := engine.Request{
|
||||||
|
Sandbox: "python",
|
||||||
|
Command: "run",
|
||||||
|
Files: map[string]string{
|
||||||
|
"": "print('hello')",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
resp, err := srv.post("/v1/exec", in)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("POST /exec: expected nil err, got %v", err)
|
||||||
|
}
|
||||||
|
out := decodeResp[engine.Execution](t, resp)
|
||||||
|
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("error not found", func(t *testing.T) {
|
||||||
|
in := engine.Request{
|
||||||
|
Sandbox: "rust",
|
||||||
|
Command: "run",
|
||||||
|
Files: nil,
|
||||||
|
}
|
||||||
|
resp, err := srv.post("/v1/exec", in)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("POST /exec: expected nil err, got %v", err)
|
||||||
|
}
|
||||||
|
if resp.StatusCode != http.StatusNotFound {
|
||||||
|
t.Errorf("StatusCode: expected 404, got %v", resp.StatusCode)
|
||||||
|
}
|
||||||
|
out := decodeResp[engine.Execution](t, resp)
|
||||||
|
if out.OK {
|
||||||
|
t.Error("OK: expected false")
|
||||||
|
}
|
||||||
|
if out.Stdout != "" {
|
||||||
|
t.Errorf("Stdout: expected empty string, got %s", out.Stdout)
|
||||||
|
}
|
||||||
|
if out.Stderr != "unknown sandbox" {
|
||||||
|
t.Errorf("Stderr: expected error, got %s", out.Stderr)
|
||||||
|
}
|
||||||
|
if out.Err != nil {
|
||||||
|
t.Errorf("Err: expected nil, got %v", out.Err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
t.Run("error bad request", func(t *testing.T) {
|
||||||
|
in := engine.Request{
|
||||||
|
Sandbox: "python",
|
||||||
|
Command: "run",
|
||||||
|
Files: nil,
|
||||||
|
}
|
||||||
|
resp, err := srv.post("/v1/exec", in)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("POST /exec: expected nil err, got %v", err)
|
||||||
|
}
|
||||||
|
if resp.StatusCode != http.StatusBadRequest {
|
||||||
|
t.Errorf("StatusCode: expected 400, got %v", resp.StatusCode)
|
||||||
|
}
|
||||||
|
out := decodeResp[engine.Execution](t, resp)
|
||||||
|
if out.OK {
|
||||||
|
t.Error("OK: expected false")
|
||||||
|
}
|
||||||
|
if out.Stdout != "" {
|
||||||
|
t.Errorf("Stdout: expected empty string, got %s", out.Stdout)
|
||||||
|
}
|
||||||
|
if out.Stderr != "empty request" {
|
||||||
|
t.Errorf("Stderr: expected error, got %s", out.Stderr)
|
||||||
|
}
|
||||||
|
if out.Err != nil {
|
||||||
|
t.Errorf("Err: expected nil, got %v", out.Err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func decodeResp[T any](t *testing.T, resp *http.Response) T {
|
||||||
|
defer resp.Body.Close()
|
||||||
|
var val T
|
||||||
|
err := json.NewDecoder(resp.Body).Decode(&val)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return val
|
||||||
|
}
|
||||||
59
server/server.go
Normal file
59
server/server.go
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
// Package server provides an HTTP API for running code in a sandbox.
|
||||||
|
package server
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/nalgeon/codapi/logx"
|
||||||
|
)
|
||||||
|
|
||||||
|
// The maximum duration of the server graceful shutdown.
|
||||||
|
const ShutdownTimeout = 3 * time.Second
|
||||||
|
|
||||||
|
// A Server is an HTTP sandbox server.
|
||||||
|
type Server struct {
|
||||||
|
srv *http.Server
|
||||||
|
wg *sync.WaitGroup
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewServer creates a new Server.
|
||||||
|
func NewServer(port int, handler http.Handler) *Server {
|
||||||
|
addr := fmt.Sprintf(":%d", port)
|
||||||
|
return &Server{
|
||||||
|
srv: &http.Server{Addr: addr, Handler: handler},
|
||||||
|
wg: &sync.WaitGroup{},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start starts the server.
|
||||||
|
func (s *Server) Start() {
|
||||||
|
// run the server inside a goroutine so that
|
||||||
|
// it does not block the main goroutine, and allow it
|
||||||
|
// to start other processes and listen for signals
|
||||||
|
s.wg.Add(1)
|
||||||
|
go func() {
|
||||||
|
defer s.wg.Done()
|
||||||
|
err := s.srv.ListenAndServe()
|
||||||
|
if err != http.ErrServerClosed {
|
||||||
|
logx.Log(err.Error())
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stop stops the server.
|
||||||
|
func (s *Server) Stop() error {
|
||||||
|
// perform a graceful shutdown, but not longer
|
||||||
|
// than the duration of ShutdownTimeout
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), ShutdownTimeout)
|
||||||
|
defer cancel()
|
||||||
|
err := s.srv.Shutdown(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
s.wg.Wait()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
31
server/server_test.go
Normal file
31
server/server_test.go
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
package server
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestServer(t *testing.T) {
|
||||||
|
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
})
|
||||||
|
|
||||||
|
srv := NewServer(8585, handler)
|
||||||
|
if srv.srv.Addr != ":8585" {
|
||||||
|
t.Fatalf("NewServer: expected port :8585 got %s", srv.srv.Addr)
|
||||||
|
}
|
||||||
|
|
||||||
|
srv.Start()
|
||||||
|
resp, err := http.Get("http://localhost:8585/get")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GET: expected nil err, got %v", err)
|
||||||
|
}
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
t.Fatalf("GET: expected status code 200, got %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
err = srv.Stop()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Stop: expected nil err, got %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
33
stringx/stringx.go
Normal file
33
stringx/stringx.go
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
// Package stringx provides helper functions for working with strings.
|
||||||
|
package stringx
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/rand"
|
||||||
|
"encoding/hex"
|
||||||
|
"regexp"
|
||||||
|
)
|
||||||
|
|
||||||
|
var compactRE = regexp.MustCompile(`\s+`)
|
||||||
|
|
||||||
|
// Shorten shortens a string to a specified number of characters.
|
||||||
|
func Shorten(s string, maxlen int) string {
|
||||||
|
var short = []rune(s)
|
||||||
|
if len(short) > maxlen {
|
||||||
|
short = short[:maxlen]
|
||||||
|
short = append(short, []rune(" [truncated]")...)
|
||||||
|
}
|
||||||
|
return string(short)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Compact replaces consecutive whitespaces with a single space.
|
||||||
|
func Compact(s string) string {
|
||||||
|
return compactRE.ReplaceAllString(string(s), " ")
|
||||||
|
}
|
||||||
|
|
||||||
|
// RandString generates a random string.
|
||||||
|
// length must be even.
|
||||||
|
func RandString(length int) string {
|
||||||
|
b := make([]byte, length/2)
|
||||||
|
_, _ = rand.Read(b)
|
||||||
|
return hex.EncodeToString(b)
|
||||||
|
}
|
||||||
51
stringx/stringx_test.go
Normal file
51
stringx/stringx_test.go
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
package stringx
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
func TestShorten(t *testing.T) {
|
||||||
|
t.Run("shorten", func(t *testing.T) {
|
||||||
|
const src = "Hello, World!"
|
||||||
|
const want = "Hello [truncated]"
|
||||||
|
got := Shorten(src, 5)
|
||||||
|
if got != want {
|
||||||
|
t.Errorf("expected %q, got %q", got, want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
t.Run("ignore", func(t *testing.T) {
|
||||||
|
const src = "Hello, World!"
|
||||||
|
const want = src
|
||||||
|
got := Shorten(src, 20)
|
||||||
|
if got != want {
|
||||||
|
t.Errorf("expected %q, got %q", got, want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCompact(t *testing.T) {
|
||||||
|
t.Run("compact", func(t *testing.T) {
|
||||||
|
const src = "go\nis awesome"
|
||||||
|
const want = "go is awesome"
|
||||||
|
got := Compact(src)
|
||||||
|
if got != want {
|
||||||
|
t.Errorf("expected %q, got %q", got, want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
t.Run("ignore", func(t *testing.T) {
|
||||||
|
const src = "go is awesome"
|
||||||
|
const want = src
|
||||||
|
got := Compact(src)
|
||||||
|
if got != want {
|
||||||
|
t.Errorf("expected %q, got %q", got, want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRandString(t *testing.T) {
|
||||||
|
lengths := []int{2, 4, 6, 8, 10}
|
||||||
|
for _, n := range lengths {
|
||||||
|
s := RandString(n)
|
||||||
|
if len(s) != n {
|
||||||
|
t.Errorf("%d: expected len(s) = %d, got %d", n, n, len(s))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user