refactor: internal package
This commit is contained in:
297
internal/engine/docker.go
Normal file
297
internal/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/internal/config"
|
||||
"github.com/nalgeon/codapi/internal/execy"
|
||||
"github.com/nalgeon/codapi/internal/fileio"
|
||||
"github.com/nalgeon/codapi/internal/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
internal/engine/docker_test.go
Normal file
164
internal/engine/docker_test.go
Normal file
@@ -0,0 +1,164 @@
|
||||
package engine
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/nalgeon/codapi/internal/config"
|
||||
"github.com/nalgeon/codapi/internal/execy"
|
||||
"github.com/nalgeon/codapi/internal/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
internal/engine/engine.go
Normal file
116
internal/engine/engine.go
Normal file
@@ -0,0 +1,116 @@
|
||||
// Package engine provides code execution engines.
|
||||
package engine
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"github.com/nalgeon/codapi/internal/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
internal/engine/engine_test.go
Normal file
137
internal/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
internal/engine/exec.go
Normal file
54
internal/engine/exec.go
Normal file
@@ -0,0 +1,54 @@
|
||||
package engine
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"os/exec"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/nalgeon/codapi/internal/execy"
|
||||
"github.com/nalgeon/codapi/internal/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
internal/engine/exec_test.go
Normal file
74
internal/engine/exec_test.go
Normal file
@@ -0,0 +1,74 @@
|
||||
package engine
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/nalgeon/codapi/internal/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
internal/engine/http.go
Normal file
166
internal/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/internal/config"
|
||||
"github.com/nalgeon/codapi/internal/httpx"
|
||||
"github.com/nalgeon/codapi/internal/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
internal/engine/http_test.go
Normal file
179
internal/engine/http_test.go
Normal file
@@ -0,0 +1,179 @@
|
||||
package engine
|
||||
|
||||
import (
|
||||
"io"
|
||||
"net/http"
|
||||
"testing"
|
||||
|
||||
"github.com/nalgeon/codapi/internal/config"
|
||||
"github.com/nalgeon/codapi/internal/httpx"
|
||||
"github.com/nalgeon/codapi/internal/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
internal/engine/io.go
Normal file
31
internal/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
internal/engine/io_test.go
Normal file
56
internal/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
internal/engine/testdata/example.txt
vendored
Normal file
1
internal/engine/testdata/example.txt
vendored
Normal file
@@ -0,0 +1 @@
|
||||
hello
|
||||
Reference in New Issue
Block a user