Add ability to copy files to and delete files from runner

This commit is contained in:
Jan-Eric Hellenberg
2021-05-31 16:31:15 +02:00
parent 242d0175a2
commit 02b3f52a11
23 changed files with 757 additions and 240 deletions

View File

@ -12,9 +12,9 @@ import (
var log = logging.GetLogger("api") var log = logging.GetLogger("api")
const ( const (
RouteBase = "/api/v1" BasePath = "/api/v1"
RouteHealth = "/health" HealthPath = "/health"
RouteRunners = "/runners" RunnersPath = "/runners"
) )
// NewRouter returns a *mux.Router which can be // NewRouter returns a *mux.Router which can be
@ -33,8 +33,8 @@ func NewRouter(runnerManager runner.Manager, environmentManager environment.Mana
// configureV1Router configures a given router with the routes of version 1 of the Poseidon API. // configureV1Router configures a given router with the routes of version 1 of the Poseidon API.
func configureV1Router(router *mux.Router, runnerManager runner.Manager, environmentManager environment.Manager) { func configureV1Router(router *mux.Router, runnerManager runner.Manager, environmentManager environment.Manager) {
v1 := router.PathPrefix(RouteBase).Subrouter() v1 := router.PathPrefix(BasePath).Subrouter()
v1.HandleFunc(RouteHealth, Health).Methods(http.MethodGet) v1.HandleFunc(HealthPath, Health).Methods(http.MethodGet)
runnerController := &RunnerController{manager: runnerManager} runnerController := &RunnerController{manager: runnerManager}

View File

@ -4,6 +4,8 @@ import (
"encoding/json" "encoding/json"
"errors" "errors"
"fmt" "fmt"
"path"
"strings"
) )
// RunnerRequest is the expected json structure of the request body for the ProvideRunner function. // RunnerRequest is the expected json structure of the request body for the ProvideRunner function.
@ -45,15 +47,54 @@ type RunnerResponse struct {
Id string `json:"runnerId"` Id string `json:"runnerId"`
} }
// FileCreation is the expected json structure of the request body for the copy files route.
// TODO: specify content of the struct
type FileCreation struct{}
// ExecutionResponse is the expected response when creating an execution for a runner. // ExecutionResponse is the expected response when creating an execution for a runner.
type ExecutionResponse struct { type ExecutionResponse struct {
WebSocketUrl string `json:"websocketUrl"` WebSocketUrl string `json:"websocketUrl"`
} }
// UpdateFileSystemRequest is the expected json structure of the request body for the update file system route.
type UpdateFileSystemRequest struct {
Delete []FilePath `json:"delete"`
Copy []File `json:"copy"`
}
// FilePath specifies the path of a file and is part of the UpdateFileSystemRequest.
type FilePath string
// File is a DTO for transmitting file contents. It is part of the UpdateFileSystemRequest.
type File struct {
Path FilePath `json:"path"`
Content []byte `json:"content"`
}
// ToAbsolute returns the absolute path of the FilePath with respect to the given basePath. If the FilePath already is absolute, basePath will be ignored.
func (f FilePath) ToAbsolute(basePath string) string {
filePathString := string(f)
if path.IsAbs(filePathString) {
return path.Clean(filePathString)
}
return path.Clean(path.Join(basePath, filePathString))
}
// AbsolutePath returns the absolute path of the file. See FilePath.ToAbsolute for details.
func (f File) AbsolutePath(basePath string) string {
return f.Path.ToAbsolute(basePath)
}
// IsDirectory returns true iff the path of the File ends with a /.
func (f File) IsDirectory() bool {
return strings.HasSuffix(string(f.Path), "/")
}
// ByteContent returns the content of the File. If the File is a directory, the content will be empty.
func (f File) ByteContent() []byte {
if f.IsDirectory() {
return []byte("")
} else {
return f.Content
}
}
// WebSocketMessageType is the type for the messages from Poseidon to the client. // WebSocketMessageType is the type for the messages from Poseidon to the client.
type WebSocketMessageType string type WebSocketMessageType string

View File

@ -14,6 +14,7 @@ import (
const ( const (
ExecutePath = "/execute" ExecutePath = "/execute"
WebsocketPath = "/websocket" WebsocketPath = "/websocket"
UpdateFileSystemPath = "/files"
DeleteRoute = "deleteRunner" DeleteRoute = "deleteRunner"
RunnerIdKey = "runnerId" RunnerIdKey = "runnerId"
ExecutionIdKey = "executionId" ExecutionIdKey = "executionId"
@ -26,10 +27,11 @@ type RunnerController struct {
// ConfigureRoutes configures a given router with the runner routes of our API. // ConfigureRoutes configures a given router with the runner routes of our API.
func (r *RunnerController) ConfigureRoutes(router *mux.Router) { func (r *RunnerController) ConfigureRoutes(router *mux.Router) {
runnersRouter := router.PathPrefix(RouteRunners).Subrouter() runnersRouter := router.PathPrefix(RunnersPath).Subrouter()
runnersRouter.HandleFunc("", r.provide).Methods(http.MethodPost) runnersRouter.HandleFunc("", r.provide).Methods(http.MethodPost)
r.runnerRouter = runnersRouter.PathPrefix(fmt.Sprintf("/{%s}", RunnerIdKey)).Subrouter() r.runnerRouter = runnersRouter.PathPrefix(fmt.Sprintf("/{%s}", RunnerIdKey)).Subrouter()
r.runnerRouter.Use(r.findRunnerMiddleware) r.runnerRouter.Use(r.findRunnerMiddleware)
r.runnerRouter.HandleFunc(UpdateFileSystemPath, r.updateFileSystem).Methods(http.MethodPatch).Name(UpdateFileSystemPath)
r.runnerRouter.HandleFunc(ExecutePath, r.execute).Methods(http.MethodPost).Name(ExecutePath) r.runnerRouter.HandleFunc(ExecutePath, r.execute).Methods(http.MethodPost).Name(ExecutePath)
r.runnerRouter.HandleFunc(WebsocketPath, r.connectToRunner).Methods(http.MethodGet).Name(WebsocketPath) r.runnerRouter.HandleFunc(WebsocketPath, r.connectToRunner).Methods(http.MethodGet).Name(WebsocketPath)
r.runnerRouter.HandleFunc("", r.delete).Methods(http.MethodDelete).Name(DeleteRoute) r.runnerRouter.HandleFunc("", r.delete).Methods(http.MethodDelete).Name(DeleteRoute)
@ -37,7 +39,7 @@ func (r *RunnerController) ConfigureRoutes(router *mux.Router) {
// provide handles the provide runners API route. // provide handles the provide runners API route.
// It tries to respond with the id of a unused runner. // It tries to respond with the id of a unused runner.
// This runner is then reserved for future use // This runner is then reserved for future use.
func (r *RunnerController) provide(writer http.ResponseWriter, request *http.Request) { func (r *RunnerController) provide(writer http.ResponseWriter, request *http.Request) {
runnerRequest := new(dto.RunnerRequest) runnerRequest := new(dto.RunnerRequest)
if err := parseJSONRequestBody(writer, request, runnerRequest); err != nil { if err := parseJSONRequestBody(writer, request, runnerRequest); err != nil {
@ -59,6 +61,24 @@ func (r *RunnerController) provide(writer http.ResponseWriter, request *http.Req
sendJson(writer, &dto.RunnerResponse{Id: nextRunner.Id()}, http.StatusOK) sendJson(writer, &dto.RunnerResponse{Id: nextRunner.Id()}, http.StatusOK)
} }
// updateFileSystem handles the files API route.
// It takes an dto.UpdateFileSystemRequest and sends it to the runner for processing.
func (r *RunnerController) updateFileSystem(writer http.ResponseWriter, request *http.Request) {
fileCopyRequest := new(dto.UpdateFileSystemRequest)
if err := parseJSONRequestBody(writer, request, fileCopyRequest); err != nil {
return
}
targetRunner, _ := runner.FromContext(request.Context())
if err := targetRunner.UpdateFileSystem(fileCopyRequest); err != nil {
log.WithError(err).Error("Could not perform the requested updateFileSystem.")
writeInternalServerError(writer, err, dto.ErrorUnknown)
return
}
writer.WriteHeader(http.StatusNoContent)
}
// execute handles the execute API route. // execute handles the execute API route.
// It takes an ExecutionRequest and stores it for a runner. // It takes an ExecutionRequest and stores it for a runner.
// It returns a url to connect to for a websocket connection to this execution in the corresponding runner. // It returns a url to connect to for a websocket connection to this execution in the corresponding runner.

View File

@ -10,6 +10,7 @@ import (
"github.com/stretchr/testify/suite" "github.com/stretchr/testify/suite"
"gitlab.hpi.de/codeocean/codemoon/poseidon/api/dto" "gitlab.hpi.de/codeocean/codemoon/poseidon/api/dto"
"gitlab.hpi.de/codeocean/codemoon/poseidon/runner" "gitlab.hpi.de/codeocean/codemoon/poseidon/runner"
"gitlab.hpi.de/codeocean/codemoon/poseidon/tests"
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
"net/url" "net/url"
@ -159,6 +160,73 @@ func (suite *RunnerRouteTestSuite) TestExecuteRoute() {
}) })
} }
func TestUpdateFileSystemRouteTestSuite(t *testing.T) {
suite.Run(t, new(UpdateFileSystemRouteTestSuite))
}
type UpdateFileSystemRouteTestSuite struct {
RunnerRouteTestSuite
path string
recorder *httptest.ResponseRecorder
runnerMock *runner.RunnerMock
}
func (s *UpdateFileSystemRouteTestSuite) SetupTest() {
s.RunnerRouteTestSuite.SetupTest()
routeUrl, err := s.router.Get(UpdateFileSystemPath).URL(RunnerIdKey, tests.DefaultMockId)
if err != nil {
s.T().Fatal(err)
}
s.path = routeUrl.String()
s.runnerMock = &runner.RunnerMock{}
s.runnerManager.On("Get", tests.DefaultMockId).Return(s.runnerMock, nil)
s.recorder = httptest.NewRecorder()
}
func (s *UpdateFileSystemRouteTestSuite) TestUpdateFileSystemReturnsNoContentOnValidRequest() {
s.runnerMock.On("UpdateFileSystem", mock.AnythingOfType("*dto.UpdateFileSystemRequest")).Return(nil)
copyRequest := dto.UpdateFileSystemRequest{}
body, _ := json.Marshal(copyRequest)
request, _ := http.NewRequest(http.MethodPatch, s.path, bytes.NewReader(body))
s.router.ServeHTTP(s.recorder, request)
s.Equal(http.StatusNoContent, s.recorder.Code)
s.runnerMock.AssertCalled(s.T(), "UpdateFileSystem", mock.AnythingOfType("*dto.UpdateFileSystemRequest"))
}
func (s *UpdateFileSystemRouteTestSuite) TestUpdateFileSystemReturnsBadRequestOnInvalidRequestBody() {
request, _ := http.NewRequest(http.MethodPatch, s.path, strings.NewReader(""))
s.router.ServeHTTP(s.recorder, request)
s.Equal(http.StatusBadRequest, s.recorder.Code)
}
func (s *UpdateFileSystemRouteTestSuite) TestUpdateFileSystemToNonExistingRunnerReturnsNotFound() {
invalidID := "some-invalid-runner-id"
s.runnerManager.On("Get", invalidID).Return(nil, runner.ErrRunnerNotFound)
path, _ := s.router.Get(UpdateFileSystemPath).URL(RunnerIdKey, invalidID)
copyRequest := dto.UpdateFileSystemRequest{}
body, _ := json.Marshal(copyRequest)
request, _ := http.NewRequest(http.MethodPatch, path.String(), bytes.NewReader(body))
s.router.ServeHTTP(s.recorder, request)
s.Equal(http.StatusNotFound, s.recorder.Code)
}
func (s *UpdateFileSystemRouteTestSuite) TestUpdateFileSystemReturnsInternalServerErrorWhenCopyFailed() {
s.runnerMock.
On("UpdateFileSystem", mock.AnythingOfType("*dto.UpdateFileSystemRequest")).
Return(runner.ErrorFileCopyFailed)
copyRequest := dto.UpdateFileSystemRequest{}
body, _ := json.Marshal(copyRequest)
request, _ := http.NewRequest(http.MethodPatch, s.path, bytes.NewReader(body))
s.router.ServeHTTP(s.recorder, request)
s.Equal(http.StatusInternalServerError, s.recorder.Code)
}
func TestDeleteRunnerRouteTestSuite(t *testing.T) { func TestDeleteRunnerRouteTestSuite(t *testing.T) {
suite.Run(t, new(DeleteRunnerRouteTestSuite)) suite.Run(t, new(DeleteRunnerRouteTestSuite))
} }

View File

@ -168,7 +168,6 @@ func newWebSocketProxy(connection webSocketConnection) (*webSocketProxy, error)
// and handles WebSocket exit messages. // and handles WebSocket exit messages.
func (wp *webSocketProxy) waitForExit(exit <-chan runner.ExitInfo, cancelExecution context.CancelFunc) { func (wp *webSocketProxy) waitForExit(exit <-chan runner.ExitInfo, cancelExecution context.CancelFunc) {
defer wp.close() defer wp.close()
cancelInputLoop := wp.Stdin.readInputLoop() cancelInputLoop := wp.Stdin.readInputLoop()
var exitInfo runner.ExitInfo var exitInfo runner.ExitInfo
select { select {
@ -258,7 +257,7 @@ func (r *RunnerController) connectToRunner(writer http.ResponseWriter, request *
} }
log.WithField("runnerId", targetRunner.Id()).WithField("executionId", executionId).Info("Running execution") log.WithField("runnerId", targetRunner.Id()).WithField("executionId", executionId).Info("Running execution")
exit, cancel := targetRunner.Execute(executionRequest, proxy.Stdin, proxy.Stdout, proxy.Stderr) exit, cancel := targetRunner.ExecuteInteractively(executionRequest, proxy.Stdin, proxy.Stdout, proxy.Stderr)
proxy.waitForExit(exit, cancel) proxy.waitForExit(exit, cancel)
} }

View File

@ -16,7 +16,7 @@ import (
"gitlab.hpi.de/codeocean/codemoon/poseidon/api/dto" "gitlab.hpi.de/codeocean/codemoon/poseidon/api/dto"
"gitlab.hpi.de/codeocean/codemoon/poseidon/nomad" "gitlab.hpi.de/codeocean/codemoon/poseidon/nomad"
"gitlab.hpi.de/codeocean/codemoon/poseidon/runner" "gitlab.hpi.de/codeocean/codemoon/poseidon/runner"
"gitlab.hpi.de/codeocean/codemoon/poseidon/tests/e2e/helpers" "gitlab.hpi.de/codeocean/codemoon/poseidon/tests/helpers"
"io" "io"
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
@ -40,7 +40,7 @@ type WebSocketTestSuite struct {
func (suite *WebSocketTestSuite) SetupTest() { func (suite *WebSocketTestSuite) SetupTest() {
runnerId := "runner-id" runnerId := "runner-id"
suite.runner, suite.apiMock = helpers.NewNomadAllocationWithMockedApiClient(runnerId) suite.runner, suite.apiMock = newNomadAllocationWithMockedApiClient(runnerId)
// default execution // default execution
suite.executionId = "execution-id" suite.executionId = "execution-id"
@ -97,7 +97,7 @@ func (suite *WebSocketTestSuite) TestWebsocketConnection() {
suite.Run("Executes the request in the runner", func() { suite.Run("Executes the request in the runner", func() {
<-time.After(100 * time.Millisecond) <-time.After(100 * time.Millisecond)
suite.apiMock.AssertCalled(suite.T(), "ExecuteCommand", suite.apiMock.AssertCalled(suite.T(), "ExecuteCommand",
mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything) mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything)
}) })
suite.Run("Can send input", func() { suite.Run("Can send input", func() {
@ -137,7 +137,7 @@ func (suite *WebSocketTestSuite) TestCancelWebSocketConnection() {
select { select {
case <-canceled: case <-canceled:
suite.Fail("Execute canceled unexpected") suite.Fail("ExecuteInteractively canceled unexpected")
default: default:
} }
@ -147,7 +147,7 @@ func (suite *WebSocketTestSuite) TestCancelWebSocketConnection() {
select { select {
case <-canceled: case <-canceled:
case <-time.After(time.Second): case <-time.After(time.Second):
suite.Fail("Execute not canceled") suite.Fail("ExecuteInteractively not canceled")
} }
} }
@ -169,7 +169,7 @@ func (suite *WebSocketTestSuite) TestWebSocketConnectionTimeout() {
select { select {
case <-canceled: case <-canceled:
suite.Fail("Execute canceled unexpected") suite.Fail("ExecuteInteractively canceled unexpected")
case <-time.After(time.Duration(limitExecution.TimeLimit-1) * time.Second): case <-time.After(time.Duration(limitExecution.TimeLimit-1) * time.Second):
<-time.After(time.Second) <-time.After(time.Second)
} }
@ -177,7 +177,7 @@ func (suite *WebSocketTestSuite) TestWebSocketConnectionTimeout() {
select { select {
case <-canceled: case <-canceled:
case <-time.After(time.Second): case <-time.After(time.Second):
suite.Fail("Execute not canceled") suite.Fail("ExecuteInteractively not canceled")
} }
message, err = helpers.ReceiveNextWebSocketMessage(connection) message, err = helpers.ReceiveNextWebSocketMessage(connection)
@ -245,7 +245,7 @@ func (suite *WebSocketTestSuite) TestWebsocketNonZeroExit() {
func TestWebsocketTLS(t *testing.T) { func TestWebsocketTLS(t *testing.T) {
runnerId := "runner-id" runnerId := "runner-id"
r, apiMock := helpers.NewNomadAllocationWithMockedApiClient(runnerId) r, apiMock := newNomadAllocationWithMockedApiClient(runnerId)
executionId := runner.ExecutionId("execution-id") executionId := runner.ExecutionId("execution-id")
r.Add(executionId, &executionRequestLs) r.Add(executionId, &executionRequestLs)
@ -307,6 +307,12 @@ func TestRawToCodeOceanWriter(t *testing.T) {
// --- Test suite specific test helpers --- // --- Test suite specific test helpers ---
func newNomadAllocationWithMockedApiClient(runnerId string) (r runner.Runner, mock *nomad.ExecutorApiMock) {
mock = &nomad.ExecutorApiMock{}
r = runner.NewNomadAllocation(runnerId, mock)
return
}
func webSocketUrl(scheme string, server *httptest.Server, router *mux.Router, runnerId string, executionId runner.ExecutionId) (*url.URL, error) { func webSocketUrl(scheme string, server *httptest.Server, router *mux.Router, runnerId string, executionId runner.ExecutionId) (*url.URL, error) {
websocketUrl, err := url.Parse(server.URL) websocketUrl, err := url.Parse(server.URL)
if err != nil { if err != nil {
@ -331,7 +337,7 @@ var executionRequestLs = dto.ExecutionRequest{Command: "ls"}
// mockApiExecuteLs mocks the ExecuteCommand of an ExecutorApi to act as if 'ls existing-file non-existing-file' was executed. // mockApiExecuteLs mocks the ExecuteCommand of an ExecutorApi to act as if 'ls existing-file non-existing-file' was executed.
func mockApiExecuteLs(api *nomad.ExecutorApiMock) { func mockApiExecuteLs(api *nomad.ExecutorApiMock) {
helpers.MockApiExecute(api, &executionRequestLs, helpers.MockApiExecute(api, &executionRequestLs,
func(_ string, _ context.Context, _ []string, _ io.Reader, stdout, stderr io.Writer) (int, error) { func(_ string, _ context.Context, _ []string, _ bool, _ io.Reader, stdout, stderr io.Writer) (int, error) {
_, _ = stdout.Write([]byte("existing-file\n")) _, _ = stdout.Write([]byte("existing-file\n"))
_, _ = stderr.Write([]byte("ls: cannot access 'non-existing-file': No such file or directory\n")) _, _ = stderr.Write([]byte("ls: cannot access 'non-existing-file': No such file or directory\n"))
return 0, nil return 0, nil
@ -343,7 +349,7 @@ var executionRequestHead = dto.ExecutionRequest{Command: "head -n 1"}
// mockApiExecuteHead mocks the ExecuteCommand of an ExecutorApi to act as if 'head -n 1' was executed. // mockApiExecuteHead mocks the ExecuteCommand of an ExecutorApi to act as if 'head -n 1' was executed.
func mockApiExecuteHead(api *nomad.ExecutorApiMock) { func mockApiExecuteHead(api *nomad.ExecutorApiMock) {
helpers.MockApiExecute(api, &executionRequestHead, helpers.MockApiExecute(api, &executionRequestHead,
func(_ string, _ context.Context, _ []string, stdin io.Reader, stdout io.Writer, stderr io.Writer) (int, error) { func(_ string, _ context.Context, _ []string, _ bool, stdin io.Reader, stdout io.Writer, stderr io.Writer) (int, error) {
scanner := bufio.NewScanner(stdin) scanner := bufio.NewScanner(stdin)
for !scanner.Scan() { for !scanner.Scan() {
scanner = bufio.NewScanner(stdin) scanner = bufio.NewScanner(stdin)
@ -359,7 +365,7 @@ var executionRequestSleep = dto.ExecutionRequest{Command: "sleep infinity"}
func mockApiExecuteSleep(api *nomad.ExecutorApiMock) <-chan bool { func mockApiExecuteSleep(api *nomad.ExecutorApiMock) <-chan bool {
canceled := make(chan bool, 1) canceled := make(chan bool, 1)
helpers.MockApiExecute(api, &executionRequestSleep, helpers.MockApiExecute(api, &executionRequestSleep,
func(_ string, ctx context.Context, _ []string, stdin io.Reader, stdout io.Writer, stderr io.Writer) (int, error) { func(_ string, ctx context.Context, _ []string, _ bool, stdin io.Reader, stdout io.Writer, stderr io.Writer) (int, error) {
<-ctx.Done() <-ctx.Done()
close(canceled) close(canceled)
return 0, ctx.Err() return 0, ctx.Err()
@ -372,7 +378,7 @@ var executionRequestError = dto.ExecutionRequest{Command: "error"}
// mockApiExecuteError mocks the ExecuteCommand method of an ExecutorApi to return an error. // mockApiExecuteError mocks the ExecuteCommand method of an ExecutorApi to return an error.
func mockApiExecuteError(api *nomad.ExecutorApiMock) { func mockApiExecuteError(api *nomad.ExecutorApiMock) {
helpers.MockApiExecute(api, &executionRequestError, helpers.MockApiExecute(api, &executionRequestError,
func(_ string, _ context.Context, _ []string, _ io.Reader, _, _ io.Writer) (int, error) { func(_ string, _ context.Context, _ []string, _ bool, _ io.Reader, _, _ io.Writer) (int, error) {
return 0, errors.New("intended error") return 0, errors.New("intended error")
}) })
} }
@ -382,7 +388,7 @@ var executionRequestExitNonZero = dto.ExecutionRequest{Command: "exit 42"}
// mockApiExecuteExitNonZero mocks the ExecuteCommand method of an ExecutorApi to exit with exit status 42. // mockApiExecuteExitNonZero mocks the ExecuteCommand method of an ExecutorApi to exit with exit status 42.
func mockApiExecuteExitNonZero(api *nomad.ExecutorApiMock) { func mockApiExecuteExitNonZero(api *nomad.ExecutorApiMock) {
helpers.MockApiExecute(api, &executionRequestExitNonZero, helpers.MockApiExecute(api, &executionRequestExitNonZero,
func(_ string, _ context.Context, _ []string, _ io.Reader, _, _ io.Writer) (int, error) { func(_ string, _ context.Context, _ []string, _ bool, _ io.Reader, _, _ io.Writer) (int, error) {
return 42, nil return 42, nil
}) })
} }

View File

@ -32,6 +32,9 @@ var (
TLS: false, TLS: false,
Namespace: "default", Namespace: "default",
}, },
Runner: runner{
WorkspacePath: "/home/python",
},
Logger: logger{ Logger: logger{
Level: "INFO", Level: "INFO",
}, },
@ -65,6 +68,11 @@ type nomad struct {
Namespace string Namespace string
} }
// runner configures the runners on the executor
type runner struct {
WorkspacePath string
}
// logger configures the used logger. // logger configures the used logger.
type logger struct { type logger struct {
Level string Level string
@ -74,6 +82,7 @@ type logger struct {
type configuration struct { type configuration struct {
Server server Server server
Nomad nomad Nomad nomad
Runner runner
Logger logger Logger logger
} }

View File

@ -26,6 +26,11 @@ nomad:
# Nomad namespace to use. If unset, 'default' is used # Nomad namespace to use. If unset, 'default' is used
namespace: poseidon namespace: poseidon
# Configuration of the runners
runner:
# Directory where all files with relative paths will be copied into. Must be writable by the default user in the container.
workspacepath: /home/python
# Configuration of the logger # Configuration of the logger
logger: logger:
# Log level that is used after reading the config (INFO until then) # Log level that is used after reading the config (INFO until then)

View File

@ -3,7 +3,7 @@ info:
title: Poseidon API title: Poseidon API
description: | description: |
This API is used by CodeOcean to run code in runners. This API is used by CodeOcean to run code in runners.
version: '0.2.1' version: '0.2.2'
components: components:
schemas: schemas:
@ -180,7 +180,7 @@ paths:
/runners/{runnerId}/files: /runners/{runnerId}/files:
patch: patch:
summary: Manipulate runner file system summary: Manipulate runner file system
description: Copy the enclosed files to the file system of the specified runner. Existing files get overwritten and results of previous file copy operations on the same runner are present when executing multiple requests. description: Delete the files with the given paths from the file system of the specified runner. Afterwards, copy the enclosed files to the runner. Existing files get overwritten and results of previous file copy operations on the same runner are present when executing multiple requests.
tags: tags:
- runner - runner
parameters: parameters:
@ -199,30 +199,30 @@ paths:
schema: schema:
type: object type: object
properties: properties:
files: delete:
description: Array of files that should be placed in the runner. The files are processed in the order in which they are given description: Array of filepaths that should be deleted. Each of the given files or directories should be deleted recursively.
type: array
items:
description: Location of the file or directory that should be deleted. Can be absolute (starting with /) or relative to the workspace directory.
type: string
example: /workspace
copy:
description: Array of files that should be placed in the runner.
type: array type: array
items: items:
type: object type: object
properties: properties:
filepath: path:
description: Location where the file should be placed. Can be absolute (starting with /) or relative to the workspace directory. Missing parent directories get created. If this ends with a /, the path is interpreted as a directory and content is ignored description: Location where the file should be placed. Can be absolute (starting with /) or relative to the workspace directory. Missing parent directories are created. If this ends with a /, the path is interpreted as a directory and content is ignored
type: string type: string
example: /etc/passwd example: /etc/passwd
content: content:
description: The content of the file. Binary data is represented as escape sequences. Any c99 s-char-sequence surrounded by quotes is valid (e.g. "\x01\x02") and converted to its byte representation. If this is not given and delete is false, the file is created with no content description: The content of the file. MUST be base64 encoded. If this is not given, the file is created with no content.
type: string type: string
example: root:x:0:0::/root:/bin/bash example: cm9vdDp4OjA6MDo6L3Jvb3Q6L2Jpbi9iYXNo # root:x:0:0::/root:/bin/bash
delete:
description: Specify that the path should be deleted. If this is true, content is ignored and the file or directory is deleted (recursively) instead
type: boolean
default: false
example: false
required: required:
- filepath - path
additionalProperties: false additionalProperties: false
required:
- files
additionalProperties: false additionalProperties: false
responses: responses:
"204": "204":

View File

@ -25,7 +25,7 @@ type apiQuerier interface {
DeleteRunner(runnerId string) (err error) DeleteRunner(runnerId string) (err error)
// ExecuteCommand runs a command in the passed allocation. // ExecuteCommand runs a command in the passed allocation.
ExecuteCommand(allocationID string, ctx context.Context, command []string, ExecuteCommand(allocationID string, ctx context.Context, command []string, tty bool,
stdin io.Reader, stdout, stderr io.Writer) (int, error) stdin io.Reader, stdout, stderr io.Writer) (int, error)
// loadRunners loads all allocations of the specified job. // loadRunners loads all allocations of the specified job.
@ -66,13 +66,13 @@ func (nc *nomadApiClient) DeleteRunner(runnerId string) (err error) {
} }
func (nc *nomadApiClient) ExecuteCommand(allocationID string, func (nc *nomadApiClient) ExecuteCommand(allocationID string,
ctx context.Context, command []string, ctx context.Context, command []string, tty bool,
stdin io.Reader, stdout, stderr io.Writer) (int, error) { stdin io.Reader, stdout, stderr io.Writer) (int, error) {
allocation, _, err := nc.client.Allocations().Info(allocationID, nil) allocation, _, err := nc.client.Allocations().Info(allocationID, nil)
if err != nil { if err != nil {
return 1, err return 1, err
} }
return nc.client.Allocations().Exec(ctx, allocation, TaskName, true, command, stdin, stdout, stderr, nil, nil) return nc.client.Allocations().Exec(ctx, allocation, TaskName, tty, command, stdin, stdout, stderr, nil, nil)
} }
func (nc *nomadApiClient) loadRunners(jobId string) (allocationListStub []*nomadApi.AllocationListStub, err error) { func (nc *nomadApiClient) loadRunners(jobId string) (allocationListStub []*nomadApi.AllocationListStub, err error) {

View File

@ -56,20 +56,20 @@ func (_m *apiQuerierMock) EvaluationStream(evalID string, ctx context.Context) (
return r0, r1 return r0, r1
} }
// ExecuteCommand provides a mock function with given fields: allocationID, ctx, command, stdin, stdout, stderr // ExecuteCommand provides a mock function with given fields: allocationID, ctx, command, tty, stdin, stdout, stderr
func (_m *apiQuerierMock) ExecuteCommand(allocationID string, ctx context.Context, command []string, stdin io.Reader, stdout io.Writer, stderr io.Writer) (int, error) { func (_m *apiQuerierMock) ExecuteCommand(allocationID string, ctx context.Context, command []string, tty bool, stdin io.Reader, stdout io.Writer, stderr io.Writer) (int, error) {
ret := _m.Called(allocationID, ctx, command, stdin, stdout, stderr) ret := _m.Called(allocationID, ctx, command, tty, stdin, stdout, stderr)
var r0 int var r0 int
if rf, ok := ret.Get(0).(func(string, context.Context, []string, io.Reader, io.Writer, io.Writer) int); ok { if rf, ok := ret.Get(0).(func(string, context.Context, []string, bool, io.Reader, io.Writer, io.Writer) int); ok {
r0 = rf(allocationID, ctx, command, stdin, stdout, stderr) r0 = rf(allocationID, ctx, command, tty, stdin, stdout, stderr)
} else { } else {
r0 = ret.Get(0).(int) r0 = ret.Get(0).(int)
} }
var r1 error var r1 error
if rf, ok := ret.Get(1).(func(string, context.Context, []string, io.Reader, io.Writer, io.Writer) error); ok { if rf, ok := ret.Get(1).(func(string, context.Context, []string, bool, io.Reader, io.Writer, io.Writer) error); ok {
r1 = rf(allocationID, ctx, command, stdin, stdout, stderr) r1 = rf(allocationID, ctx, command, tty, stdin, stdout, stderr)
} else { } else {
r1 = ret.Error(1) r1 = ret.Error(1)
} }

View File

@ -56,20 +56,20 @@ func (_m *ExecutorApiMock) EvaluationStream(evalID string, ctx context.Context)
return r0, r1 return r0, r1
} }
// ExecuteCommand provides a mock function with given fields: allocationID, ctx, command, stdin, stdout, stderr // ExecuteCommand provides a mock function with given fields: allocationID, ctx, command, tty, stdin, stdout, stderr
func (_m *ExecutorApiMock) ExecuteCommand(allocationID string, ctx context.Context, command []string, stdin io.Reader, stdout io.Writer, stderr io.Writer) (int, error) { func (_m *ExecutorApiMock) ExecuteCommand(allocationID string, ctx context.Context, command []string, tty bool, stdin io.Reader, stdout io.Writer, stderr io.Writer) (int, error) {
ret := _m.Called(allocationID, ctx, command, stdin, stdout, stderr) ret := _m.Called(allocationID, ctx, command, tty, stdin, stdout, stderr)
var r0 int var r0 int
if rf, ok := ret.Get(0).(func(string, context.Context, []string, io.Reader, io.Writer, io.Writer) int); ok { if rf, ok := ret.Get(0).(func(string, context.Context, []string, bool, io.Reader, io.Writer, io.Writer) int); ok {
r0 = rf(allocationID, ctx, command, stdin, stdout, stderr) r0 = rf(allocationID, ctx, command, tty, stdin, stdout, stderr)
} else { } else {
r0 = ret.Get(0).(int) r0 = ret.Get(0).(int)
} }
var r1 error var r1 error
if rf, ok := ret.Get(1).(func(string, context.Context, []string, io.Reader, io.Writer, io.Writer) error); ok { if rf, ok := ret.Get(1).(func(string, context.Context, []string, bool, io.Reader, io.Writer, io.Writer) error); ok {
r1 = rf(allocationID, ctx, command, stdin, stdout, stderr) r1 = rf(allocationID, ctx, command, tty, stdin, stdout, stderr)
} else { } else {
r1 = ret.Error(1) r1 = ret.Error(1)
} }

View File

@ -11,7 +11,10 @@ import (
"strings" "strings"
) )
var log = logging.GetLogger("nomad") var (
log = logging.GetLogger("nomad")
ErrorExecutorCommunicationFailed = errors.New("communication with executor failed")
)
// ExecutorApi provides access to an container orchestration solution // ExecutorApi provides access to an container orchestration solution
type ExecutorApi interface { type ExecutorApi interface {

View File

@ -130,17 +130,20 @@ func (m *NomadRunnerManager) refreshEnvironment(id EnvironmentId) {
log.WithError(err).Printf("Failed get allocation count") log.WithError(err).Printf("Failed get allocation count")
break break
} }
neededRunners := job.desiredIdleRunnersCount - job.idleRunners.Length() + 1 additionallyNeededRunners := job.desiredIdleRunnersCount - job.idleRunners.Length() + 1
runnerCount := jobScale + neededRunners requiredRunnerCount := jobScale
if additionallyNeededRunners > 0 {
requiredRunnerCount += additionallyNeededRunners
}
time.Sleep(50 * time.Millisecond) time.Sleep(50 * time.Millisecond)
if runnerCount != lastJobScaling { if requiredRunnerCount != lastJobScaling {
log.Printf("Set job scaling %d", runnerCount) log.Printf("Set job scaling %d", requiredRunnerCount)
err = m.apiClient.SetJobScale(string(job.jobId), runnerCount, "Runner Requested") err = m.apiClient.SetJobScale(string(job.jobId), requiredRunnerCount, "Runner Requested")
if err != nil { if err != nil {
log.WithError(err).Printf("Failed set allocation scaling") log.WithError(err).Printf("Failed set allocation scaling")
continue continue
} }
lastJobScaling = runnerCount lastJobScaling = requiredRunnerCount
} }
} }
} }

View File

@ -1,11 +1,17 @@
package runner package runner
import ( import (
"archive/tar"
"bytes"
"context" "context"
"encoding/json" "encoding/json"
"errors"
"fmt"
"gitlab.hpi.de/codeocean/codemoon/poseidon/api/dto" "gitlab.hpi.de/codeocean/codemoon/poseidon/api/dto"
"gitlab.hpi.de/codeocean/codemoon/poseidon/config"
"gitlab.hpi.de/codeocean/codemoon/poseidon/nomad" "gitlab.hpi.de/codeocean/codemoon/poseidon/nomad"
"io" "io"
"strings"
"time" "time"
) )
@ -20,18 +26,30 @@ const (
runnerContextKey ContextKey = "runner" runnerContextKey ContextKey = "runner"
) )
var (
ErrorFileCopyFailed = errors.New("file copy failed")
FileCopyBasePath = config.Config.Runner.WorkspacePath
)
type Runner interface { type Runner interface {
// Id returns the id of the runner. // Id returns the id of the runner.
Id() string Id() string
ExecutionStorage ExecutionStorage
// Execute runs the given execution request and forwards from and to the given reader and writers. // ExecuteInteractively runs the given execution request and forwards from and to the given reader and writers.
// An ExitInfo is sent to the exit channel on command completion. // An ExitInfo is sent to the exit channel on command completion.
Execute(request *dto.ExecutionRequest, stdin io.Reader, stdout, stderr io.Writer) (exit <-chan ExitInfo, cancel context.CancelFunc) // Output from the runner is forwarded immediately.
ExecuteInteractively(
request *dto.ExecutionRequest,
stdin io.Reader,
stdout,
stderr io.Writer,
) (exit <-chan ExitInfo, cancel context.CancelFunc)
// Copy copies the specified files into the runner. // UpdateFileSystem processes a dto.UpdateFileSystemRequest by first deleting each given dto.FilePath recursively
Copy(dto.FileCreation) // and then copying each given dto.File to the runner.
UpdateFileSystem(request *dto.UpdateFileSystemRequest) error
} }
// NomadAllocation is an abstraction to communicate with Nomad allocations. // NomadAllocation is an abstraction to communicate with Nomad allocations.
@ -64,7 +82,11 @@ type ExitInfo struct {
Err error Err error
} }
func (r *NomadAllocation) Execute(request *dto.ExecutionRequest, stdin io.Reader, stdout, stderr io.Writer) (<-chan ExitInfo, context.CancelFunc) { func (r *NomadAllocation) ExecuteInteractively(
request *dto.ExecutionRequest,
stdin io.Reader,
stdout, stderr io.Writer,
) (<-chan ExitInfo, context.CancelFunc) {
command := request.FullCommand() command := request.FullCommand()
var ctx context.Context var ctx context.Context
var cancel context.CancelFunc var cancel context.CancelFunc
@ -75,15 +97,94 @@ func (r *NomadAllocation) Execute(request *dto.ExecutionRequest, stdin io.Reader
} }
exit := make(chan ExitInfo) exit := make(chan ExitInfo)
go func() { go func() {
exitCode, err := r.api.ExecuteCommand(r.Id(), ctx, command, stdin, stdout, stderr) exitCode, err := r.api.ExecuteCommand(r.Id(), ctx, command, true, stdin, stdout, stderr)
exit <- ExitInfo{uint8(exitCode), err} exit <- ExitInfo{uint8(exitCode), err}
close(exit) close(exit)
}() }()
return exit, cancel return exit, cancel
} }
func (r *NomadAllocation) Copy(files dto.FileCreation) { func (r *NomadAllocation) UpdateFileSystem(copyRequest *dto.UpdateFileSystemRequest) error {
var tarBuffer bytes.Buffer
if err := createTarArchiveForFiles(copyRequest.Copy, &tarBuffer); err != nil {
return err
}
fileDeletionCommand := fileDeletionCommand(copyRequest.Delete)
copyCommand := "tar --extract --absolute-names --verbose --directory=/ --file=/dev/stdin;"
updateFileCommand := (&dto.ExecutionRequest{Command: fileDeletionCommand + copyCommand}).FullCommand()
stdOut := bytes.Buffer{}
stdErr := bytes.Buffer{}
exitCode, err := r.api.ExecuteCommand(r.Id(), context.Background(), updateFileCommand, false,
&tarBuffer, &stdOut, &stdErr)
if err != nil {
return fmt.Errorf(
"%w: nomad error during file copy: %v",
nomad.ErrorExecutorCommunicationFailed,
err)
}
if exitCode != 0 {
return fmt.Errorf(
"%w: stderr output '%s' and stdout output '%s'",
ErrorFileCopyFailed,
stdErr.String(),
stdOut.String())
}
return nil
}
func createTarArchiveForFiles(filesToCopy []dto.File, w io.Writer) error {
tarWriter := tar.NewWriter(w)
for _, file := range filesToCopy {
if err := tarWriter.WriteHeader(tarHeader(file)); err != nil {
log.
WithError(err).
WithField("file", file).
Error("Error writing tar file header")
return err
}
if _, err := tarWriter.Write(file.ByteContent()); err != nil {
log.
WithError(err).
WithField("file", file).
Error("Error writing tar file content")
return err
}
}
return tarWriter.Close()
}
func fileDeletionCommand(filesToDelete []dto.FilePath) string {
if len(filesToDelete) == 0 {
return ""
}
command := "rm --recursive --force "
for _, filePath := range filesToDelete {
// To avoid command injection, filenames need to be quoted.
// See https://unix.stackexchange.com/questions/347332/what-characters-need-to-be-escaped-in-files-without-quotes for details.
singleQuoteEscapedFileName := strings.ReplaceAll(filePath.ToAbsolute(FileCopyBasePath), "'", "'\\''")
command += fmt.Sprintf("'%s' ", singleQuoteEscapedFileName)
}
command += ";"
return command
}
func tarHeader(file dto.File) *tar.Header {
if file.IsDirectory() {
return &tar.Header{
Typeflag: tar.TypeDir,
Name: file.AbsolutePath(FileCopyBasePath),
Mode: 0755,
}
} else {
return &tar.Header{
Typeflag: tar.TypeReg,
Name: file.AbsolutePath(FileCopyBasePath),
Mode: 0744,
Size: int64(len(file.Content)),
}
}
} }
// MarshalJSON implements json.Marshaler interface. // MarshalJSON implements json.Marshaler interface.

View File

@ -1,4 +1,4 @@
// Code generated by mockery v0.0.0-dev. DO NOT EDIT. // Code generated by mockery v2.8.0. DO NOT EDIT.
package runner package runner
@ -21,18 +21,8 @@ func (_m *RunnerMock) Add(id ExecutionId, executionRequest *dto.ExecutionRequest
_m.Called(id, executionRequest) _m.Called(id, executionRequest)
} }
// Copy provides a mock function with given fields: _a0 // ExecuteInteractively provides a mock function with given fields: request, stdin, stdout, stderr
func (_m *RunnerMock) Copy(_a0 dto.FileCreation) { func (_m *RunnerMock) ExecuteInteractively(request *dto.ExecutionRequest, stdin io.Reader, stdout io.Writer, stderr io.Writer) (<-chan ExitInfo, context.CancelFunc) {
_m.Called(_a0)
}
// Delete provides a mock function with given fields: id
func (_m *RunnerMock) Delete(id ExecutionId) {
_m.Called(id)
}
// Execute provides a mock function with given fields: request, stdin, stdout, stderr
func (_m *RunnerMock) Execute(request *dto.ExecutionRequest, stdin io.Reader, stdout io.Writer, stderr io.Writer) (<-chan ExitInfo, context.CancelFunc) {
ret := _m.Called(request, stdin, stdout, stderr) ret := _m.Called(request, stdin, stdout, stderr)
var r0 <-chan ExitInfo var r0 <-chan ExitInfo
@ -56,8 +46,22 @@ func (_m *RunnerMock) Execute(request *dto.ExecutionRequest, stdin io.Reader, st
return r0, r1 return r0, r1
} }
// Get provides a mock function with given fields: id // Id provides a mock function with given fields:
func (_m *RunnerMock) Get(id ExecutionId) (*dto.ExecutionRequest, bool) { func (_m *RunnerMock) Id() string {
ret := _m.Called()
var r0 string
if rf, ok := ret.Get(0).(func() string); ok {
r0 = rf()
} else {
r0 = ret.Get(0).(string)
}
return r0
}
// Pop provides a mock function with given fields: id
func (_m *RunnerMock) Pop(id ExecutionId) (*dto.ExecutionRequest, bool) {
ret := _m.Called(id) ret := _m.Called(id)
var r0 *dto.ExecutionRequest var r0 *dto.ExecutionRequest
@ -79,15 +83,15 @@ func (_m *RunnerMock) Get(id ExecutionId) (*dto.ExecutionRequest, bool) {
return r0, r1 return r0, r1
} }
// Id provides a mock function with given fields: // UpdateFileSystem provides a mock function with given fields: request
func (_m *RunnerMock) Id() string { func (_m *RunnerMock) UpdateFileSystem(request *dto.UpdateFileSystemRequest) error {
ret := _m.Called() ret := _m.Called(request)
var r0 string var r0 error
if rf, ok := ret.Get(0).(func() string); ok { if rf, ok := ret.Get(0).(func(*dto.UpdateFileSystemRequest) error); ok {
r0 = rf() r0 = rf(request)
} else { } else {
r0 = ret.Get(0).(string) r0 = ret.Error(0)
} }
return r0 return r0

View File

@ -1,12 +1,20 @@
package runner package runner
import ( import (
"archive/tar"
"bytes"
"context" "context"
"encoding/json" "encoding/json"
"fmt"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/mock" "github.com/stretchr/testify/mock"
"github.com/stretchr/testify/suite"
"gitlab.hpi.de/codeocean/codemoon/poseidon/api/dto" "gitlab.hpi.de/codeocean/codemoon/poseidon/api/dto"
"gitlab.hpi.de/codeocean/codemoon/poseidon/nomad" "gitlab.hpi.de/codeocean/codemoon/poseidon/nomad"
"gitlab.hpi.de/codeocean/codemoon/poseidon/tests"
"io"
"regexp"
"strings"
"testing" "testing"
"time" "time"
) )
@ -66,42 +74,42 @@ func TestFromContextReturnsIsNotOkWhenContextHasNoRunner(t *testing.T) {
func TestExecuteCallsAPI(t *testing.T) { func TestExecuteCallsAPI(t *testing.T) {
apiMock := &nomad.ExecutorApiMock{} apiMock := &nomad.ExecutorApiMock{}
apiMock.On("ExecuteCommand", mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(0, nil) apiMock.On("ExecuteCommand", mock.Anything, mock.Anything, mock.Anything, true, mock.Anything, mock.Anything, mock.Anything).Return(0, nil)
runner := NewNomadAllocation("testRunner", apiMock) runner := NewNomadAllocation(tests.DefaultRunnerId, apiMock)
request := &dto.ExecutionRequest{Command: "echo 'Hello World!'"} request := &dto.ExecutionRequest{Command: "echo 'Hello World!'"}
runner.Execute(request, nil, nil, nil) runner.ExecuteInteractively(request, nil, nil, nil)
<-time.After(50 * time.Millisecond) <-time.After(50 * time.Millisecond)
apiMock.AssertCalled(t, "ExecuteCommand", "testRunner", mock.Anything, request.FullCommand(), mock.Anything, mock.Anything, mock.Anything) apiMock.AssertCalled(t, "ExecuteCommand", tests.DefaultRunnerId, mock.Anything, request.FullCommand(), true, mock.Anything, mock.Anything, mock.Anything)
} }
func TestExecuteReturnsAfterTimeout(t *testing.T) { func TestExecuteReturnsAfterTimeout(t *testing.T) {
apiMock := newApiMockWithTimeLimitHandling() apiMock := newApiMockWithTimeLimitHandling()
runner := NewNomadAllocation("testRunner", apiMock) runner := NewNomadAllocation(tests.DefaultRunnerId, apiMock)
timeLimit := 1 timeLimit := 1
execution := &dto.ExecutionRequest{TimeLimit: timeLimit} execution := &dto.ExecutionRequest{TimeLimit: timeLimit}
exit, _ := runner.Execute(execution, nil, nil, nil) exit, _ := runner.ExecuteInteractively(execution, nil, nil, nil)
select { select {
case <-exit: case <-exit:
assert.FailNow(t, "Execute should not terminate instantly") assert.FailNow(t, "ExecuteInteractively should not terminate instantly")
case <-time.After(50 * time.Millisecond): case <-time.After(50 * time.Millisecond):
} }
select { select {
case <-time.After(time.Duration(timeLimit) * time.Second): case <-time.After(time.Duration(timeLimit) * time.Second):
assert.FailNow(t, "Execute should return after the time limit") assert.FailNow(t, "ExecuteInteractively should return after the time limit")
case exitCode := <-exit: case exitInfo := <-exit:
assert.Equal(t, uint8(0), exitCode.Code) assert.Equal(t, uint8(0), exitInfo.Code)
} }
} }
func newApiMockWithTimeLimitHandling() (apiMock *nomad.ExecutorApiMock) { func newApiMockWithTimeLimitHandling() (apiMock *nomad.ExecutorApiMock) {
apiMock = &nomad.ExecutorApiMock{} apiMock = &nomad.ExecutorApiMock{}
apiMock. apiMock.
On("ExecuteCommand", mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything). On("ExecuteCommand", mock.Anything, mock.Anything, mock.Anything, true, mock.Anything, mock.Anything, mock.Anything).
Run(func(args mock.Arguments) { Run(func(args mock.Arguments) {
ctx := args.Get(1).(context.Context) ctx := args.Get(1).(context.Context)
<-ctx.Done() <-ctx.Done()
@ -109,3 +117,138 @@ func newApiMockWithTimeLimitHandling() (apiMock *nomad.ExecutorApiMock) {
Return(0, nil) Return(0, nil)
return return
} }
func TestUpdateFileSystemTestSuite(t *testing.T) {
suite.Run(t, new(UpdateFileSystemTestSuite))
}
type UpdateFileSystemTestSuite struct {
suite.Suite
runner *NomadAllocation
apiMock *nomad.ExecutorApiMock
mockedExecuteCommandCall *mock.Call
command []string
stdin *bytes.Buffer
}
func (s *UpdateFileSystemTestSuite) SetupTest() {
s.apiMock = &nomad.ExecutorApiMock{}
s.runner = NewNomadAllocation(tests.DefaultRunnerId, s.apiMock)
s.mockedExecuteCommandCall = s.apiMock.On("ExecuteCommand", tests.DefaultRunnerId, mock.Anything, mock.Anything, false, mock.Anything, mock.Anything, mock.Anything).
Run(func(args mock.Arguments) {
s.command = args.Get(2).([]string)
s.stdin = args.Get(4).(*bytes.Buffer)
})
}
func (s *UpdateFileSystemTestSuite) TestUpdateFileSystemForRunnerPerformsTarExtractionWithAbsoluteNamesOnRunner() {
// note: this method tests an implementation detail of the method UpdateFileSystemOfRunner method
// if the implementation changes, delete this test and write a new one
s.mockedExecuteCommandCall.Return(0, nil)
copyRequest := &dto.UpdateFileSystemRequest{}
err := s.runner.UpdateFileSystem(copyRequest)
s.NoError(err)
s.apiMock.AssertCalled(s.T(), "ExecuteCommand", mock.Anything, mock.Anything, mock.Anything, false, mock.Anything, mock.Anything, mock.Anything)
s.Regexp("tar --extract --absolute-names", s.command)
}
func (s *UpdateFileSystemTestSuite) TestUpdateFileSystemForRunnerReturnsErrorIfExitCodeIsNotZero() {
s.mockedExecuteCommandCall.Return(1, nil)
copyRequest := &dto.UpdateFileSystemRequest{}
err := s.runner.UpdateFileSystem(copyRequest)
s.ErrorIs(err, ErrorFileCopyFailed)
}
func (s *UpdateFileSystemTestSuite) TestUpdateFileSystemForRunnerReturnsErrorIfApiCallDid() {
s.mockedExecuteCommandCall.Return(0, tests.DefaultError)
copyRequest := &dto.UpdateFileSystemRequest{}
err := s.runner.UpdateFileSystem(copyRequest)
s.ErrorIs(err, nomad.ErrorExecutorCommunicationFailed)
}
func (s *UpdateFileSystemTestSuite) TestFilesToCopyAreIncludedInTarArchive() {
s.mockedExecuteCommandCall.Return(0, nil)
copyRequest := &dto.UpdateFileSystemRequest{Copy: []dto.File{{Path: tests.DefaultFileName, Content: []byte(tests.DefaultFileContent)}}}
err := s.runner.UpdateFileSystem(copyRequest)
s.NoError(err)
s.apiMock.AssertCalled(s.T(), "ExecuteCommand", mock.Anything, mock.Anything, mock.Anything, false, mock.Anything, mock.Anything, mock.Anything)
tarFiles := s.readFilesFromTarArchive(s.stdin)
s.Len(tarFiles, 1)
tarFile := tarFiles[0]
s.True(strings.HasSuffix(tarFile.Name, tests.DefaultFileName))
s.Equal(byte(tar.TypeReg), tarFile.TypeFlag)
s.Equal(tests.DefaultFileContent, tarFile.Content)
}
func (s *UpdateFileSystemTestSuite) TestFilesWithRelativePathArePutInDefaultLocation() {
s.mockedExecuteCommandCall.Return(0, nil)
copyRequest := &dto.UpdateFileSystemRequest{Copy: []dto.File{{Path: tests.DefaultFileName, Content: []byte(tests.DefaultFileContent)}}}
_ = s.runner.UpdateFileSystem(copyRequest)
tarFiles := s.readFilesFromTarArchive(s.stdin)
s.Len(tarFiles, 1)
tarFile := tarFiles[0]
s.True(strings.HasSuffix(tarFile.Name, tests.DefaultFileName))
s.True(strings.HasPrefix(tarFile.Name, FileCopyBasePath))
}
func (s *UpdateFileSystemTestSuite) TestFilesWithAbsolutePathArePutInAbsoluteLocation() {
s.mockedExecuteCommandCall.Return(0, nil)
copyRequest := &dto.UpdateFileSystemRequest{Copy: []dto.File{{Path: tests.FileNameWithAbsolutePath, Content: []byte(tests.DefaultFileContent)}}}
_ = s.runner.UpdateFileSystem(copyRequest)
tarFiles := s.readFilesFromTarArchive(s.stdin)
s.Len(tarFiles, 1)
s.Equal(tarFiles[0].Name, tests.FileNameWithAbsolutePath)
}
func (s *UpdateFileSystemTestSuite) TestDirectoriesAreMarkedAsDirectoryInTar() {
s.mockedExecuteCommandCall.Return(0, nil)
copyRequest := &dto.UpdateFileSystemRequest{Copy: []dto.File{{Path: tests.DefaultDirectoryName, Content: []byte{}}}}
_ = s.runner.UpdateFileSystem(copyRequest)
tarFiles := s.readFilesFromTarArchive(s.stdin)
s.Len(tarFiles, 1)
tarFile := tarFiles[0]
s.True(strings.HasSuffix(tarFile.Name+"/", tests.DefaultDirectoryName))
s.Equal(byte(tar.TypeDir), tarFile.TypeFlag)
s.Equal("", tarFile.Content)
}
func (s *UpdateFileSystemTestSuite) TestFilesToRemoveGetRemoved() {
s.mockedExecuteCommandCall.Return(0, nil)
copyRequest := &dto.UpdateFileSystemRequest{Delete: []dto.FilePath{tests.DefaultFileName}}
err := s.runner.UpdateFileSystem(copyRequest)
s.NoError(err)
s.apiMock.AssertCalled(s.T(), "ExecuteCommand", mock.Anything, mock.Anything, mock.Anything, false, mock.Anything, mock.Anything, mock.Anything)
s.Regexp(fmt.Sprintf("rm[^;]+%s' *;", regexp.QuoteMeta(tests.DefaultFileName)), s.command)
}
func (s *UpdateFileSystemTestSuite) TestFilesToRemoveGetEscaped() {
s.mockedExecuteCommandCall.Return(0, nil)
copyRequest := &dto.UpdateFileSystemRequest{Delete: []dto.FilePath{"/some/potentially/harmful'filename"}}
err := s.runner.UpdateFileSystem(copyRequest)
s.NoError(err)
s.apiMock.AssertCalled(s.T(), "ExecuteCommand", mock.Anything, mock.Anything, mock.Anything, false, mock.Anything, mock.Anything, mock.Anything)
s.Contains(strings.Join(s.command, " "), "'/some/potentially/harmful'\\''filename'")
}
type TarFile struct {
Name string
Content string
TypeFlag byte
}
func (s *UpdateFileSystemTestSuite) readFilesFromTarArchive(tarArchive io.Reader) (files []TarFile) {
reader := tar.NewReader(tarArchive)
for {
hdr, err := reader.Next()
if err != nil {
break
}
bf, _ := io.ReadAll(reader)
files = append(files, TarFile{Name: hdr.Name, Content: string(bf), TypeFlag: hdr.Typeflag})
}
return files
}

View File

@ -1,6 +1,13 @@
package tests package tests
import "errors"
const ( const (
NonExistingId = "n0n-3x1st1ng-1d"
DefaultFileName = "test.txt"
DefaultFileContent = "Hello, Codemoon!"
DefaultDirectoryName = "test/"
FileNameWithAbsolutePath = "/test.txt"
DefaultEnvironmentIdAsInteger = 0 DefaultEnvironmentIdAsInteger = 0
AnotherEnvironmentIdAsInteger = 42 AnotherEnvironmentIdAsInteger = 42
DefaultJobId = "s0m3-j0b-1d" DefaultJobId = "s0m3-j0b-1d"
@ -8,4 +15,9 @@ const (
DefaultRunnerId = "s0m3-r4nd0m-1d" DefaultRunnerId = "s0m3-r4nd0m-1d"
AnotherRunnerId = "4n0th3r-runn3r-1d" AnotherRunnerId = "4n0th3r-runn3r-1d"
DefaultExecutionId = "s0m3-3x3cu710n-1d" DefaultExecutionId = "s0m3-3x3cu710n-1d"
DefaultMockId = "m0ck-1d"
)
var (
DefaultError = errors.New("an error occured")
) )

View File

@ -21,7 +21,7 @@ type E2ETestSuite struct {
suite.Suite suite.Suite
} }
func (suite *E2ETestSuite) SetupTest() { func (s *E2ETestSuite) SetupTest() {
// Waiting one second before each test allows Nomad to rescale after tests requested runners. // Waiting one second before each test allows Nomad to rescale after tests requested runners.
<-time.After(time.Second) <-time.After(time.Second)
} }

View File

@ -3,13 +3,13 @@ package e2e
import ( import (
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"gitlab.hpi.de/codeocean/codemoon/poseidon/api" "gitlab.hpi.de/codeocean/codemoon/poseidon/api"
"gitlab.hpi.de/codeocean/codemoon/poseidon/tests/e2e/helpers" "gitlab.hpi.de/codeocean/codemoon/poseidon/tests/helpers"
"net/http" "net/http"
"testing" "testing"
) )
func TestHealthRoute(t *testing.T) { func TestHealthRoute(t *testing.T) {
resp, err := http.Get(helpers.BuildURL(api.RouteBase, api.RouteHealth)) resp, err := http.Get(helpers.BuildURL(api.BasePath, api.HealthPath))
if assert.NoError(t, err) { if assert.NoError(t, err) {
assert.Equal(t, http.StatusNoContent, resp.StatusCode, "The response code should be NoContent") assert.Equal(t, http.StatusNoContent, resp.StatusCode, "The response code should be NoContent")
} }

View File

@ -1,67 +1,170 @@
package e2e package e2e
import ( import (
"bytes"
"encoding/json" "encoding/json"
"github.com/stretchr/testify/assert" "fmt"
"github.com/gorilla/websocket"
"gitlab.hpi.de/codeocean/codemoon/poseidon/api" "gitlab.hpi.de/codeocean/codemoon/poseidon/api"
"gitlab.hpi.de/codeocean/codemoon/poseidon/api/dto" "gitlab.hpi.de/codeocean/codemoon/poseidon/api/dto"
"gitlab.hpi.de/codeocean/codemoon/poseidon/tests/e2e/helpers" "gitlab.hpi.de/codeocean/codemoon/poseidon/runner"
"gitlab.hpi.de/codeocean/codemoon/poseidon/tests"
"gitlab.hpi.de/codeocean/codemoon/poseidon/tests/helpers"
"io" "io"
"net/http" "net/http"
"strings" "strings"
"testing"
) )
func (suite *E2ETestSuite) TestProvideRunnerRoute() { func (s *E2ETestSuite) TestProvideRunnerRoute() {
runnerRequestString, _ := json.Marshal(dto.RunnerRequest{}) runnerRequestString, _ := json.Marshal(dto.RunnerRequest{})
reader := strings.NewReader(string(runnerRequestString)) reader := strings.NewReader(string(runnerRequestString))
resp, err := http.Post(helpers.BuildURL(api.RouteBase, api.RouteRunners), "application/json", reader) resp, err := http.Post(helpers.BuildURL(api.BasePath, api.RunnersPath), "application/json", reader)
suite.NoError(err) s.NoError(err)
suite.Equal(http.StatusOK, resp.StatusCode, "The response code should be ok") s.Equal(http.StatusOK, resp.StatusCode, "The response code should be ok")
runnerResponse := new(dto.RunnerResponse) runnerResponse := new(dto.RunnerResponse)
err = json.NewDecoder(resp.Body).Decode(runnerResponse) err = json.NewDecoder(resp.Body).Decode(runnerResponse)
suite.NoError(err) s.NoError(err)
suite.True(runnerResponse.Id != "", "The response contains a runner id") s.True(runnerResponse.Id != "", "The response contains a runner id")
} }
func newRunnerId(t *testing.T) string { // ProvideRunner creates a runner with the given RunnerRequest via an external request.
runnerRequestString, _ := json.Marshal(dto.RunnerRequest{}) // It needs a running Poseidon instance to work.
func ProvideRunner(request *dto.RunnerRequest) (string, error) {
url := helpers.BuildURL(api.BasePath, api.RunnersPath)
runnerRequestString, _ := json.Marshal(request)
reader := strings.NewReader(string(runnerRequestString)) reader := strings.NewReader(string(runnerRequestString))
resp, err := http.Post(helpers.BuildURL(api.RouteBase, api.RouteRunners), "application/json", reader) resp, err := http.Post(url, "application/json", reader)
assert.NoError(t, err) if err != nil {
return "", err
}
if resp.StatusCode != http.StatusOK {
return "", fmt.Errorf("expected response code 200 when getting runner, got %v", resp.StatusCode)
}
runnerResponse := new(dto.RunnerResponse) runnerResponse := new(dto.RunnerResponse)
_ = json.NewDecoder(resp.Body).Decode(runnerResponse) err = json.NewDecoder(resp.Body).Decode(runnerResponse)
return runnerResponse.Id if err != nil {
return "", err
}
return runnerResponse.Id, nil
} }
func (suite *E2ETestSuite) TestDeleteRunnerRoute() { func (s *E2ETestSuite) TestDeleteRunnerRoute() {
runnerId := newRunnerId(suite.T()) runnerId, err := ProvideRunner(&dto.RunnerRequest{})
suite.NotEqual("", runnerId) s.NoError(err)
suite.Run("Deleting the runner returns NoContent", func() { s.Run("Deleting the runner returns NoContent", func() {
resp, err := httpDelete(helpers.BuildURL(api.RouteBase, api.RouteRunners, "/", runnerId), nil) resp, err := helpers.HttpDelete(helpers.BuildURL(api.BasePath, api.RunnersPath, runnerId), nil)
suite.NoError(err) s.NoError(err)
suite.Equal(http.StatusNoContent, resp.StatusCode) s.Equal(http.StatusNoContent, resp.StatusCode)
}) })
suite.Run("Deleting it again returns NotFound", func() { s.Run("Deleting it again returns NotFound", func() {
resp, err := httpDelete(helpers.BuildURL(api.RouteBase, api.RouteRunners, "/", runnerId), nil) resp, err := helpers.HttpDelete(helpers.BuildURL(api.BasePath, api.RunnersPath, runnerId), nil)
suite.NoError(err) s.NoError(err)
suite.Equal(http.StatusNotFound, resp.StatusCode) s.Equal(http.StatusNotFound, resp.StatusCode)
}) })
suite.Run("Deleting non-existing runner returns NotFound", func() { s.Run("Deleting non-existing runner returns NotFound", func() {
resp, err := httpDelete(helpers.BuildURL(api.RouteBase, api.RouteRunners, "/", "n0n-3x1st1ng-1d"), nil) resp, err := helpers.HttpDelete(helpers.BuildURL(api.BasePath, api.RunnersPath, tests.NonExistingId), nil)
suite.NoError(err) s.NoError(err)
suite.Equal(http.StatusNotFound, resp.StatusCode) s.Equal(http.StatusNotFound, resp.StatusCode)
}) })
} }
// HttpDelete sends a Delete Http Request with body to the passed url. func (s *E2ETestSuite) TestCopyFilesRoute() {
func httpDelete(url string, body io.Reader) (response *http.Response, err error) { runnerId, err := ProvideRunner(&dto.RunnerRequest{})
req, _ := http.NewRequest(http.MethodDelete, url, body) s.NoError(err)
client := &http.Client{} copyFilesRequestByteString, _ := json.Marshal(&dto.UpdateFileSystemRequest{
return client.Do(req) Copy: []dto.File{{Path: tests.DefaultFileName, Content: []byte(tests.DefaultFileContent)}},
})
sendCopyRequest := func(reader io.Reader) (*http.Response, error) {
return helpers.HttpPatch(helpers.BuildURL(api.BasePath, api.RunnersPath, runnerId, api.UpdateFileSystemPath), "application/json", reader)
}
s.Run("File copy with valid payload succeeds", func() {
resp, err := sendCopyRequest(bytes.NewReader(copyFilesRequestByteString))
s.NoError(err)
s.Equal(http.StatusNoContent, resp.StatusCode)
s.Run("File content can be printed on runner", func() {
s.Equal(tests.DefaultFileContent, s.ContentOfFileOnRunner(runnerId, tests.DefaultFileName))
})
})
s.Run("File deletion request deletes file on runner", func() {
copyFilesRequestByteString, _ := json.Marshal(&dto.UpdateFileSystemRequest{
Delete: []dto.FilePath{tests.DefaultFileName},
})
resp, err := sendCopyRequest(bytes.NewReader(copyFilesRequestByteString))
s.NoError(err)
s.Equal(http.StatusNoContent, resp.StatusCode)
s.Run("File content can no longer be printed", func() {
s.Contains(s.ContentOfFileOnRunner(runnerId, tests.DefaultFileName), "No such file or directory")
})
})
s.Run("File copy happens after file deletion", func() {
copyFilesRequestByteString, _ := json.Marshal(&dto.UpdateFileSystemRequest{
Delete: []dto.FilePath{tests.DefaultFileName},
Copy: []dto.File{{Path: tests.DefaultFileName, Content: []byte(tests.DefaultFileContent)}},
})
resp, err := sendCopyRequest(bytes.NewReader(copyFilesRequestByteString))
s.NoError(err)
s.Equal(http.StatusNoContent, resp.StatusCode)
s.Run("File content can be printed on runner", func() {
s.Equal(tests.DefaultFileContent, s.ContentOfFileOnRunner(runnerId, tests.DefaultFileName))
})
})
s.Run("If one file produces permission denied error, others are still copied", func() {
newFileContent := []byte("New content")
copyFilesRequestByteString, _ := json.Marshal(&dto.UpdateFileSystemRequest{
Copy: []dto.File{
{Path: "/dev/sda", Content: []byte(tests.DefaultFileContent)},
{Path: tests.DefaultFileName, Content: newFileContent},
},
})
resp, err := sendCopyRequest(bytes.NewReader(copyFilesRequestByteString))
s.NoError(err)
s.Equal(http.StatusInternalServerError, resp.StatusCode)
internalServerError := new(dto.InternalServerError)
err = json.NewDecoder(resp.Body).Decode(internalServerError)
s.NoError(err)
s.Contains(internalServerError.Message, "Cannot open: Permission denied")
s.Run("File content can be printed on runner", func() {
s.Equal(string(newFileContent), s.ContentOfFileOnRunner(runnerId, tests.DefaultFileName))
})
})
s.Run("File copy with invalid payload returns bad request", func() {
resp, err := helpers.HttpPatch(helpers.BuildURL(api.BasePath, api.RunnersPath, runnerId, api.UpdateFileSystemPath), "text/html", strings.NewReader(""))
s.NoError(err)
s.Equal(http.StatusBadRequest, resp.StatusCode)
})
s.Run("Copying to non-existing runner returns NotFound", func() {
resp, err := helpers.HttpPatch(helpers.BuildURL(api.BasePath, api.RunnersPath, tests.NonExistingId, api.UpdateFileSystemPath), "application/json", bytes.NewReader(copyFilesRequestByteString))
s.NoError(err)
s.Equal(http.StatusNotFound, resp.StatusCode)
})
}
func (s *E2ETestSuite) ContentOfFileOnRunner(runnerId string, filename string) string {
webSocketURL, _ := ProvideWebSocketURL(&s.Suite, runnerId, &dto.ExecutionRequest{Command: fmt.Sprintf("cat %s/%s", runner.FileCopyBasePath, filename)})
connection, _ := ConnectToWebSocket(webSocketURL)
messages, err := helpers.ReceiveAllWebSocketMessages(connection)
s.Require().Error(err)
s.Equal(&websocket.CloseError{Code: websocket.CloseNormalClosure}, err)
stdout, _, _ := helpers.WebSocketOutputMessages(messages)
return stdout
} }

View File

@ -7,25 +7,25 @@ import (
"github.com/stretchr/testify/suite" "github.com/stretchr/testify/suite"
"gitlab.hpi.de/codeocean/codemoon/poseidon/api" "gitlab.hpi.de/codeocean/codemoon/poseidon/api"
"gitlab.hpi.de/codeocean/codemoon/poseidon/api/dto" "gitlab.hpi.de/codeocean/codemoon/poseidon/api/dto"
"gitlab.hpi.de/codeocean/codemoon/poseidon/tests/e2e/helpers" "gitlab.hpi.de/codeocean/codemoon/poseidon/tests/helpers"
"net/http" "net/http"
"strings" "strings"
"time" "time"
) )
func (suite *E2ETestSuite) TestExecuteCommandRoute() { func (s *E2ETestSuite) TestExecuteCommandRoute() {
runnerId, err := ProvideRunner(&suite.Suite, &dto.RunnerRequest{}) runnerId, err := ProvideRunner(&dto.RunnerRequest{})
suite.Require().NoError(err) s.Require().NoError(err)
webSocketURL, err := ProvideWebSocketURL(&suite.Suite, runnerId, &dto.ExecutionRequest{Command: "true"}) webSocketURL, err := ProvideWebSocketURL(&s.Suite, runnerId, &dto.ExecutionRequest{Command: "true"})
suite.Require().NoError(err) s.Require().NoError(err)
suite.NotEqual("", webSocketURL) s.NotEqual("", webSocketURL)
var connection *websocket.Conn var connection *websocket.Conn
var connectionClosed bool var connectionClosed bool
connection, err = ConnectToWebSocket(webSocketURL) connection, err = ConnectToWebSocket(webSocketURL)
suite.Require().NoError(err, "websocket connects") s.Require().NoError(err, "websocket connects")
closeHandler := connection.CloseHandler() closeHandler := connection.CloseHandler()
connection.SetCloseHandler(func(code int, text string) error { connection.SetCloseHandler(func(code int, text string) error {
connectionClosed = true connectionClosed = true
@ -33,119 +33,119 @@ func (suite *E2ETestSuite) TestExecuteCommandRoute() {
}) })
startMessage, err := helpers.ReceiveNextWebSocketMessage(connection) startMessage, err := helpers.ReceiveNextWebSocketMessage(connection)
suite.Require().NoError(err) s.Require().NoError(err)
suite.Equal(&dto.WebSocketMessage{Type: dto.WebSocketMetaStart}, startMessage) s.Equal(&dto.WebSocketMessage{Type: dto.WebSocketMetaStart}, startMessage)
exitMessage, err := helpers.ReceiveNextWebSocketMessage(connection) exitMessage, err := helpers.ReceiveNextWebSocketMessage(connection)
suite.Require().NoError(err) s.Require().NoError(err)
suite.Equal(&dto.WebSocketMessage{Type: dto.WebSocketExit}, exitMessage) s.Equal(&dto.WebSocketMessage{Type: dto.WebSocketExit}, exitMessage)
_, err = helpers.ReceiveAllWebSocketMessages(connection) _, err = helpers.ReceiveAllWebSocketMessages(connection)
suite.Require().Error(err) s.Require().Error(err)
suite.Equal(&websocket.CloseError{Code: websocket.CloseNormalClosure}, err) s.Equal(&websocket.CloseError{Code: websocket.CloseNormalClosure}, err)
_, _, _ = connection.ReadMessage() _, _, _ = connection.ReadMessage()
suite.True(connectionClosed, "connection should be closed") s.True(connectionClosed, "connection should be closed")
} }
func (suite *E2ETestSuite) TestOutputToStdout() { func (s *E2ETestSuite) TestOutputToStdout() {
connection, err := ProvideWebSocketConnection(&suite.Suite, &dto.ExecutionRequest{Command: "echo Hello World"}) connection, err := ProvideWebSocketConnection(&s.Suite, &dto.ExecutionRequest{Command: "echo Hello World"})
suite.Require().NoError(err) s.Require().NoError(err)
messages, err := helpers.ReceiveAllWebSocketMessages(connection) messages, err := helpers.ReceiveAllWebSocketMessages(connection)
suite.Require().Error(err) s.Require().Error(err)
suite.Equal(&websocket.CloseError{Code: websocket.CloseNormalClosure}, err) s.Equal(&websocket.CloseError{Code: websocket.CloseNormalClosure}, err)
suite.Require().Equal(&dto.WebSocketMessage{Type: dto.WebSocketMetaStart}, messages[0]) s.Require().Equal(&dto.WebSocketMessage{Type: dto.WebSocketMetaStart}, messages[0])
suite.Require().Equal(&dto.WebSocketMessage{Type: dto.WebSocketOutputStdout, Data: "Hello World\r\n"}, messages[1]) s.Require().Equal(&dto.WebSocketMessage{Type: dto.WebSocketOutputStdout, Data: "Hello World\r\n"}, messages[1])
suite.Require().Equal(&dto.WebSocketMessage{Type: dto.WebSocketExit}, messages[2]) s.Require().Equal(&dto.WebSocketMessage{Type: dto.WebSocketExit}, messages[2])
} }
func (suite *E2ETestSuite) TestOutputToStderr() { func (s *E2ETestSuite) TestOutputToStderr() {
suite.T().Skip("known bug causing all output to be written to stdout (even if it should be written to stderr)") s.T().Skip("known bug causing all output to be written to stdout (even if it should be written to stderr)")
connection, err := ProvideWebSocketConnection(&suite.Suite, &dto.ExecutionRequest{Command: "cat -invalid"}) connection, err := ProvideWebSocketConnection(&s.Suite, &dto.ExecutionRequest{Command: "cat -invalid"})
suite.Require().NoError(err) s.Require().NoError(err)
messages, err := helpers.ReceiveAllWebSocketMessages(connection) messages, err := helpers.ReceiveAllWebSocketMessages(connection)
suite.Require().Error(err) s.Require().Error(err)
suite.Equal(&websocket.CloseError{Code: websocket.CloseNormalClosure}, err) s.Equal(&websocket.CloseError{Code: websocket.CloseNormalClosure}, err)
controlMessages := helpers.WebSocketControlMessages(messages) controlMessages := helpers.WebSocketControlMessages(messages)
suite.Require().Equal(&dto.WebSocketMessage{Type: dto.WebSocketMetaStart}, controlMessages[0]) s.Require().Equal(&dto.WebSocketMessage{Type: dto.WebSocketMetaStart}, controlMessages[0])
suite.Require().Equal(&dto.WebSocketMessage{Type: dto.WebSocketExit}, controlMessages[1]) s.Require().Equal(&dto.WebSocketMessage{Type: dto.WebSocketExit}, controlMessages[1])
stdout, stderr, errors := helpers.WebSocketOutputMessages(messages) stdout, stderr, errors := helpers.WebSocketOutputMessages(messages)
suite.NotContains(stdout, "cat: invalid option", "Stdout should not contain the error") s.NotContains(stdout, "cat: invalid option", "Stdout should not contain the error")
suite.Contains(stderr, "cat: invalid option", "Stderr should contain the error") s.Contains(stderr, "cat: invalid option", "Stderr should contain the error")
suite.Empty(errors) s.Empty(errors)
} }
func (suite *E2ETestSuite) TestCommandHead() { func (s *E2ETestSuite) TestCommandHead() {
hello := "Hello World!" hello := "Hello World!"
connection, err := ProvideWebSocketConnection(&suite.Suite, &dto.ExecutionRequest{Command: "head -n 1"}) connection, err := ProvideWebSocketConnection(&s.Suite, &dto.ExecutionRequest{Command: "head -n 1"})
suite.Require().NoError(err) s.Require().NoError(err)
startMessage, err := helpers.ReceiveNextWebSocketMessage(connection) startMessage, err := helpers.ReceiveNextWebSocketMessage(connection)
suite.Require().NoError(err) s.Require().NoError(err)
suite.Equal(dto.WebSocketMetaStart, startMessage.Type) s.Equal(dto.WebSocketMetaStart, startMessage.Type)
err = connection.WriteMessage(websocket.TextMessage, []byte(fmt.Sprintf("%s\n", hello))) err = connection.WriteMessage(websocket.TextMessage, []byte(fmt.Sprintf("%s\n", hello)))
suite.Require().NoError(err) s.Require().NoError(err)
messages, err := helpers.ReceiveAllWebSocketMessages(connection) messages, err := helpers.ReceiveAllWebSocketMessages(connection)
suite.Require().Error(err) s.Require().Error(err)
suite.Equal(err, &websocket.CloseError{Code: websocket.CloseNormalClosure}) s.Equal(err, &websocket.CloseError{Code: websocket.CloseNormalClosure})
stdout, _, _ := helpers.WebSocketOutputMessages(messages) stdout, _, _ := helpers.WebSocketOutputMessages(messages)
suite.Contains(stdout, fmt.Sprintf("%s\r\n%s\r\n", hello, hello)) s.Contains(stdout, fmt.Sprintf("%s\r\n%s\r\n", hello, hello))
} }
func (suite *E2ETestSuite) TestCommandReturnsAfterTimeout() { func (s *E2ETestSuite) TestCommandReturnsAfterTimeout() {
connection, err := ProvideWebSocketConnection(&suite.Suite, &dto.ExecutionRequest{Command: "sleep 4", TimeLimit: 1}) connection, err := ProvideWebSocketConnection(&s.Suite, &dto.ExecutionRequest{Command: "sleep 4", TimeLimit: 1})
suite.Require().NoError(err) s.Require().NoError(err)
c := make(chan bool) c := make(chan bool)
var messages []*dto.WebSocketMessage var messages []*dto.WebSocketMessage
go func() { go func() {
messages, err = helpers.ReceiveAllWebSocketMessages(connection) messages, err = helpers.ReceiveAllWebSocketMessages(connection)
if !suite.Equal(&websocket.CloseError{Code: websocket.CloseNormalClosure}, err) { if !s.Equal(&websocket.CloseError{Code: websocket.CloseNormalClosure}, err) {
suite.T().Fail() s.T().Fail()
} }
close(c) close(c)
}() }()
select { select {
case <-time.After(2 * time.Second): case <-time.After(2 * time.Second):
suite.T().Fatal("The execution should have returned by now") s.T().Fatal("The execution should have returned by now")
case <-c: case <-c:
if suite.Equal(&dto.WebSocketMessage{Type: dto.WebSocketMetaTimeout}, messages[len(messages)-1]) { if s.Equal(&dto.WebSocketMessage{Type: dto.WebSocketMetaTimeout}, messages[len(messages)-1]) {
return return
} }
} }
suite.T().Fail() s.T().Fail()
} }
func (suite *E2ETestSuite) TestEchoEnvironment() { func (s *E2ETestSuite) TestEchoEnvironment() {
connection, err := ProvideWebSocketConnection(&suite.Suite, &dto.ExecutionRequest{ connection, err := ProvideWebSocketConnection(&s.Suite, &dto.ExecutionRequest{
Command: "echo $hello", Command: "echo $hello",
Environment: map[string]string{"hello": "world"}, Environment: map[string]string{"hello": "world"},
}) })
suite.Require().NoError(err) s.Require().NoError(err)
startMessage, err := helpers.ReceiveNextWebSocketMessage(connection) startMessage, err := helpers.ReceiveNextWebSocketMessage(connection)
suite.Require().NoError(err) s.Require().NoError(err)
suite.Equal(dto.WebSocketMetaStart, startMessage.Type) s.Equal(dto.WebSocketMetaStart, startMessage.Type)
messages, err := helpers.ReceiveAllWebSocketMessages(connection) messages, err := helpers.ReceiveAllWebSocketMessages(connection)
suite.Require().Error(err) s.Require().Error(err)
suite.Equal(err, &websocket.CloseError{Code: websocket.CloseNormalClosure}) s.Equal(err, &websocket.CloseError{Code: websocket.CloseNormalClosure})
stdout, _, _ := helpers.WebSocketOutputMessages(messages) stdout, _, _ := helpers.WebSocketOutputMessages(messages)
suite.Equal("world\r\n", stdout) s.Equal("world\r\n", stdout)
} }
// ProvideWebSocketConnection establishes a client WebSocket connection to run the passed ExecutionRequest. // ProvideWebSocketConnection establishes a client WebSocket connection to run the passed ExecutionRequest.
// It requires a running Poseidon instance. // It requires a running Poseidon instance.
func ProvideWebSocketConnection(suite *suite.Suite, request *dto.ExecutionRequest) (connection *websocket.Conn, err error) { func ProvideWebSocketConnection(suite *suite.Suite, request *dto.ExecutionRequest) (connection *websocket.Conn, err error) {
runnerId, err := ProvideRunner(suite, &dto.RunnerRequest{}) runnerId, err := ProvideRunner(&dto.RunnerRequest{})
if err != nil { if err != nil {
return return
} }
@ -157,27 +157,10 @@ func ProvideWebSocketConnection(suite *suite.Suite, request *dto.ExecutionReques
return return
} }
// ProvideRunner creates a runner with the given RunnerRequest via an external request.
// It needs a running Poseidon instance to work.
func ProvideRunner(suite *suite.Suite, request *dto.RunnerRequest) (string, error) {
url := helpers.BuildURL(api.RouteBase, api.RouteRunners)
runnerRequestBytes, _ := json.Marshal(request)
reader := strings.NewReader(string(runnerRequestBytes))
resp, err := http.Post(url, "application/json", reader)
suite.Require().NoError(err)
suite.Require().Equal(http.StatusOK, resp.StatusCode)
runnerResponse := new(dto.RunnerResponse)
err = json.NewDecoder(resp.Body).Decode(runnerResponse)
suite.Require().NoError(err)
return runnerResponse.Id, nil
}
// ProvideWebSocketURL creates a WebSocket endpoint from the ExecutionRequest via an external api request. // ProvideWebSocketURL creates a WebSocket endpoint from the ExecutionRequest via an external api request.
// It requires a running Poseidon instance. // It requires a running Poseidon instance.
func ProvideWebSocketURL(suite *suite.Suite, runnerId string, request *dto.ExecutionRequest) (string, error) { func ProvideWebSocketURL(suite *suite.Suite, runnerId string, request *dto.ExecutionRequest) (string, error) {
url := helpers.BuildURL(api.RouteBase, api.RouteRunners, "/", runnerId, api.ExecutePath) url := helpers.BuildURL(api.BasePath, api.RunnersPath, runnerId, api.ExecutePath)
executionRequestBytes, _ := json.Marshal(request) executionRequestBytes, _ := json.Marshal(request)
reader := strings.NewReader(string(executionRequestBytes)) reader := strings.NewReader(string(executionRequestBytes))
resp, err := http.Post(url, "application/json", reader) resp, err := http.Post(url, "application/json", reader)

View File

@ -12,8 +12,8 @@ import (
"gitlab.hpi.de/codeocean/codemoon/poseidon/api/dto" "gitlab.hpi.de/codeocean/codemoon/poseidon/api/dto"
"gitlab.hpi.de/codeocean/codemoon/poseidon/config" "gitlab.hpi.de/codeocean/codemoon/poseidon/config"
"gitlab.hpi.de/codeocean/codemoon/poseidon/nomad" "gitlab.hpi.de/codeocean/codemoon/poseidon/nomad"
"gitlab.hpi.de/codeocean/codemoon/poseidon/runner"
"io" "io"
"net/http"
"net/http/httptest" "net/http/httptest"
"os/exec" "os/exec"
"path/filepath" "path/filepath"
@ -23,8 +23,14 @@ import (
// BuildURL joins multiple route paths. // BuildURL joins multiple route paths.
func BuildURL(parts ...string) (url string) { func BuildURL(parts ...string) (url string) {
parts = append([]string{config.Config.PoseidonAPIURL().String()}, parts...) url = config.Config.PoseidonAPIURL().String()
return strings.Join(parts, "") for _, part := range parts {
if !strings.HasPrefix(part, "/") {
url += "/"
}
url += part
}
return
} }
// WebSocketOutputMessages extracts all stdout, stderr and error messages from the passed messages. // WebSocketOutputMessages extracts all stdout, stderr and error messages from the passed messages.
@ -105,20 +111,15 @@ func StartTLSServer(t *testing.T, router *mux.Router) (server *httptest.Server,
return return
} }
func NewNomadAllocationWithMockedApiClient(runnerId string) (r runner.Runner, mock *nomad.ExecutorApiMock) {
mock = &nomad.ExecutorApiMock{}
r = runner.NewNomadAllocation(runnerId, mock)
return
}
// MockApiExecute mocks the ExecuteCommand method of an ExecutorApi to call the given method run when the command // MockApiExecute mocks the ExecuteCommand method of an ExecutorApi to call the given method run when the command
// corresponding to the given ExecutionRequest is called. // corresponding to the given ExecutionRequest is called.
func MockApiExecute(api *nomad.ExecutorApiMock, request *dto.ExecutionRequest, func MockApiExecute(api *nomad.ExecutorApiMock, request *dto.ExecutionRequest,
run func(runnerId string, ctx context.Context, command []string, stdin io.Reader, stdout, stderr io.Writer) (int, error)) { run func(runnerId string, ctx context.Context, command []string, tty bool, stdin io.Reader, stdout, stderr io.Writer) (int, error)) {
call := api.On("ExecuteCommand", call := api.On("ExecuteCommand",
mock.AnythingOfType("string"), mock.AnythingOfType("string"),
mock.Anything, mock.Anything,
request.FullCommand(), request.FullCommand(),
mock.AnythingOfType("bool"),
mock.Anything, mock.Anything,
mock.Anything, mock.Anything,
mock.Anything) mock.Anything)
@ -126,9 +127,25 @@ func MockApiExecute(api *nomad.ExecutorApiMock, request *dto.ExecutionRequest,
exit, err := run(args.Get(0).(string), exit, err := run(args.Get(0).(string),
args.Get(1).(context.Context), args.Get(1).(context.Context),
args.Get(2).([]string), args.Get(2).([]string),
args.Get(3).(io.Reader), args.Get(3).(bool),
args.Get(4).(io.Writer), args.Get(4).(io.Reader),
args.Get(5).(io.Writer)) args.Get(5).(io.Writer),
args.Get(6).(io.Writer))
call.ReturnArguments = mock.Arguments{exit, err} call.ReturnArguments = mock.Arguments{exit, err}
}) })
} }
// HttpDelete sends a Delete Http Request with body to the passed url.
func HttpDelete(url string, body io.Reader) (response *http.Response, err error) {
req, _ := http.NewRequest(http.MethodDelete, url, body)
client := &http.Client{}
return client.Do(req)
}
// HttpPatch sends a Patch Http Request with body to the passed url.
func HttpPatch(url string, contentType string, body io.Reader) (response *http.Response, err error) {
req, _ := http.NewRequest(http.MethodPatch, url, body)
req.Header.Set("Content-Type", contentType)
client := &http.Client{}
return client.Do(req)
}