diff --git a/internal/config/config.go b/internal/config/config.go index 1e77f4a..20a6594 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -99,6 +99,7 @@ type Step struct { Version string `json:"version"` User string `json:"user"` Action string `json:"action"` + Detach bool `json:"detach"` Stdin bool `json:"stdin"` Command []string `json:"command"` Timeout int `json:"timeout"` diff --git a/internal/engine/docker.go b/internal/engine/docker.go index bb7adfd..b8fbcfe 100644 --- a/internal/engine/docker.go +++ b/internal/engine/docker.go @@ -23,6 +23,7 @@ var killTimeout = 5 * time.Second const ( actionRun = "run" actionExec = "exec" + actionStop = "stop" ) // A Docker engine executes a specific sandbox command @@ -125,9 +126,9 @@ func (e *Docker) execStep(step *config.Step, req Request, dir string, files File // getBox selects an appropriate box for the step (if any). func (e *Docker) getBox(step *config.Step, req Request) (*config.Box, error) { - if step.Action == actionExec { - // exec steps use existing instances - // and do not spin up new boxes + if step.Action != actionRun { + // steps other than "run" use existing containers + // and do not spin up new ones return nil, nil } var boxName string @@ -244,11 +245,14 @@ func (e *Docker) exec(box *config.Box, step *config.Step, req Request, dir strin // buildArgs prepares the arguments for the `docker` command. func (e *Docker) buildArgs(box *config.Box, step *config.Step, req Request, dir string) []string { var args []string - if step.Action == actionRun { + switch step.Action { + case actionRun: args = dockerRunArgs(box, step, req, dir) - } else if step.Action == actionExec { - args = dockerExecArgs(step) - } else { + case actionExec: + args = dockerExecArgs(step, req) + case actionStop: + args = dockerStopArgs(step, req) + default: // should never happen if the config is valid args = []string{"version"} } @@ -271,12 +275,15 @@ func dockerRunArgs(box *config.Box, step *config.Step, req Request, dir string) "--pids-limit", strconv.Itoa(box.NProc), "--user", step.User, } - if !box.Writable { - args = append(args, "--read-only") + if step.Detach { + args = append(args, "--detach") } if step.Stdin { args = append(args, "--interactive") } + if !box.Writable { + args = append(args, "--read-only") + } if box.Storage != "" { args = append(args, "--storage-opt", fmt.Sprintf("size=%s", box.Storage)) } @@ -300,14 +307,23 @@ func dockerRunArgs(box *config.Box, step *config.Step, req Request, dir string) } // dockerExecArgs prepares the arguments for the `docker exec` command. -func dockerExecArgs(step *config.Step) []string { +func dockerExecArgs(step *config.Step, req Request) []string { + // :name means executing in the container passed in the request + box := strings.Replace(step.Box, ":name", req.ID, 1) return []string{ actionExec, "--interactive", "--user", step.User, - step.Box, + box, } } +// dockerStopArgs prepares the arguments for the `docker stop` command. +func dockerStopArgs(step *config.Step, req Request) []string { + // :name means executing in the container passed in the request + box := strings.Replace(step.Box, ":name", req.ID, 1) + return []string{actionStop, box} +} + // filesReader creates a reader over an in-memory collection of files. func filesReader(files Files) io.Reader { var input strings.Builder diff --git a/internal/engine/docker_test.go b/internal/engine/docker_test.go index aaa38c8..52584fd 100644 --- a/internal/engine/docker_test.go +++ b/internal/engine/docker_test.go @@ -59,6 +59,27 @@ var dockerCfg = &config.Config{ }, }, Commands: map[string]config.SandboxCommands{ + "alpine": map[string]*config.Command{ + "echo": { + Engine: "docker", + Before: &config.Step{ + Box: "alpine", User: "sandbox", Action: "run", Detach: true, + Command: []string{"echo", "before"}, + NOutput: 4096, + }, + Steps: []*config.Step{ + { + Box: ":name", User: "sandbox", Action: "exec", + Command: []string{"sh", "main.sh"}, + NOutput: 4096, + }, + }, + After: &config.Step{ + Box: ":name", User: "sandbox", Action: "stop", + NOutput: 4096, + }, + }, + }, "go": map[string]*config.Command{ "run": { Engine: "docker", @@ -297,6 +318,49 @@ func TestDockerExec(t *testing.T) { }) } +func TestDockerStop(t *testing.T) { + logx.Mock() + commands := map[string]execy.CmdOut{ + "docker run": {Stdout: "c958ff2", Stderr: "", Err: nil}, + "docker exec": {Stdout: "hello", Stderr: "", Err: nil}, + "docker stop": {Stdout: "alpine_42", Stderr: "", Err: nil}, + } + mem := execy.Mock(commands) + engine := NewDocker(dockerCfg, "alpine", "echo") + + t.Run("success", func(t *testing.T) { + req := Request{ + ID: "alpine_42", + Sandbox: "alpine", + Command: "echo", + Files: map[string]string{ + "": "echo hello", + }, + } + 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" + 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, "docker run --rm --name alpine_42", "--detach") + mem.MustHave(t, "docker exec --interactive --user sandbox alpine_42 sh main.sh") + mem.MustHave(t, "docker stop alpine_42") + }) +} + func Test_expandVars(t *testing.T) { const name = "codapi_01" commands := map[string]string{ diff --git a/internal/logx/memory.go b/internal/logx/memory.go index e13eeae..96d337c 100644 --- a/internal/logx/memory.go +++ b/internal/logx/memory.go @@ -30,9 +30,16 @@ func (m *Memory) WriteString(s string) { } // Has returns true if the memory has the message. -func (m *Memory) Has(msg string) bool { +func (m *Memory) Has(message ...string) bool { for _, line := range m.Lines { - if strings.Contains(line, msg) { + containsAll := true + for _, part := range message { + if !strings.Contains(line, part) { + containsAll = false + break + } + } + if containsAll { return true } } @@ -40,16 +47,18 @@ func (m *Memory) Has(msg string) bool { } // 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) +// If the message consists of several parts, +// they must all be in the same memory line. +func (m *Memory) MustHave(t *testing.T, message ...string) { + if !m.Has(message...) { + t.Errorf("%s must have: %v", m.Name, message) } } // 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) +func (m *Memory) MustNotHave(t *testing.T, message ...string) { + if m.Has(message...) { + t.Errorf("%s must NOT have: %v", m.Name, message) } } diff --git a/internal/logx/memory_test.go b/internal/logx/memory_test.go index fac8646..4913afa 100644 --- a/internal/logx/memory_test.go +++ b/internal/logx/memory_test.go @@ -40,4 +40,23 @@ func TestMemory_Has(t *testing.T) { if !mem.Has("hello world") { t.Error("Has: unexpected false") } + _, _ = mem.Write([]byte("one two three four")) + if !mem.Has("one two") { + t.Error("Has: one two: unexpected false") + } + if !mem.Has("two three") { + t.Error("Has: two three: unexpected false") + } + if mem.Has("one three") { + t.Error("Has: one three: unexpected true") + } + if !mem.Has("one", "three") { + t.Error("Has: [one three]: unexpected false") + } + if !mem.Has("one", "three", "four") { + t.Error("Has: [one three four]: unexpected false") + } + if !mem.Has("four", "three") { + t.Error("Has: [four three]: unexpected false") + } }