Refactor interfaces to use a runner manager and an environment manager.

See https://gitlab.hpi.de/codeocean/codemoon/poseidon/-/issues/44.
This commit is contained in:
Jan-Eric Hellenberg
2021-05-12 15:00:48 +02:00
parent 0d697bfd67
commit 83ea552cf7
27 changed files with 816 additions and 567 deletions

View File

@ -55,7 +55,6 @@ snaked_name=$(shell sed -e "s/\([A-Z]\)/_\L\1/g" -e "s/^_//" <<< "$(name)")
mock: deps ## Create/Update a mock. Example: make mock name=apiQuerier pkg=./nomad mock: deps ## Create/Update a mock. Example: make mock name=apiQuerier pkg=./nomad
@mockery \ @mockery \
--name=$(name) \ --name=$(name) \
--output=$(pkg) \
--structname=$(name)Mock \ --structname=$(name)Mock \
--filename=$(snaked_name)_mock.go \ --filename=$(snaked_name)_mock.go \
--inpackage \ --inpackage \

View File

@ -105,8 +105,7 @@ $ make e2e-docker DOCKER_OPTS=""
### Mocks ### Mocks
For mocks we use [mockery](https://github.com/vektra/mockery). To generate a mock, first navigate to the package the interface is defined in. For mocks we use [mockery](https://github.com/vektra/mockery). You can create a mock for the interface of your choice by running
You can then create a mock for the interface of your choice by running
```bash ```bash
make mock name=INTERFACE_NAME pkg=./PATH/TO/PKG make mock name=INTERFACE_NAME pkg=./PATH/TO/PKG
@ -120,4 +119,26 @@ For example, for an interface called `ExecutorApi` in the package `nomad`, you m
make mock name=ExecutorApi pkg=./nomad make mock name=ExecutorApi pkg=./nomad
``` ```
If the interface changes, you can rerun this command. If the interface changes, you can rerun this command (deleting the mock file first to avoid errors may be necessary).
Mocks can also be generated by using mockery directly on a specific interface. To do this, first navigate to the package the interface is defined in. Then run
```bash
mockery \
--name=<<interface_name>> \
--structname=<<interface_name>>Mock \
--filename=<<interface_name>>Mock.go \
--inpackage
```
For example, for an interface called `ExecutorApi` in the package `nomad`, you might run
```bash
mockery \
--name=ExecutorApi \
--structname=ExecutorApiMock \
--filename=ExecutorApiMock.go \
--inpackage
```
Note that per default, the mocks are created in a `mocks` sub-folder. However, in some cases (if the mock implements private interface methods), it needs to be in the same package as the interface it is mocking. The `--inpackage` flag can be used to avoid creating it in a subdirectory.

View File

@ -5,7 +5,7 @@ import (
"gitlab.hpi.de/codeocean/codemoon/poseidon/api/auth" "gitlab.hpi.de/codeocean/codemoon/poseidon/api/auth"
"gitlab.hpi.de/codeocean/codemoon/poseidon/environment" "gitlab.hpi.de/codeocean/codemoon/poseidon/environment"
"gitlab.hpi.de/codeocean/codemoon/poseidon/logging" "gitlab.hpi.de/codeocean/codemoon/poseidon/logging"
"gitlab.hpi.de/codeocean/codemoon/poseidon/nomad" "gitlab.hpi.de/codeocean/codemoon/poseidon/runner"
"net/http" "net/http"
) )
@ -17,32 +17,34 @@ const (
RouteRunners = "/runners" RouteRunners = "/runners"
) )
// NewRouter returns an HTTP handler (http.Handler) which can be // NewRouter returns a *mux.Router which can be
// used by the net/http package to serve the routes of our API. It // used by the net/http package to serve the routes of our API. It
// always returns a router for the newest version of our API. We // always returns a router for the newest version of our API. We
// use gorilla/mux because it is more convenient than net/http, e.g. // use gorilla/mux because it is more convenient than net/http, e.g.
// when extracting path parameters. // when extracting path parameters.
func NewRouter(apiClient nomad.ExecutorApi, runnerPool environment.RunnerPool) *mux.Router { func NewRouter(runnerManager runner.Manager, environmentManager environment.Manager) *mux.Router {
router := mux.NewRouter() router := mux.NewRouter()
// this can later be restricted to a specific host with // this can later be restricted to a specific host with
// `router.Host(...)` and to HTTPS with `router.Schemes("https")` // `router.Host(...)` and to HTTPS with `router.Schemes("https")`
router = newRouterV1(router, apiClient, runnerPool) configureV1Router(router, runnerManager, environmentManager)
router.Use(logging.HTTPLoggingMiddleware) router.Use(logging.HTTPLoggingMiddleware)
return router return router
} }
// newRouterV1 returns a sub-router containing the routes of version 1 of our API. // configureV1Router configures a given router with the routes of version 1 of our API.
func newRouterV1(router *mux.Router, apiClient nomad.ExecutorApi, runnerPool environment.RunnerPool) *mux.Router { func configureV1Router(router *mux.Router, runnerManager runner.Manager, environmentManager environment.Manager) {
v1 := router.PathPrefix(RouteBase).Subrouter() v1 := router.PathPrefix(RouteBase).Subrouter()
v1.HandleFunc(RouteHealth, Health).Methods(http.MethodGet) v1.HandleFunc(RouteHealth, Health).Methods(http.MethodGet)
runnerController := &RunnerController{manager: runnerManager}
if auth.InitializeAuthentication() { if auth.InitializeAuthentication() {
// Create new authenticated subrouter. // Create new authenticated subrouter.
// All routes added to v1 after this require authentication. // All routes added to v1 after this require authentication.
v1 = v1.PathPrefix("").Subrouter() authenticatedV1Router := v1.PathPrefix("").Subrouter()
v1.Use(auth.HTTPAuthenticationMiddleware) authenticatedV1Router.Use(auth.HTTPAuthenticationMiddleware)
runnerController.ConfigureRoutes(authenticatedV1Router)
} else {
runnerController.ConfigureRoutes(v1)
} }
registerRunnerRoutes(v1.PathPrefix(RouteRunners).Subrouter(), apiClient, runnerPool)
return v1
} }

View File

@ -4,7 +4,6 @@ import (
"github.com/gorilla/mux" "github.com/gorilla/mux"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"gitlab.hpi.de/codeocean/codemoon/poseidon/config" "gitlab.hpi.de/codeocean/codemoon/poseidon/config"
"gitlab.hpi.de/codeocean/codemoon/poseidon/environment"
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
"testing" "testing"
@ -17,7 +16,7 @@ func mockHTTPHandler(writer http.ResponseWriter, _ *http.Request) {
func TestNewRouterV1WithAuthenticationDisabled(t *testing.T) { func TestNewRouterV1WithAuthenticationDisabled(t *testing.T) {
config.Config.Server.Token = "" config.Config.Server.Token = ""
router := mux.NewRouter() router := mux.NewRouter()
v1 := newRouterV1(router, nil, environment.NewLocalRunnerPool()) configureV1Router(router, nil, nil)
t.Run("health route is accessible", func(t *testing.T) { t.Run("health route is accessible", func(t *testing.T) {
request, err := http.NewRequest(http.MethodGet, "/api/v1/health", nil) request, err := http.NewRequest(http.MethodGet, "/api/v1/health", nil)
@ -30,7 +29,7 @@ func TestNewRouterV1WithAuthenticationDisabled(t *testing.T) {
}) })
t.Run("added route is accessible", func(t *testing.T) { t.Run("added route is accessible", func(t *testing.T) {
v1.HandleFunc("/test", mockHTTPHandler) router.HandleFunc("/api/v1/test", mockHTTPHandler)
request, err := http.NewRequest(http.MethodGet, "/api/v1/test", nil) request, err := http.NewRequest(http.MethodGet, "/api/v1/test", nil)
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
@ -44,7 +43,7 @@ func TestNewRouterV1WithAuthenticationDisabled(t *testing.T) {
func TestNewRouterV1WithAuthenticationEnabled(t *testing.T) { func TestNewRouterV1WithAuthenticationEnabled(t *testing.T) {
config.Config.Server.Token = "TestToken" config.Config.Server.Token = "TestToken"
router := mux.NewRouter() router := mux.NewRouter()
v1 := newRouterV1(router, nil, environment.NewLocalRunnerPool()) configureV1Router(router, nil, nil)
t.Run("health route is accessible", func(t *testing.T) { t.Run("health route is accessible", func(t *testing.T) {
request, err := http.NewRequest(http.MethodGet, "/api/v1/health", nil) request, err := http.NewRequest(http.MethodGet, "/api/v1/health", nil)
@ -56,9 +55,8 @@ func TestNewRouterV1WithAuthenticationEnabled(t *testing.T) {
assert.Equal(t, http.StatusNoContent, recorder.Code) assert.Equal(t, http.StatusNoContent, recorder.Code)
}) })
t.Run("added route is not accessible", func(t *testing.T) { t.Run("protected route is not accessible", func(t *testing.T) {
v1.HandleFunc("/test", mockHTTPHandler) request, err := http.NewRequest(http.MethodPost, "/api/v1/runners", nil)
request, err := http.NewRequest(http.MethodGet, "/api/v1/test", nil)
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }

View File

@ -18,6 +18,9 @@ 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.
type FileCreation struct{}
// WebsocketResponse is the expected response when creating an execution for a runner. // WebsocketResponse is the expected response when creating an execution for a runner.
type WebsocketResponse struct { type WebsocketResponse struct {
WebsocketUrl string `json:"websocketUrl"` WebsocketUrl string `json:"websocketUrl"`

20
api/environments.go Normal file
View File

@ -0,0 +1,20 @@
package api
import (
"gitlab.hpi.de/codeocean/codemoon/poseidon/environment"
"net/http"
)
type EnvironmentController struct {
manager environment.Manager // nolint:unused,structcheck
}
// create creates a new execution environment on the executor.
func (e *EnvironmentController) create(writer http.ResponseWriter, request *http.Request) { // nolint:unused
}
// delete removes an execution environment from the executor
func (e *EnvironmentController) delete(writer http.ResponseWriter, request *http.Request) { // nolint:unused
}

View File

@ -4,7 +4,8 @@ import (
"net/http" "net/http"
) )
// Health tries to respond that the server is alive. // Health handles the health route.
// It tries to respond that the server is alive.
// If it is not, the response won't reach the client. // If it is not, the response won't reach the client.
func Health(writer http.ResponseWriter, _ *http.Request) { func Health(writer http.ResponseWriter, _ *http.Request) {
writer.WriteHeader(http.StatusNoContent) writer.WriteHeader(http.StatusNoContent)

View File

@ -1,13 +1,10 @@
package api package api
import ( import (
"errors"
"fmt" "fmt"
"github.com/gorilla/mux" "github.com/gorilla/mux"
"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/environment"
"gitlab.hpi.de/codeocean/codemoon/poseidon/nomad"
"gitlab.hpi.de/codeocean/codemoon/poseidon/runner" "gitlab.hpi.de/codeocean/codemoon/poseidon/runner"
"net/http" "net/http"
"net/url" "net/url"
@ -21,106 +18,116 @@ const (
ExecutionIdKey = "executionId" ExecutionIdKey = "executionId"
) )
// provideRunner tries to respond with the id of a runner type RunnerController struct {
manager runner.Manager
runnerRouter *mux.Router
}
// ConfigureRoutes configures a given router with the runner routes of our API.
func (r *RunnerController) ConfigureRoutes(router *mux.Router) {
runnersRouter := router.PathPrefix(RouteRunners).Subrouter()
runnersRouter.HandleFunc("", r.provide).Methods(http.MethodPost)
r.runnerRouter = runnersRouter.PathPrefix(fmt.Sprintf("/{%s}", RunnerIdKey)).Subrouter()
r.runnerRouter.Use(r.findRunnerMiddleware)
r.runnerRouter.HandleFunc(ExecutePath, r.execute).Methods(http.MethodPost).Name(ExecutePath)
r.runnerRouter.HandleFunc(WebsocketPath, connectToRunner).Methods(http.MethodGet).Name(WebsocketPath)
r.runnerRouter.HandleFunc("", r.delete).Methods(http.MethodDelete).Name(DeleteRoute)
}
// provide handles the provide runners API route.
// 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 provideRunner(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 {
return return
} }
executionEnvironment, err := environment.GetExecutionEnvironment(runnerRequest.ExecutionEnvironmentId) environmentId := runner.EnvironmentId(runnerRequest.ExecutionEnvironmentId)
nextRunner, err := r.manager.Use(environmentId)
if err != nil { if err != nil {
writeNotFound(writer, err) if err == runner.ErrUnknownExecutionEnvironment {
return writeNotFound(writer, err)
} } else if err == runner.ErrNoRunnersAvailable {
nextRunner, err := executionEnvironment.NextRunner() writeInternalServerError(writer, err, dto.ErrorNomadOverload)
if err != nil { } else {
writeInternalServerError(writer, err, dto.ErrorNomadOverload) writeInternalServerError(writer, err, dto.ErrorUnknown)
}
return return
} }
sendJson(writer, &dto.RunnerResponse{Id: nextRunner.Id()}, http.StatusOK) sendJson(writer, &dto.RunnerResponse{Id: nextRunner.Id()}, http.StatusOK)
} }
// executeCommand takes an ExecutionRequest and stores it for a runner. // execute handles the execute API route.
// 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.
func executeCommand(router *mux.Router) func(w http.ResponseWriter, r *http.Request) { func (r *RunnerController) execute(writer http.ResponseWriter, request *http.Request) {
return func(writer http.ResponseWriter, request *http.Request) { executionRequest := new(dto.ExecutionRequest)
executionRequest := new(dto.ExecutionRequest) if err := parseJSONRequestBody(writer, request, executionRequest); err != nil {
if err := parseJSONRequestBody(writer, request, executionRequest); err != nil { return
return
}
var scheme string
if config.Config.Server.TLS {
scheme = "wss"
} else {
scheme = "ws"
}
r, _ := runner.FromContext(request.Context())
path, err := router.Get(WebsocketPath).URL(RunnerIdKey, r.Id())
if err != nil {
log.WithError(err).Error("Could not create runner websocket URL.")
writeInternalServerError(writer, err, dto.ErrorUnknown)
return
}
id, err := r.AddExecution(*executionRequest)
if err != nil {
log.WithError(err).Error("Could not store execution.")
writeInternalServerError(writer, err, dto.ErrorUnknown)
return
}
websocketUrl := url.URL{
Scheme: scheme,
Host: request.Host,
Path: path.String(),
RawQuery: fmt.Sprintf("%s=%s", ExecutionIdKey, id),
}
sendJson(writer, &dto.WebsocketResponse{WebsocketUrl: websocketUrl.String()}, http.StatusOK)
} }
var scheme string
if config.Config.Server.TLS {
scheme = "wss"
} else {
scheme = "ws"
}
targetRunner, _ := runner.FromContext(request.Context())
path, err := r.runnerRouter.Get(WebsocketPath).URL(RunnerIdKey, targetRunner.Id())
if err != nil {
log.WithError(err).Error("Could not create runner websocket URL.")
writeInternalServerError(writer, err, dto.ErrorUnknown)
return
}
id, err := targetRunner.AddExecution(*executionRequest)
if err != nil {
log.WithError(err).Error("Could not store execution.")
writeInternalServerError(writer, err, dto.ErrorUnknown)
return
}
websocketUrl := url.URL{
Scheme: scheme,
Host: request.Host,
Path: path.String(),
RawQuery: fmt.Sprintf("%s=%s", ExecutionIdKey, id),
}
sendJson(writer, &dto.WebsocketResponse{WebsocketUrl: websocketUrl.String()}, http.StatusOK)
} }
// The findRunnerMiddleware looks up the runnerId for routes containing it // The findRunnerMiddleware looks up the runnerId for routes containing it
// and adds the runner to the context of the request. // and adds the runner to the context of the request.
func findRunnerMiddleware(runnerPool environment.RunnerPool) func(handler http.Handler) http.Handler { func (r *RunnerController) findRunnerMiddleware(next http.Handler) http.Handler {
return func(next http.Handler) http.Handler { return http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
return http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { // Find runner
// Find runner runnerId := mux.Vars(request)[RunnerIdKey]
runnerId := mux.Vars(request)[RunnerIdKey] r, err := r.manager.Get(runnerId)
r, ok := runnerPool.Get(runnerId)
if !ok {
writeNotFound(writer, errors.New("no runner with this id"))
return
}
ctx := runner.NewContext(request.Context(), r.(runner.Runner))
requestWithRunner := request.WithContext(ctx)
next.ServeHTTP(writer, requestWithRunner)
})
}
}
func deleteRunner(apiClient nomad.ExecutorApi, runnerPool environment.RunnerPool) func(writer http.ResponseWriter, request *http.Request) {
return func(writer http.ResponseWriter, request *http.Request) {
targetRunner, _ := runner.FromContext(request.Context())
err := apiClient.DeleteRunner(targetRunner.Id())
if err != nil { if err != nil {
writeInternalServerError(writer, err, dto.ErrorNomadInternalServerError) writeNotFound(writer, err)
return return
} }
ctx := runner.NewContext(request.Context(), r.(runner.Runner))
requestWithRunner := request.WithContext(ctx)
next.ServeHTTP(writer, requestWithRunner)
})
}
runnerPool.Delete(targetRunner.Id()) // delete handles the delete runner API route.
// It destroys the given runner on the executor and removes it from the used runners list.
func (r *RunnerController) delete(writer http.ResponseWriter, request *http.Request) {
targetRunner, _ := runner.FromContext(request.Context())
writer.WriteHeader(http.StatusNoContent) err := r.manager.Return(targetRunner)
if err != nil {
if err == runner.ErrUnknownExecutionEnvironment {
writeNotFound(writer, err)
}
writeInternalServerError(writer, err, dto.ErrorNomadInternalServerError)
return
} }
}
func registerRunnerRoutes(router *mux.Router, apiClient nomad.ExecutorApi, runnerPool environment.RunnerPool) { writer.WriteHeader(http.StatusNoContent)
router.HandleFunc("", provideRunner).Methods(http.MethodPost)
runnerRouter := router.PathPrefix(fmt.Sprintf("/{%s}", RunnerIdKey)).Subrouter()
runnerRouter.Use(findRunnerMiddleware(runnerPool))
runnerRouter.HandleFunc(ExecutePath, executeCommand(runnerRouter)).Methods(http.MethodPost).Name(ExecutePath)
runnerRouter.HandleFunc(WebsocketPath, connectToRunner).Methods(http.MethodGet).Name(WebsocketPath)
runnerRouter.HandleFunc("", deleteRunner(apiClient, runnerPool)).Methods(http.MethodDelete).Name(DeleteRoute)
} }

View File

@ -3,15 +3,12 @@ package api
import ( import (
"bytes" "bytes"
"encoding/json" "encoding/json"
"errors"
"fmt" "fmt"
"github.com/gorilla/mux" "github.com/gorilla/mux"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/mock"
"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/environment" "gitlab.hpi.de/codeocean/codemoon/poseidon/environment"
"gitlab.hpi.de/codeocean/codemoon/poseidon/nomad"
"gitlab.hpi.de/codeocean/codemoon/poseidon/runner" "gitlab.hpi.de/codeocean/codemoon/poseidon/runner"
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
@ -20,11 +17,27 @@ import (
"testing" "testing"
) )
func TestFindRunnerMiddleware(t *testing.T) { type MiddlewareTestSuite struct {
runnerPool := environment.NewLocalRunnerPool() suite.Suite
manager *runner.ManagerMock
router *mux.Router
runnerController *RunnerController
testRunner runner.Runner
}
func (suite *MiddlewareTestSuite) SetupTest() {
suite.manager = &runner.ManagerMock{}
suite.router = mux.NewRouter()
suite.runnerController = &RunnerController{suite.manager, suite.router}
suite.testRunner = runner.NewRunner("runner")
}
func TestMiddlewareTestSuite(t *testing.T) {
suite.Run(t, new(MiddlewareTestSuite))
}
func (suite *MiddlewareTestSuite) TestFindRunnerMiddleware() {
var capturedRunner runner.Runner var capturedRunner runner.Runner
testRunner := runner.NewExerciseRunner("testRunner")
runnerPool.Add(testRunner)
testRunnerIdRoute := func(writer http.ResponseWriter, request *http.Request) { testRunnerIdRoute := func(writer http.ResponseWriter, request *http.Request) {
var ok bool var ok bool
@ -35,12 +48,8 @@ func TestFindRunnerMiddleware(t *testing.T) {
writer.WriteHeader(http.StatusInternalServerError) writer.WriteHeader(http.StatusInternalServerError)
} }
} }
router := mux.NewRouter()
router.Use(findRunnerMiddleware(runnerPool))
router.HandleFunc(fmt.Sprintf("/test/{%s}", RunnerIdKey), testRunnerIdRoute).Name("test-runner-id")
testRunnerRequest := func(t *testing.T, runnerId string) *http.Request { testRunnerRequest := func(t *testing.T, runnerId string) *http.Request {
path, err := router.Get("test-runner-id").URL(RunnerIdKey, runnerId) path, err := suite.router.Get("test-runner-id").URL(RunnerIdKey, runnerId)
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
@ -51,36 +60,57 @@ func TestFindRunnerMiddleware(t *testing.T) {
return request return request
} }
t.Run("sets runner in context if runner exists", func(t *testing.T) { suite.router.Use(suite.runnerController.findRunnerMiddleware)
suite.router.HandleFunc(fmt.Sprintf("/test/{%s}", RunnerIdKey), testRunnerIdRoute).Name("test-runner-id")
suite.manager.On("Get", suite.testRunner.Id()).Return(suite.testRunner, nil)
suite.T().Run("sets runner in context if runner exists", func(t *testing.T) {
capturedRunner = nil capturedRunner = nil
recorder := httptest.NewRecorder() recorder := httptest.NewRecorder()
router.ServeHTTP(recorder, testRunnerRequest(t, testRunner.Id())) suite.router.ServeHTTP(recorder, testRunnerRequest(t, suite.testRunner.Id()))
assert.Equal(t, http.StatusOK, recorder.Code) assert.Equal(t, http.StatusOK, recorder.Code)
assert.Equal(t, testRunner, capturedRunner) assert.Equal(t, suite.testRunner, capturedRunner)
}) })
t.Run("returns 404 if runner does not exist", func(t *testing.T) { invalidID := "some-invalid-runner-id"
suite.manager.On("Get", invalidID).Return(nil, runner.ErrRunnerNotFound)
suite.T().Run("returns 404 if runner does not exist", func(t *testing.T) {
recorder := httptest.NewRecorder() recorder := httptest.NewRecorder()
router.ServeHTTP(recorder, testRunnerRequest(t, "some-invalid-runner-id")) suite.router.ServeHTTP(recorder, testRunnerRequest(t, invalidID))
assert.Equal(t, http.StatusNotFound, recorder.Code) assert.Equal(t, http.StatusNotFound, recorder.Code)
}) })
} }
func TestExecuteRoute(t *testing.T) { func TestRunnerRouteTestSuite(t *testing.T) {
runnerPool := environment.NewLocalRunnerPool() suite.Run(t, new(RunnerRouteTestSuite))
router := NewRouter(nil, runnerPool) }
testRunner := runner.NewExerciseRunner("testRunner")
runnerPool.Add(testRunner)
path, err := router.Get(ExecutePath).URL(RunnerIdKey, testRunner.Id()) type RunnerRouteTestSuite struct {
suite.Suite
runnerManager *runner.ManagerMock
environmentManager *environment.ManagerMock
router *mux.Router
runner runner.Runner
}
func (suite *RunnerRouteTestSuite) SetupTest() {
suite.runnerManager = &runner.ManagerMock{}
suite.environmentManager = &environment.ManagerMock{}
suite.router = NewRouter(suite.runnerManager, suite.environmentManager)
suite.runner = runner.NewRunner("test_runner")
suite.runnerManager.On("Get", suite.runner.Id()).Return(suite.runner, nil)
}
func (suite *RunnerRouteTestSuite) TestExecuteRoute() {
path, err := suite.router.Get(ExecutePath).URL(RunnerIdKey, suite.runner.Id())
if err != nil { if err != nil {
t.Fatal(err) suite.T().Fatal()
} }
t.Run("valid request", func(t *testing.T) { suite.Run("valid request", func() {
recorder := httptest.NewRecorder() recorder := httptest.NewRecorder()
executionRequest := dto.ExecutionRequest{ executionRequest := dto.ExecutionRequest{
Command: "command", Command: "command",
@ -89,131 +119,70 @@ func TestExecuteRoute(t *testing.T) {
} }
body, err := json.Marshal(executionRequest) body, err := json.Marshal(executionRequest)
if err != nil { if err != nil {
t.Fatal(err) suite.T().Fatal(err)
} }
request, err := http.NewRequest(http.MethodPost, path.String(), bytes.NewReader(body)) request, err := http.NewRequest(http.MethodPost, path.String(), bytes.NewReader(body))
if err != nil { if err != nil {
t.Fatal(err) suite.T().Fatal(err)
} }
router.ServeHTTP(recorder, request) suite.router.ServeHTTP(recorder, request)
var websocketResponse dto.WebsocketResponse var websocketResponse dto.WebsocketResponse
err = json.NewDecoder(recorder.Result().Body).Decode(&websocketResponse) err = json.NewDecoder(recorder.Result().Body).Decode(&websocketResponse)
if err != nil { if err != nil {
t.Fatal(err) suite.T().Fatal(err)
} }
assert.Equal(t, http.StatusOK, recorder.Code) suite.Equal(http.StatusOK, recorder.Code)
t.Run("creates an execution request for the runner", func(t *testing.T) { suite.Run("creates an execution request for the runner", func() {
url, err := url.Parse(websocketResponse.WebsocketUrl) url, err := url.Parse(websocketResponse.WebsocketUrl)
if err != nil { if err != nil {
t.Fatal(err) suite.T().Fatal(err)
} }
executionId := url.Query().Get(ExecutionIdKey) executionId := url.Query().Get(ExecutionIdKey)
storedExecutionRequest, ok := testRunner.Execution(runner.ExecutionId(executionId)) storedExecutionRequest, ok := suite.runner.Execution(runner.ExecutionId(executionId))
assert.True(t, ok, "No execution request with this id: ", executionId) suite.True(ok, "No execution request with this id: ", executionId)
assert.Equal(t, executionRequest, storedExecutionRequest) suite.Equal(executionRequest, storedExecutionRequest)
}) })
}) })
t.Run("invalid request", func(t *testing.T) { suite.Run("invalid request", func() {
recorder := httptest.NewRecorder() recorder := httptest.NewRecorder()
body := "" body := ""
request, err := http.NewRequest(http.MethodPost, path.String(), strings.NewReader(body)) request, err := http.NewRequest(http.MethodPost, path.String(), strings.NewReader(body))
if err != nil { if err != nil {
t.Fatal(err) suite.T().Fatal(err)
} }
router.ServeHTTP(recorder, request) suite.router.ServeHTTP(recorder, request)
assert.Equal(t, http.StatusBadRequest, recorder.Code) suite.Equal(http.StatusBadRequest, recorder.Code)
}) })
} }
func TestDeleteRunnerRouteTestSuite(t *testing.T) { func (suite *RunnerRouteTestSuite) TestDeleteRoute() {
suite.Run(t, new(DeleteRunnerRouteTestSuite)) deleteURL, err := suite.router.Get(DeleteRoute).URL(RunnerIdKey, suite.runner.Id())
}
type DeleteRunnerRouteTestSuite struct {
suite.Suite
runnerPool environment.RunnerPool
apiClient *nomad.ExecutorApiMock
router *mux.Router
testRunner runner.Runner
path string
}
func (suite *DeleteRunnerRouteTestSuite) SetupTest() {
suite.runnerPool = environment.NewLocalRunnerPool()
suite.apiClient = &nomad.ExecutorApiMock{}
suite.router = NewRouter(suite.apiClient, suite.runnerPool)
suite.testRunner = runner.NewExerciseRunner("testRunner")
suite.runnerPool.Add(suite.testRunner)
var err error
runnerUrl, err := suite.router.Get(DeleteRoute).URL(RunnerIdKey, suite.testRunner.Id())
if err != nil { if err != nil {
suite.T().Fatal(err) suite.T().Fatal(err)
} }
suite.path = runnerUrl.String() deletePath := deleteURL.String()
} suite.runnerManager.On("Return", suite.runner).Return(nil)
func (suite *DeleteRunnerRouteTestSuite) TestValidRequestReturnsNoContent() { suite.Run("valid request", func() {
suite.apiClient.On("DeleteRunner", mock.AnythingOfType("string")).Return(nil) recorder := httptest.NewRecorder()
request, err := http.NewRequest(http.MethodDelete, deletePath, nil)
if err != nil {
suite.T().Fatal(err)
}
recorder := httptest.NewRecorder() suite.router.ServeHTTP(recorder, request)
request, err := http.NewRequest(http.MethodDelete, suite.path, nil)
if err != nil {
suite.T().Fatal(err)
}
suite.router.ServeHTTP(recorder, request) suite.Equal(http.StatusNoContent, recorder.Code)
suite.Equal(http.StatusNoContent, recorder.Code) suite.Run("runner was returned to runner manager", func() {
suite.runnerManager.AssertCalled(suite.T(), "Return", suite.runner)
suite.Run("runner is deleted on nomad", func() { })
suite.apiClient.AssertCalled(suite.T(), "DeleteRunner", suite.testRunner.Id())
})
suite.Run("runner is deleted from runnerPool", func() {
returnedRunner, ok := suite.runnerPool.Get(suite.testRunner.Id())
suite.Nil(returnedRunner)
suite.False(ok)
}) })
} }
func (suite *DeleteRunnerRouteTestSuite) TestReturnInternalServerErrorWhenApiCallToNomadFailed() {
suite.apiClient.On("DeleteRunner", mock.AnythingOfType("string")).Return(errors.New("API call failed"))
recorder := httptest.NewRecorder()
request, err := http.NewRequest(http.MethodDelete, suite.path, nil)
if err != nil {
suite.T().Fatal(err)
}
suite.router.ServeHTTP(recorder, request)
suite.Equal(http.StatusInternalServerError, recorder.Code)
}
func (suite *DeleteRunnerRouteTestSuite) TestDeleteInvalidRunnerIdReturnsNotFound() {
var err error
runnersUrl, err := suite.router.Get(DeleteRoute).URL(RunnerIdKey, "1nv4l1dID")
if err != nil {
suite.T().Fatal(err)
}
suite.path = runnersUrl.String()
recorder := httptest.NewRecorder()
request, err := http.NewRequest(http.MethodDelete, suite.path, nil)
if err != nil {
suite.T().Fatal(err)
}
suite.router.ServeHTTP(recorder, request)
suite.Equal(http.StatusNotFound, recorder.Code)
}

View File

@ -2,7 +2,6 @@ package api
import ( import (
"fmt" "fmt"
"github.com/gorilla/mux"
"github.com/gorilla/websocket" "github.com/gorilla/websocket"
"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"
@ -15,10 +14,8 @@ import (
) )
type WebsocketTestSuite struct { type WebsocketTestSuite struct {
suite.Suite RunnerRouteTestSuite
runner runner.Runner
server *httptest.Server server *httptest.Server
router *mux.Router
executionId runner.ExecutionId executionId runner.ExecutionId
} }
@ -26,10 +23,12 @@ func TestWebsocketTestSuite(t *testing.T) {
suite.Run(t, new(WebsocketTestSuite)) suite.Run(t, new(WebsocketTestSuite))
} }
func (suite *WebsocketTestSuite) SetupSuite() { func (suite *WebsocketTestSuite) SetupTest() {
runnerPool := environment.NewLocalRunnerPool() suite.runnerManager = &runner.ManagerMock{}
suite.runner = runner.NewExerciseRunner("testRunner") suite.environmentManager = &environment.ManagerMock{}
runnerPool.Add(suite.runner) suite.router = NewRouter(suite.runnerManager, suite.environmentManager)
suite.runner = runner.NewRunner("test_runner")
suite.runnerManager.On("Get", suite.runner.Id()).Return(suite.runner, nil)
var err error var err error
suite.executionId, err = suite.runner.AddExecution(dto.ExecutionRequest{ suite.executionId, err = suite.runner.AddExecution(dto.ExecutionRequest{
Command: "command", Command: "command",
@ -38,11 +37,12 @@ func (suite *WebsocketTestSuite) SetupSuite() {
}) })
suite.Require().NoError(err) suite.Require().NoError(err)
router := mux.NewRouter() // router.HandleFunc(fmt.Sprintf("%s/{%s}%s", RouteRunners, RunnerIdKey, WebsocketPath), connectToRunner).Methods(http.MethodGet).Name(WebsocketPath)
router.Use(findRunnerMiddleware(runnerPool)) suite.server = httptest.NewServer(suite.router)
router.HandleFunc(fmt.Sprintf("%s/{%s}%s", RouteRunners, RunnerIdKey, WebsocketPath), connectToRunner).Methods(http.MethodGet).Name(WebsocketPath) }
suite.server = httptest.NewServer(router)
suite.router = router func (suite *WebsocketTestSuite) TearDownSuite() {
suite.server.Close()
} }
func (suite *WebsocketTestSuite) websocketUrl(scheme, runnerId string, executionId runner.ExecutionId) (*url.URL, error) { func (suite *WebsocketTestSuite) websocketUrl(scheme, runnerId string, executionId runner.ExecutionId) (*url.URL, error) {
@ -56,10 +56,6 @@ func (suite *WebsocketTestSuite) websocketUrl(scheme, runnerId string, execution
return websocketUrl, nil return websocketUrl, nil
} }
func (suite *WebsocketTestSuite) TearDownSuite() {
suite.server.Close()
}
func (suite *WebsocketTestSuite) TestWebsocketConnectionCanBeEstablished() { func (suite *WebsocketTestSuite) TestWebsocketConnectionCanBeEstablished() {
path, err := suite.websocketUrl("ws", suite.runner.Id(), suite.executionId) path, err := suite.websocketUrl("ws", suite.runner.Id(), suite.executionId)
suite.Require().NoError(err) suite.Require().NoError(err)

View File

@ -1,106 +0,0 @@
package environment
import (
"errors"
"gitlab.hpi.de/codeocean/codemoon/poseidon/logging"
"gitlab.hpi.de/codeocean/codemoon/poseidon/nomad"
"gitlab.hpi.de/codeocean/codemoon/poseidon/runner"
"time"
)
var log = logging.GetLogger("execution_environment")
// ExecutionEnvironment is a partial image of an execution environment in CodeOcean.
type ExecutionEnvironment interface {
// NextRunner gets the next available runner and marks it as running.
// If no runner is available it throws a error after a small timeout
NextRunner() (runner.Runner, error)
// Refresh fetches the runners for this execution environment and sends them to the pool.
// This function does not terminate. Instead it fetches new runners periodically.
Refresh()
}
// NomadExecutionEnvironment is an implementation that returns a nomad specific execution environment.
// Here it is mapped on a Job in Nomad.
// The jobId has to match the only Nomad task group name!
type NomadExecutionEnvironment struct {
id int
jobId string
availableRunners chan runner.Runner
allRunners RunnerPool
nomadApiClient nomad.ExecutorApi
}
var executionEnvironment ExecutionEnvironment
// DebugInit initializes one execution environment so that its runners can be provided.
// ToDo: This should be replaced by a create Execution Environment route
func DebugInit(runnersPool RunnerPool, nomadApi nomad.ExecutorApi) {
executionEnvironment = &NomadExecutionEnvironment{
id: 0,
jobId: "python",
availableRunners: make(chan runner.Runner, 5),
nomadApiClient: nomadApi,
allRunners: runnersPool,
}
go executionEnvironment.Refresh()
}
// GetExecutionEnvironment returns a previously added ExecutionEnvironment.
// This way you can access all Runners for that environment,
func GetExecutionEnvironment(id int) (ExecutionEnvironment, error) {
// TODO: Remove hardcoded execution environment
return executionEnvironment, nil
}
func (environment *NomadExecutionEnvironment) NextRunner() (r runner.Runner, err error) {
select {
case r = <-environment.availableRunners:
r.SetStatus(runner.StatusRunning)
return r, nil
case <-time.After(50 * time.Millisecond):
return nil, errors.New("no runners available")
}
}
// Refresh Big ToDo: Improve this function!! State out that it also rescales the job; Provide context to be terminable...
func (environment *NomadExecutionEnvironment) Refresh() {
for {
runners, err := environment.nomadApiClient.LoadRunners(environment.jobId)
if err != nil {
log.WithError(err).Warn("Failed fetching runners")
break
}
for _, r := range environment.unusedRunners(runners) {
// ToDo: Listen on Nomad event stream
log.WithField("allocation", r).Debug("Adding allocation")
environment.allRunners.Add(r)
environment.availableRunners <- r
}
jobScale, err := environment.nomadApiClient.JobScale(environment.jobId)
if err != nil {
log.WithError(err).Warn("Failed get allocation count")
break
}
neededRunners := cap(environment.availableRunners) - len(environment.availableRunners) + 1
runnerCount := jobScale + neededRunners
time.Sleep(50 * time.Millisecond)
log.WithField("count", runnerCount).Debug("Set job scaling")
err = environment.nomadApiClient.SetJobScale(environment.jobId, runnerCount, "Runner Requested")
if err != nil {
log.WithError(err).Warn("Failed to set allocation scaling")
continue
}
}
}
func (environment *NomadExecutionEnvironment) unusedRunners(fetchedRunnerIds []string) (newRunners []runner.Runner) {
newRunners = make([]runner.Runner, 0)
for _, runnerId := range fetchedRunnerIds {
_, ok := environment.allRunners.Get(runnerId)
if !ok {
newRunners = append(newRunners, runner.NewExerciseRunner(runnerId))
}
}
return
}

View File

@ -1,109 +0,0 @@
package environment
import (
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/mock"
"github.com/stretchr/testify/suite"
"gitlab.hpi.de/codeocean/codemoon/poseidon/nomad"
"gitlab.hpi.de/codeocean/codemoon/poseidon/runner"
"testing"
"time"
)
const anotherRunnerId = "4n0th3r-1d"
const jobId = "4n0th3r-1d"
func TestGetNextRunnerTestSuite(t *testing.T) {
suite.Run(t, new(GetNextRunnerTestSuite))
}
type GetNextRunnerTestSuite struct {
suite.Suite
nomadExecutionEnvironment *NomadExecutionEnvironment
exerciseRunner runner.Runner
}
func (suite *GetNextRunnerTestSuite) SetupTest() {
suite.nomadExecutionEnvironment = &NomadExecutionEnvironment{
availableRunners: make(chan runner.Runner, 50),
allRunners: NewLocalRunnerPool(),
}
suite.exerciseRunner = CreateTestRunner()
}
func (suite *GetNextRunnerTestSuite) TestGetNextRunnerReturnsRunnerIfAvailable() {
suite.nomadExecutionEnvironment.availableRunners <- suite.exerciseRunner
receivedRunner, err := suite.nomadExecutionEnvironment.NextRunner()
suite.NoError(err)
suite.Equal(suite.exerciseRunner, receivedRunner)
}
func (suite *GetNextRunnerTestSuite) TestGetNextRunnerChangesStatusOfRunner() {
suite.nomadExecutionEnvironment.availableRunners <- suite.exerciseRunner
receivedRunner, _ := suite.nomadExecutionEnvironment.NextRunner()
suite.Equal(runner.StatusRunning, receivedRunner.Status())
}
func (suite *GetNextRunnerTestSuite) TestGetNextRunnerDoesNotReturnTheSameRunnerTwice() {
suite.nomadExecutionEnvironment.availableRunners <- suite.exerciseRunner
suite.nomadExecutionEnvironment.availableRunners <- runner.NewExerciseRunner(anotherRunnerId)
firstReceivedRunner, _ := suite.nomadExecutionEnvironment.NextRunner()
secondReceivedRunner, _ := suite.nomadExecutionEnvironment.NextRunner()
suite.NotEqual(firstReceivedRunner, secondReceivedRunner)
}
func (suite *GetNextRunnerTestSuite) TestGetNextRunnerThrowsAnErrorIfNoRunnersAvailable() {
receivedRunner, err := suite.nomadExecutionEnvironment.NextRunner()
suite.Nil(receivedRunner)
suite.Error(err)
}
func TestRefreshFetchRunners(t *testing.T) {
apiMock, environment := newRefreshMock([]string{RunnerId}, NewLocalRunnerPool())
// ToDo: Terminate Refresh when test finished (also in other tests)
go environment.Refresh()
_, _ = environment.NextRunner()
apiMock.AssertCalled(t, "LoadRunners", jobId)
}
func TestRefreshFetchesRunnersIntoChannel(t *testing.T) {
_, environment := newRefreshMock([]string{RunnerId}, NewLocalRunnerPool())
go environment.Refresh()
availableRunner, _ := environment.NextRunner()
assert.Equal(t, availableRunner.Id(), RunnerId)
}
func TestRefreshScalesJob(t *testing.T) {
apiMock, environment := newRefreshMock([]string{RunnerId}, NewLocalRunnerPool())
go environment.Refresh()
_, _ = environment.NextRunner()
time.Sleep(100 * time.Millisecond) // ToDo: Be safe this test is not flaky
apiMock.AssertCalled(t, "SetJobScale", jobId, 52, "Runner Requested")
}
func TestRefreshAddsRunnerToPool(t *testing.T) {
runnersInUse := NewLocalRunnerPool()
_, environment := newRefreshMock([]string{RunnerId}, runnersInUse)
go environment.Refresh()
availableRunner, _ := environment.NextRunner()
poolRunner, ok := runnersInUse.Get(availableRunner.Id())
assert.True(t, ok)
assert.Equal(t, availableRunner, poolRunner)
}
func newRefreshMock(returnedRunnerIds []string, allRunners RunnerPool) (apiClient *nomad.ExecutorApiMock, environment *NomadExecutionEnvironment) {
apiClient = &nomad.ExecutorApiMock{}
apiClient.On("LoadRunners", jobId).Return(returnedRunnerIds, nil)
apiClient.On("JobScale", jobId).Return(len(returnedRunnerIds), nil)
apiClient.On("SetJobScale", jobId, mock.AnythingOfType("int"), "Runner Requested").Return(nil)
environment = &NomadExecutionEnvironment{
jobId: jobId,
availableRunners: make(chan runner.Runner, 50),
allRunners: allRunners,
nomadApiClient: apiClient,
}
return
}

59
environment/manager.go Normal file
View File

@ -0,0 +1,59 @@
package environment
import (
"gitlab.hpi.de/codeocean/codemoon/poseidon/nomad"
"gitlab.hpi.de/codeocean/codemoon/poseidon/runner"
)
// Manager encapsulates API calls to the executor API for creation and deletion of execution environments.
type Manager interface {
// Load fetches all already created execution environments from the executor and registers them at the runner manager.
// It should be called during the startup process (e.g. on creation of the Manager).
Load()
// Create creates a new execution environment on the executor.
Create(
id string,
prewarmingPoolSize uint,
cpuLimit uint,
memoryLimit uint,
image string,
networkAccess bool,
exposedPorts []uint16,
)
// Delete remove the execution environment with the given id from the executor.
Delete(id string)
}
func NewNomadEnvironmentManager(runnerManager runner.Manager, apiClient nomad.ExecutorApi) *NomadEnvironmentManager {
environmentManager := &NomadEnvironmentManager{runnerManager, apiClient}
environmentManager.Load()
return environmentManager
}
type NomadEnvironmentManager struct {
runnerManager runner.Manager
api nomad.ExecutorApi
}
func (m *NomadEnvironmentManager) Create(
id string,
prewarmingPoolSize uint,
cpuLimit uint,
memoryLimit uint,
image string,
networkAccess bool,
exposedPorts []uint16,
) {
}
func (m *NomadEnvironmentManager) Delete(id string) {
}
func (m *NomadEnvironmentManager) Load() {
// ToDo: remove create default execution environment for debugging purposes
m.runnerManager.RegisterEnvironment(runner.EnvironmentId(0), "python", 5)
}

View File

@ -0,0 +1,25 @@
// Code generated by mockery v0.0.0-dev. DO NOT EDIT.
package environment
import mock "github.com/stretchr/testify/mock"
// ManagerMock is an autogenerated mock type for the Manager type
type ManagerMock struct {
mock.Mock
}
// Create provides a mock function with given fields: id, prewarmingPoolSize, cpuLimit, memoryLimit, image, networkAccess, exposedPorts
func (_m *ManagerMock) Create(id string, prewarmingPoolSize uint, cpuLimit uint, memoryLimit uint, image string, networkAccess bool, exposedPorts []uint16) {
_m.Called(id, prewarmingPoolSize, cpuLimit, memoryLimit, image, networkAccess, exposedPorts)
}
// Delete provides a mock function with given fields: id
func (_m *ManagerMock) Delete(id string) {
_m.Called(id)
}
// Load provides a mock function with given fields:
func (_m *ManagerMock) Load() {
_m.Called()
}

View File

@ -1,9 +0,0 @@
package environment
import "gitlab.hpi.de/codeocean/codemoon/poseidon/runner"
const RunnerId = "s0m3-r4nd0m-1d"
func CreateTestRunner() runner.Runner {
return runner.NewExerciseRunner(RunnerId)
}

2
go.mod
View File

@ -5,7 +5,7 @@ go 1.16
require ( require (
github.com/google/uuid v1.2.0 github.com/google/uuid v1.2.0
github.com/gorilla/mux v1.8.0 github.com/gorilla/mux v1.8.0
github.com/gorilla/websocket v1.4.2 // indirect github.com/gorilla/websocket v1.4.2
github.com/hashicorp/go-cleanhttp v0.5.2 // indirect github.com/hashicorp/go-cleanhttp v0.5.2 // indirect
github.com/hashicorp/nomad v1.0.4 github.com/hashicorp/nomad v1.0.4
github.com/hashicorp/nomad/api v0.0.0-20210505182403-7d5a9ecde95c github.com/hashicorp/nomad/api v0.0.0-20210505182403-7d5a9ecde95c

26
main.go
View File

@ -7,6 +7,7 @@ import (
"gitlab.hpi.de/codeocean/codemoon/poseidon/environment" "gitlab.hpi.de/codeocean/codemoon/poseidon/environment"
"gitlab.hpi.de/codeocean/codemoon/poseidon/logging" "gitlab.hpi.de/codeocean/codemoon/poseidon/logging"
"gitlab.hpi.de/codeocean/codemoon/poseidon/nomad" "gitlab.hpi.de/codeocean/codemoon/poseidon/nomad"
"gitlab.hpi.de/codeocean/codemoon/poseidon/runner"
"net/http" "net/http"
"os" "os"
"os/signal" "os/signal"
@ -38,13 +39,22 @@ func runServer(server *http.Server) {
} }
} }
func initServer(apiClient nomad.ExecutorApi, runnerPool environment.RunnerPool) *http.Server { func initServer() *http.Server {
// API initialization
nomadAPIClient, err := nomad.NewExecutorApi(config.Config.NomadAPIURL(), config.Config.Nomad.Namespace)
if err != nil {
log.WithError(err).WithField("nomad url", config.Config.NomadAPIURL()).Fatal("Error parsing the nomad url")
}
runnerManager := runner.NewNomadRunnerManager(nomadAPIClient)
environmentManager := environment.NewNomadEnvironmentManager(runnerManager, nomadAPIClient)
return &http.Server{ return &http.Server{
Addr: config.Config.PoseidonAPIURL().Host, Addr: config.Config.PoseidonAPIURL().Host,
WriteTimeout: time.Second * 15, WriteTimeout: time.Second * 15,
ReadTimeout: time.Second * 15, ReadTimeout: time.Second * 15,
IdleTimeout: time.Second * 60, IdleTimeout: time.Second * 60,
Handler: api.NewRouter(apiClient, runnerPool), Handler: api.NewRouter(runnerManager, environmentManager),
} }
} }
@ -68,17 +78,7 @@ func main() {
} }
logging.InitializeLogging(config.Config.Logger.Level) logging.InitializeLogging(config.Config.Logger.Level)
// API initialization server := initServer()
nomadAPIClient, err := nomad.NewExecutorApi(config.Config.NomadAPIURL(), config.Config.Nomad.Namespace)
if err != nil {
log.WithError(err).WithField("nomad url", config.Config.NomadAPIURL()).Fatal("Error parsing the nomad url")
}
// ToDo: Move to create execution environment
runnerPool := environment.NewLocalRunnerPool()
environment.DebugInit(runnerPool, nomadAPIClient)
server := initServer(nomadAPIClient, runnerPool)
go runServer(server) go runServer(server)
shutdownOnOSSignal(server) shutdownOnOSSignal(server)
} }

View File

@ -72,8 +72,8 @@ func (_m *apiQuerierMock) LoadJobList() ([]*api.JobListStub, error) {
return r0, r1 return r0, r1
} }
// SetJobScaling provides a mock function with given fields: jobId, count, reason // SetJobScale provides a mock function with given fields: jobId, count, reason
func (_m *apiQuerierMock) SetJobScaling(jobId string, count int, reason string) error { func (_m *apiQuerierMock) SetJobScale(jobId string, count int, reason string) error {
ret := _m.Called(jobId, count, reason) ret := _m.Called(jobId, count, reason)
var r0 error var r0 error
@ -122,17 +122,3 @@ func (_m *apiQuerierMock) loadRunners(jobId string) ([]*api.AllocationListStub,
return r0, r1 return r0, r1
} }
// SetJobScale provides a mock function with given fields: jobId, count, reason
func (_m *apiQuerierMock) SetJobScale(jobId string, count int, reason string) error {
ret := _m.Called(jobId, count, reason)
var r0 error
if rf, ok := ret.Get(0).(func(string, int, string) error); ok {
r0 = rf(jobId, count, reason)
} else {
r0 = ret.Error(0)
}
return r0
}

145
runner/manager.go Normal file
View File

@ -0,0 +1,145 @@
package runner
import (
"errors"
"gitlab.hpi.de/codeocean/codemoon/poseidon/logging"
"gitlab.hpi.de/codeocean/codemoon/poseidon/nomad"
"time"
)
var (
log = logging.GetLogger("runner")
ErrUnknownExecutionEnvironment = errors.New("execution environment not found")
ErrNoRunnersAvailable = errors.New("no runners available for this execution environment")
ErrRunnerNotFound = errors.New("no runner found with this id")
)
// Manager keeps track of the used and unused runners of all execution environments in order to provide unused runners to new clients and ensure no runner is used twice.
type Manager interface {
// RegisterEnvironment adds a new environment for being managed.
RegisterEnvironment(environmentId EnvironmentId, nomadJobId NomadJobId, desiredIdleRunnersCount int)
// Use returns a new runner.
// It makes sure that runner is not in use yet and returns an error if no runner could be provided.
Use(id EnvironmentId) (Runner, error)
// Get returns the used runner with the given runnerId.
// If no runner with the given runnerId is currently used, it returns an error.
Get(runnerId string) (Runner, error)
// Return hands back the runner.
// The runner is deleted or cleaned up for reuse depending on the used executor.
Return(r Runner) error
}
func NewNomadRunnerManager(apiClient nomad.ExecutorApi) *NomadRunnerManager {
return &NomadRunnerManager{
apiClient,
make(map[EnvironmentId]*NomadJob),
NewLocalRunnerPool(),
}
}
type EnvironmentId int
type NomadJobId string
type NomadJob struct {
jobId NomadJobId
idleRunners Pool
desiredIdleRunnersCount int
}
type NomadRunnerManager struct {
apiClient nomad.ExecutorApi
jobs map[EnvironmentId]*NomadJob
usedRunners Pool
}
func (m *NomadRunnerManager) RegisterEnvironment(environmentId EnvironmentId, nomadJobId NomadJobId, desiredIdleRunnersCount int) {
m.jobs[environmentId] = &NomadJob{
nomadJobId,
NewLocalRunnerPool(),
desiredIdleRunnersCount,
}
go m.refreshEnvironment(environmentId)
}
func (m *NomadRunnerManager) Use(environmentId EnvironmentId) (Runner, error) {
job, ok := m.jobs[environmentId]
if !ok {
return nil, ErrUnknownExecutionEnvironment
}
runner, ok := job.idleRunners.Sample()
if !ok {
return nil, ErrNoRunnersAvailable
}
m.usedRunners.Add(runner)
return runner, nil
}
func (m *NomadRunnerManager) Get(runnerId string) (r Runner, err error) {
runner, ok := m.usedRunners.Get(runnerId)
if !ok {
return nil, ErrRunnerNotFound
}
return runner.(Runner), nil
}
func (m *NomadRunnerManager) Return(r Runner) (err error) {
err = m.apiClient.DeleteRunner(r.Id())
if err != nil {
return err
}
m.usedRunners.Delete(r.Id())
return
}
// Refresh Big ToDo: Improve this function!! State out that it also rescales the job; Provide context to be terminable...
func (m *NomadRunnerManager) refreshEnvironment(id EnvironmentId) {
job := m.jobs[id]
lastJobScaling := -1
for {
runners, err := m.apiClient.LoadRunners(string(job.jobId))
if err != nil {
log.WithError(err).Printf("Failed fetching runners")
break
}
for _, r := range m.unusedRunners(id, runners) {
// ToDo: Listen on Nomad event stream
log.Printf("Adding allocation %+v", r)
job.idleRunners.Add(r)
}
jobScale, err := m.apiClient.JobScale(string(job.jobId))
if err != nil {
log.WithError(err).Printf("Failed get allocation count")
break
}
neededRunners := job.desiredIdleRunnersCount - job.idleRunners.Len() + 1
runnerCount := jobScale + neededRunners
time.Sleep(50 * time.Millisecond)
if runnerCount != lastJobScaling {
log.Printf("Set job scaling %d", runnerCount)
err = m.apiClient.SetJobScale(string(job.jobId), runnerCount, "Runner Requested")
if err != nil {
log.WithError(err).Printf("Failed set allocation scaling")
continue
}
lastJobScaling = runnerCount
}
}
}
func (m *NomadRunnerManager) unusedRunners(environmentId EnvironmentId, fetchedRunnerIds []string) (newRunners []Runner) {
newRunners = make([]Runner, 0)
for _, runnerId := range fetchedRunnerIds {
_, ok := m.usedRunners.Get(runnerId)
if !ok {
_, ok = m.jobs[environmentId].idleRunners.Get(runnerId)
if !ok {
newRunners = append(newRunners, NewRunner(runnerId))
}
}
}
return
}

75
runner/manager_mock.go Normal file
View File

@ -0,0 +1,75 @@
// Code generated by mockery v0.0.0-dev. DO NOT EDIT.
package runner
import mock "github.com/stretchr/testify/mock"
// ManagerMock is an autogenerated mock type for the Manager type
type ManagerMock struct {
mock.Mock
}
// Get provides a mock function with given fields: runnerId
func (_m *ManagerMock) Get(runnerId string) (Runner, error) {
ret := _m.Called(runnerId)
var r0 Runner
if rf, ok := ret.Get(0).(func(string) Runner); ok {
r0 = rf(runnerId)
} else {
if ret.Get(0) != nil {
r0 = ret.Get(0).(Runner)
}
}
var r1 error
if rf, ok := ret.Get(1).(func(string) error); ok {
r1 = rf(runnerId)
} else {
r1 = ret.Error(1)
}
return r0, r1
}
// RegisterEnvironment provides a mock function with given fields: environmentId, nomadJobId, desiredIdleRunnersCount
func (_m *ManagerMock) RegisterEnvironment(environmentId EnvironmentId, nomadJobId NomadJobId, desiredIdleRunnersCount int) {
_m.Called(environmentId, nomadJobId, desiredIdleRunnersCount)
}
// Return provides a mock function with given fields: r
func (_m *ManagerMock) Return(r Runner) error {
ret := _m.Called(r)
var r0 error
if rf, ok := ret.Get(0).(func(Runner) error); ok {
r0 = rf(r)
} else {
r0 = ret.Error(0)
}
return r0
}
// Use provides a mock function with given fields: id
func (_m *ManagerMock) Use(id EnvironmentId) (Runner, error) {
ret := _m.Called(id)
var r0 Runner
if rf, ok := ret.Get(0).(func(EnvironmentId) Runner); ok {
r0 = rf(id)
} else {
if ret.Get(0) != nil {
r0 = ret.Get(0).(Runner)
}
}
var r1 error
if rf, ok := ret.Get(1).(func(EnvironmentId) error); ok {
r1 = rf(id)
} else {
r1 = ret.Error(1)
}
return r0, r1
}

172
runner/manager_test.go Normal file
View File

@ -0,0 +1,172 @@
package runner
import (
"errors"
"github.com/stretchr/testify/mock"
"github.com/stretchr/testify/suite"
"gitlab.hpi.de/codeocean/codemoon/poseidon/nomad"
"testing"
"time"
)
const anotherRunnerId = "4n0th3r-runn3r-1d"
const defaultEnvironmentId = EnvironmentId(0)
const otherEnvironmentId = EnvironmentId(42)
const jobId = "4n0th3r-j0b-1d"
const waitTime = 100 * time.Millisecond
func TestGetNextRunnerTestSuite(t *testing.T) {
suite.Run(t, new(ManagerTestSuite))
}
type ManagerTestSuite struct {
suite.Suite
apiMock *nomad.ExecutorApiMock
nomadRunnerManager *NomadRunnerManager
exerciseRunner Runner
}
func (suite *ManagerTestSuite) setUp(returnedRunnerIds []string) {
suite.apiMock = &nomad.ExecutorApiMock{}
suite.nomadRunnerManager = NewNomadRunnerManager(suite.apiMock)
suite.exerciseRunner = CreateTestRunner()
suite.mockRunnerQueries(returnedRunnerIds)
suite.registerDefaultEnvironment()
}
func (suite *ManagerTestSuite) mockRunnerQueries(returnedRunnerIds []string) {
suite.apiMock.On("LoadRunners", jobId).Return(returnedRunnerIds, nil)
suite.apiMock.On("JobScale", jobId).Return(len(returnedRunnerIds), nil)
suite.apiMock.On("SetJobScale", jobId, mock.AnythingOfType("int"), "Runner Requested").Return(nil)
}
func (suite *ManagerTestSuite) registerDefaultEnvironment() {
suite.nomadRunnerManager.RegisterEnvironment(defaultEnvironmentId, jobId, 5)
}
func (suite *ManagerTestSuite) TestRegisterEnvironmentAddsANewJob() {
suite.NotNil(suite.nomadRunnerManager.jobs[defaultEnvironmentId])
}
func (suite *ManagerTestSuite) TestUseReturnsNotFoundErrorIfEnvironmentNotFound() {
runner, err := suite.nomadRunnerManager.Use(EnvironmentId(42))
suite.Nil(runner)
suite.Equal(ErrUnknownExecutionEnvironment, err)
}
func (suite *ManagerTestSuite) TestUseReturnsRunnerIfAvailable() {
suite.nomadRunnerManager.jobs[defaultEnvironmentId].idleRunners.Add(suite.exerciseRunner)
receivedRunner, err := suite.nomadRunnerManager.Use(defaultEnvironmentId)
suite.NoError(err)
suite.Equal(suite.exerciseRunner, receivedRunner)
}
func (suite *ManagerTestSuite) TestUseReturnsErrorIfNoRunnerAvailable() {
suite.setUp([]string{})
time.Sleep(waitTime)
runner, err := suite.nomadRunnerManager.Use(defaultEnvironmentId)
suite.Nil(runner)
suite.Equal(ErrNoRunnersAvailable, err)
}
func (suite *ManagerTestSuite) TestUseReturnsNoRunnerOfDifferentEnvironment() {
suite.setUp([]string{})
suite.nomadRunnerManager.jobs[defaultEnvironmentId].idleRunners.Add(suite.exerciseRunner)
receivedRunner, err := suite.nomadRunnerManager.Use(otherEnvironmentId)
suite.Nil(receivedRunner)
suite.Error(err)
}
func (suite *ManagerTestSuite) TestUseDoesNotReturnTheSameRunnerTwice() {
suite.nomadRunnerManager.jobs[defaultEnvironmentId].idleRunners.Add(suite.exerciseRunner)
suite.nomadRunnerManager.jobs[defaultEnvironmentId].idleRunners.Add(NewRunner(anotherRunnerId))
firstReceivedRunner, _ := suite.nomadRunnerManager.Use(defaultEnvironmentId)
secondReceivedRunner, _ := suite.nomadRunnerManager.Use(defaultEnvironmentId)
suite.NotEqual(firstReceivedRunner, secondReceivedRunner)
}
func (suite *ManagerTestSuite) TestUseThrowsAnErrorIfNoRunnersAvailable() {
receivedRunner, err := suite.nomadRunnerManager.Use(defaultEnvironmentId)
suite.Nil(receivedRunner)
suite.Error(err)
}
func (suite *ManagerTestSuite) TestUseAddsRunnerToUsedRunners() {
suite.setUp([]string{RunnerId})
time.Sleep(waitTime)
receivedRunner, _ := suite.nomadRunnerManager.Use(defaultEnvironmentId)
savedRunner, ok := suite.nomadRunnerManager.usedRunners.Get(receivedRunner.Id())
suite.True(ok)
suite.Equal(savedRunner, receivedRunner)
}
func (suite *ManagerTestSuite) TestGetReturnsRunnerIfRunnerIsUsed() {
suite.setUp([]string{})
suite.nomadRunnerManager.usedRunners.Add(suite.exerciseRunner)
savedRunner, err := suite.nomadRunnerManager.Get(suite.exerciseRunner.Id())
suite.NoError(err)
suite.Equal(savedRunner, suite.exerciseRunner)
}
func (suite *ManagerTestSuite) TestGetReturnsErrorIfRunnerNotFound() {
suite.setUp([]string{})
savedRunner, err := suite.nomadRunnerManager.Get(RunnerId)
suite.Nil(savedRunner)
suite.Error(err)
}
func (suite *ManagerTestSuite) TestReturnRemovesRunnerFromUsedRunners() {
suite.setUp([]string{})
suite.apiMock.On("DeleteRunner", mock.AnythingOfType("string")).Return(nil)
suite.nomadRunnerManager.usedRunners.Add(suite.exerciseRunner)
err := suite.nomadRunnerManager.Return(suite.exerciseRunner)
suite.Nil(err)
_, ok := suite.nomadRunnerManager.usedRunners.Get(suite.exerciseRunner.Id())
suite.False(ok)
}
func (suite *ManagerTestSuite) TestReturnCallsDeleteRunnerApiMethod() {
suite.setUp([]string{})
suite.apiMock.On("DeleteRunner", mock.AnythingOfType("string")).Return(nil)
err := suite.nomadRunnerManager.Return(suite.exerciseRunner)
suite.Nil(err)
suite.apiMock.AssertCalled(suite.T(), "DeleteRunner", suite.exerciseRunner.Id())
}
func (suite *ManagerTestSuite) TestReturnThrowsErrorWhenApiCallFailed() {
suite.setUp([]string{})
suite.apiMock.On("DeleteRunner", mock.AnythingOfType("string")).Return(errors.New("return failed"))
err := suite.nomadRunnerManager.Return(suite.exerciseRunner)
suite.Error(err)
}
func (suite *ManagerTestSuite) TestRefreshFetchesRunners() {
suite.setUp([]string{RunnerId})
time.Sleep(waitTime)
suite.apiMock.AssertCalled(suite.T(), "LoadRunners", jobId)
}
func (suite *ManagerTestSuite) TestNewRunnersFoundInRefreshAreAddedToUnusedRunners() {
suite.setUp([]string{RunnerId})
time.Sleep(waitTime)
availableRunner, _ := suite.nomadRunnerManager.Use(defaultEnvironmentId)
suite.Equal(availableRunner.Id(), RunnerId)
}
func (suite *ManagerTestSuite) TestRefreshScalesJob() {
suite.setUp([]string{RunnerId})
time.Sleep(waitTime)
// use one runner
_, _ = suite.nomadRunnerManager.Use(defaultEnvironmentId)
time.Sleep(waitTime)
suite.apiMock.AssertCalled(suite.T(), "SetJobScale", jobId, 6, "Runner Requested")
}
func (suite *ManagerTestSuite) TestRefreshAddsRunnerToPool() {
suite.setUp([]string{RunnerId})
time.Sleep(waitTime)
poolRunner, ok := suite.nomadRunnerManager.jobs[defaultEnvironmentId].idleRunners.Get(RunnerId)
suite.True(ok)
suite.Equal(RunnerId, poolRunner.Id())
}

View File

@ -1,35 +1,38 @@
package environment package runner
import ( import (
"gitlab.hpi.de/codeocean/codemoon/poseidon/runner"
"gitlab.hpi.de/codeocean/codemoon/poseidon/store" "gitlab.hpi.de/codeocean/codemoon/poseidon/store"
"sync" "sync"
) )
// RunnerPool is a type of entity store that should store runner entities. // Pool is a type of entity store that should store runner entities.
type RunnerPool interface { type Pool interface {
store.EntityStore store.EntityStore
// Sample returns and removes an arbitrary entity from the pool.
// ok is true iff a runner was returned.
Sample() (r Runner, ok bool)
} }
// localRunnerPool stores runner objects in the local application memory. // localRunnerPool stores runner objects in the local application memory.
// ToDo: Create implementation that use some persistent storage like a database // ToDo: Create implementation that use some persistent storage like a database
type localRunnerPool struct { type localRunnerPool struct {
sync.RWMutex sync.RWMutex
runners map[string]runner.Runner runners map[string]Runner
} }
// NewLocalRunnerPool responds with a RunnerPool implementation // NewLocalRunnerPool responds with a Pool implementation
// This implementation stores the data thread-safe in the local application memory // This implementation stores the data thread-safe in the local application memory
func NewLocalRunnerPool() *localRunnerPool { func NewLocalRunnerPool() *localRunnerPool {
return &localRunnerPool{ return &localRunnerPool{
runners: make(map[string]runner.Runner), runners: make(map[string]Runner),
} }
} }
func (pool *localRunnerPool) Add(r store.Entity) { func (pool *localRunnerPool) Add(r store.Entity) {
pool.Lock() pool.Lock()
defer pool.Unlock() defer pool.Unlock()
runnerEntity, ok := r.(runner.Runner) runnerEntity, ok := r.(Runner)
if !ok { if !ok {
log. log.
WithField("pool", pool). WithField("pool", pool).
@ -51,3 +54,17 @@ func (pool *localRunnerPool) Delete(id string) {
defer pool.Unlock() defer pool.Unlock()
delete(pool.runners, id) delete(pool.runners, id)
} }
func (pool *localRunnerPool) Sample() (Runner, bool) {
pool.Lock()
defer pool.Unlock()
for _, runner := range pool.runners {
delete(pool.runners, runner.Id())
return runner, true
}
return nil, false
}
func (pool *localRunnerPool) Len() int {
return len(pool.runners)
}

View File

@ -1,10 +1,9 @@
package environment package runner
import ( import (
"github.com/sirupsen/logrus" "github.com/sirupsen/logrus"
"github.com/sirupsen/logrus/hooks/test" "github.com/sirupsen/logrus/hooks/test"
"github.com/stretchr/testify/suite" "github.com/stretchr/testify/suite"
"gitlab.hpi.de/codeocean/codemoon/poseidon/runner"
"testing" "testing"
) )
@ -21,7 +20,7 @@ func TestRunnerPoolTestSuite(t *testing.T) {
type RunnerPoolTestSuite struct { type RunnerPoolTestSuite struct {
suite.Suite suite.Suite
runnerPool *localRunnerPool runnerPool *localRunnerPool
runner runner.Runner runner Runner
} }
func (suite *RunnerPoolTestSuite) SetupTest() { func (suite *RunnerPoolTestSuite) SetupTest() {
@ -60,7 +59,7 @@ func (suite *RunnerPoolTestSuite) TestAddedRunnerCanBeRetrieved() {
} }
func (suite *RunnerPoolTestSuite) TestRunnerWithSameIdOverwritesOldOne() { func (suite *RunnerPoolTestSuite) TestRunnerWithSameIdOverwritesOldOne() {
otherRunnerWithSameId := runner.NewExerciseRunner(suite.runner.Id()) otherRunnerWithSameId := NewRunner(suite.runner.Id())
// assure runner is actually different // assure runner is actually different
suite.NotEqual(suite.runner, otherRunnerWithSameId) suite.NotEqual(suite.runner, otherRunnerWithSameId)

View File

@ -9,9 +9,6 @@ import (
"sync" "sync"
) )
// Status is the type for the status of a Runner.
type Status string
// ContextKey is the type for keys in a request context. // ContextKey is the type for keys in a request context.
type ContextKey string type ContextKey string
@ -19,11 +16,6 @@ type ContextKey string
type ExecutionId string type ExecutionId string
const ( const (
StatusReady Status = "ready"
StatusRunning Status = "running"
StatusTimeout Status = "timeout"
StatusFinished Status = "finished"
// runnerContextKey is the key used to store runners in context.Context // runnerContextKey is the key used to store runners in context.Context
runnerContextKey ContextKey = "runner" runnerContextKey ContextKey = "runner"
) )
@ -31,37 +23,33 @@ const (
type Runner interface { type Runner interface {
store.Entity store.Entity
// SetStatus sets the status of the runner.
SetStatus(Status)
// Status gets the status of the runner.
Status() Status
// Execution looks up an ExecutionId for the runner and returns the associated RunnerRequest.
// If this request does not exit, ok is false, else true.
Execution(ExecutionId) (request dto.ExecutionRequest, ok bool)
// AddExecution saves the supplied ExecutionRequest for the runner and returns an ExecutionId to retrieve it again. // AddExecution saves the supplied ExecutionRequest for the runner and returns an ExecutionId to retrieve it again.
AddExecution(dto.ExecutionRequest) (ExecutionId, error) AddExecution(dto.ExecutionRequest) (ExecutionId, error)
Execution(ExecutionId) (executionRequest dto.ExecutionRequest, ok bool)
// DeleteExecution deletes the execution of the runner with the specified id. // DeleteExecution deletes the execution of the runner with the specified id.
DeleteExecution(ExecutionId) DeleteExecution(ExecutionId)
// Execute executes the execution with the given ID.
Execute(ExecutionId)
// Copy copies the specified files into the runner.
Copy(dto.FileCreation)
} }
// ExerciseRunner is an abstraction to communicate with Nomad allocations. // NomadAllocation is an abstraction to communicate with Nomad allocations.
type ExerciseRunner struct { type NomadAllocation struct {
sync.RWMutex sync.RWMutex
id string id string
status Status
ch chan bool ch chan bool
executions map[ExecutionId]dto.ExecutionRequest executions map[ExecutionId]dto.ExecutionRequest
} }
// NewExerciseRunner creates a new exercise runner with the provided id. // NewRunner creates a new runner with the provided id.
func NewExerciseRunner(id string) *ExerciseRunner { func NewRunner(id string) Runner {
return &ExerciseRunner{ return &NomadAllocation{
id: id, id: id,
status: StatusReady,
ch: make(chan bool), ch: make(chan bool),
executions: make(map[ExecutionId]dto.ExecutionRequest), executions: make(map[ExecutionId]dto.ExecutionRequest),
} }
@ -69,40 +57,26 @@ func NewExerciseRunner(id string) *ExerciseRunner {
// MarshalJSON implements json.Marshaler interface. // MarshalJSON implements json.Marshaler interface.
// This exports private attributes like the id too. // This exports private attributes like the id too.
func (r *ExerciseRunner) MarshalJSON() ([]byte, error) { func (r *NomadAllocation) MarshalJSON() ([]byte, error) {
return json.Marshal(struct { return json.Marshal(struct {
Id string `json:"runnerId"` Id string `json:"runnerId"`
Status Status `json:"status"`
}{ }{
Id: r.Id(), Id: r.Id(),
Status: r.Status(),
}) })
} }
func (r *ExerciseRunner) SetStatus(status Status) { func (r *NomadAllocation) Id() string {
r.Lock()
defer r.Unlock()
r.status = status
}
func (r *ExerciseRunner) Status() Status {
r.RLock()
defer r.RUnlock()
return r.status
}
func (r *ExerciseRunner) Id() string {
return r.id return r.id
} }
func (r *ExerciseRunner) Execution(id ExecutionId) (executionRequest dto.ExecutionRequest, ok bool) { func (r *NomadAllocation) Execution(id ExecutionId) (executionRequest dto.ExecutionRequest, ok bool) {
r.RLock() r.RLock()
defer r.RUnlock() defer r.RUnlock()
executionRequest, ok = r.executions[id] executionRequest, ok = r.executions[id]
return return
} }
func (r *ExerciseRunner) AddExecution(request dto.ExecutionRequest) (ExecutionId, error) { func (r *NomadAllocation) AddExecution(request dto.ExecutionRequest) (ExecutionId, error) {
r.Lock() r.Lock()
defer r.Unlock() defer r.Unlock()
idUuid, err := uuid.NewRandom() idUuid, err := uuid.NewRandom()
@ -114,7 +88,15 @@ func (r *ExerciseRunner) AddExecution(request dto.ExecutionRequest) (ExecutionId
return id, err return id, err
} }
func (r *ExerciseRunner) DeleteExecution(id ExecutionId) { func (r *NomadAllocation) Execute(id ExecutionId) {
}
func (r *NomadAllocation) Copy(files dto.FileCreation) {
}
func (r *NomadAllocation) DeleteExecution(id ExecutionId) {
r.Lock() r.Lock()
defer r.Unlock() defer r.Unlock()
delete(r.executions, id) delete(r.executions, id)

View File

@ -9,32 +9,19 @@ import (
) )
func TestIdIsStored(t *testing.T) { func TestIdIsStored(t *testing.T) {
runner := NewExerciseRunner("42") runner := NewRunner("42")
assert.Equal(t, "42", runner.Id()) assert.Equal(t, "42", runner.Id())
} }
func TestStatusIsStored(t *testing.T) {
runner := NewExerciseRunner("42")
for _, status := range []Status{StatusReady, StatusRunning, StatusTimeout, StatusFinished} {
runner.SetStatus(status)
assert.Equal(t, status, runner.Status(), "The status is returned as it is stored")
}
}
func TestDefaultStatus(t *testing.T) {
runner := NewExerciseRunner("42")
assert.Equal(t, StatusReady, runner.status)
}
func TestMarshalRunner(t *testing.T) { func TestMarshalRunner(t *testing.T) {
runner := NewExerciseRunner("42") runner := NewRunner("42")
marshal, err := json.Marshal(runner) marshal, err := json.Marshal(runner)
assert.NoError(t, err) assert.NoError(t, err)
assert.Equal(t, "{\"runnerId\":\"42\",\"status\":\"ready\"}", string(marshal)) assert.Equal(t, "{\"runnerId\":\"42\"}", string(marshal))
} }
func TestExecutionRequestIsStored(t *testing.T) { func TestExecutionRequestIsStored(t *testing.T) {
runner := NewExerciseRunner("42") runner := NewRunner("42")
executionRequest := dto.ExecutionRequest{ executionRequest := dto.ExecutionRequest{
Command: "command", Command: "command",
TimeLimit: 10, TimeLimit: 10,
@ -49,7 +36,7 @@ func TestExecutionRequestIsStored(t *testing.T) {
} }
func TestNewContextReturnsNewContextWithRunner(t *testing.T) { func TestNewContextReturnsNewContextWithRunner(t *testing.T) {
runner := NewExerciseRunner("testRunner") runner := NewRunner("testRunner")
ctx := context.Background() ctx := context.Background()
newCtx := NewContext(ctx, runner) newCtx := NewContext(ctx, runner)
storedRunner := newCtx.Value(runnerContextKey).(Runner) storedRunner := newCtx.Value(runnerContextKey).(Runner)
@ -59,7 +46,7 @@ func TestNewContextReturnsNewContextWithRunner(t *testing.T) {
} }
func TestFromContextReturnsRunner(t *testing.T) { func TestFromContextReturnsRunner(t *testing.T) {
runner := NewExerciseRunner("testRunner") runner := NewRunner("testRunner")
ctx := NewContext(context.Background(), runner) ctx := NewContext(context.Background(), runner)
storedRunner, ok := FromContext(ctx) storedRunner, ok := FromContext(ctx)

7
runner/test_constants.go Normal file
View File

@ -0,0 +1,7 @@
package runner
const RunnerId = "s0m3-r4nd0m-1d"
func CreateTestRunner() Runner {
return NewRunner(RunnerId)
}

View File

@ -13,6 +13,9 @@ type EntityStore interface {
// Delete deletes the entity with the passed id from the store. // Delete deletes the entity with the passed id from the store.
Delete(id string) Delete(id string)
// Len returns the number of currently stored entities in the store.
Len() int
} }
type Entity interface { type Entity interface {