added poseidon with aws to k8s changes

This commit is contained in:
Elmar Kresse
2024-08-12 10:02:36 +02:00
parent 5376f7a027
commit 254460d64c
60 changed files with 6912 additions and 0 deletions

98
internal/api/api.go Normal file
View File

@ -0,0 +1,98 @@
package api
import (
"github.com/gorilla/mux"
"github.com/openHPI/poseidon/internal/api/auth"
"github.com/openHPI/poseidon/internal/config"
"github.com/openHPI/poseidon/internal/environment"
"github.com/openHPI/poseidon/internal/runner"
"github.com/openHPI/poseidon/pkg/dto"
"github.com/openHPI/poseidon/pkg/logging"
"github.com/openHPI/poseidon/pkg/monitoring"
"net/http"
)
var log = logging.GetLogger("api")
const (
BasePath = "/api/v1"
HealthPath = "/health"
VersionPath = "/version"
RunnersPath = "/runners"
EnvironmentsPath = "/execution-environments"
StatisticsPath = "/statistics"
)
// NewRouter returns a *mux.Router which can be
// 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
// use gorilla/mux because it is more convenient than net/http, e.g.
// when extracting path parameters.
func NewRouter(runnerManager runner.Manager, environmentManager environment.ManagerHandler) *mux.Router {
router := mux.NewRouter()
// this can later be restricted to a specific host with
// `router.Host(...)` and to HTTPS with `router.Schemes("https")`
configureV1Router(router, runnerManager, environmentManager)
router.Use(logging.HTTPLoggingMiddleware)
router.Use(monitoring.InfluxDB2Middleware)
return router
}
// 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.ManagerHandler) {
router.NotFoundHandler = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
log.WithContext(r.Context()).WithField("request", r).Debug("Not Found Handler")
w.WriteHeader(http.StatusNotFound)
})
v1 := router.PathPrefix(BasePath).Subrouter()
v1.HandleFunc(HealthPath, Health(environmentManager)).Methods(http.MethodGet).Name(HealthPath)
v1.HandleFunc(VersionPath, Version).Methods(http.MethodGet).Name(VersionPath)
runnerController := &RunnerController{manager: runnerManager}
environmentController := &EnvironmentController{manager: environmentManager}
configureRoutes := func(router *mux.Router) {
runnerController.ConfigureRoutes(router)
environmentController.ConfigureRoutes(router)
// May add a statistics controller if another route joins
statisticsRouter := router.PathPrefix(StatisticsPath).Subrouter()
statisticsRouter.
HandleFunc(EnvironmentsPath, StatisticsExecutionEnvironments(environmentManager)).
Methods(http.MethodGet).Name(EnvironmentsPath)
}
if auth.InitializeAuthentication() {
// Create new authenticated subrouter.
// All routes added to v1 after this require authentication.
authenticatedV1Router := v1.PathPrefix("").Subrouter()
authenticatedV1Router.Use(auth.HTTPAuthenticationMiddleware)
configureRoutes(authenticatedV1Router)
} else {
configureRoutes(v1)
}
}
// Version handles the version route.
// It responds the release information stored in the configuration.
func Version(writer http.ResponseWriter, request *http.Request) {
release := config.Config.Sentry.Release
if release != "" {
sendJSON(writer, release, http.StatusOK, request.Context())
} else {
writer.WriteHeader(http.StatusNotFound)
}
}
// StatisticsExecutionEnvironments handles the route for statistics about execution environments.
// It responds the prewarming pool size and the number of idle runners and used runners.
func StatisticsExecutionEnvironments(manager environment.Manager) http.HandlerFunc {
return func(writer http.ResponseWriter, request *http.Request) {
result := make(map[string]*dto.StatisticalExecutionEnvironmentData)
environmentsData := manager.Statistics()
for id, data := range environmentsData {
result[id.ToString()] = data
}
sendJSON(writer, result, http.StatusOK, request.Context())
}
}

38
internal/api/auth/auth.go Normal file
View File

@ -0,0 +1,38 @@
package auth
import (
"crypto/subtle"
"github.com/openHPI/poseidon/internal/config"
"github.com/openHPI/poseidon/pkg/logging"
"net/http"
)
var log = logging.GetLogger("api/auth")
const TokenHeader = "Poseidon-Token"
var correctAuthenticationToken []byte
// InitializeAuthentication returns true iff the authentication is initialized successfully and can be used.
func InitializeAuthentication() bool {
token := config.Config.Server.Token
if token == "" {
return false
}
correctAuthenticationToken = []byte(token)
return true
}
func HTTPAuthenticationMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
token := r.Header.Get(TokenHeader)
if subtle.ConstantTimeCompare([]byte(token), correctAuthenticationToken) == 0 {
log.WithContext(r.Context()).
WithField("token", logging.RemoveNewlineSymbol(token)).
Warn("Incorrect token")
w.WriteHeader(http.StatusUnauthorized)
return
}
next.ServeHTTP(w, r)
})
}

View File

@ -0,0 +1,159 @@
package api
import (
"context"
"encoding/json"
"errors"
"fmt"
"github.com/gorilla/mux"
"github.com/openHPI/poseidon/internal/environment"
"github.com/openHPI/poseidon/internal/runner"
"github.com/openHPI/poseidon/pkg/dto"
"github.com/openHPI/poseidon/pkg/logging"
"net/http"
"strconv"
)
const (
executionEnvironmentIDKey = "executionEnvironmentId"
fetchEnvironmentKey = "fetch"
listRouteName = "list"
getRouteName = "get"
createOrUpdateRouteName = "createOrUpdate"
deleteRouteName = "delete"
)
var ErrMissingURLParameter = errors.New("url parameter missing")
type EnvironmentController struct {
manager environment.ManagerHandler
}
type ExecutionEnvironmentsResponse struct {
ExecutionEnvironments []runner.ExecutionEnvironment `json:"executionEnvironments"`
}
func (e *EnvironmentController) ConfigureRoutes(router *mux.Router) {
environmentRouter := router.PathPrefix(EnvironmentsPath).Subrouter()
environmentRouter.HandleFunc("", e.list).Methods(http.MethodGet).Name(listRouteName)
specificEnvironmentRouter := environmentRouter.Path(fmt.Sprintf("/{%s:[0-9]+}", executionEnvironmentIDKey)).Subrouter()
specificEnvironmentRouter.HandleFunc("", e.get).Methods(http.MethodGet).Name(getRouteName)
specificEnvironmentRouter.HandleFunc("", e.createOrUpdate).Methods(http.MethodPut).Name(createOrUpdateRouteName)
specificEnvironmentRouter.HandleFunc("", e.delete).Methods(http.MethodDelete).Name(deleteRouteName)
}
// list returns all information about available execution environments.
func (e *EnvironmentController) list(writer http.ResponseWriter, request *http.Request) {
fetch, err := parseFetchParameter(request)
if err != nil {
writeClientError(writer, err, http.StatusBadRequest, request.Context())
return
}
environments, err := e.manager.List(fetch)
if err != nil {
writeInternalServerError(writer, err, dto.ErrorUnknown, request.Context())
return
}
sendJSON(writer, ExecutionEnvironmentsResponse{environments}, http.StatusOK, request.Context())
}
// get returns all information about the requested execution environment.
func (e *EnvironmentController) get(writer http.ResponseWriter, request *http.Request) {
environmentID, err := parseEnvironmentID(request)
if err != nil {
// This case is never used as the router validates the id format
writeClientError(writer, err, http.StatusBadRequest, request.Context())
return
}
fetch, err := parseFetchParameter(request)
if err != nil {
writeClientError(writer, err, http.StatusBadRequest, request.Context())
return
}
executionEnvironment, err := e.manager.Get(environmentID, fetch)
if errors.Is(err, runner.ErrUnknownExecutionEnvironment) {
writer.WriteHeader(http.StatusNotFound)
return
} else if err != nil {
writeInternalServerError(writer, err, dto.ErrorUnknown, request.Context())
return
}
sendJSON(writer, executionEnvironment, http.StatusOK, request.Context())
}
// delete removes the specified execution environment.
func (e *EnvironmentController) delete(writer http.ResponseWriter, request *http.Request) {
environmentID, err := parseEnvironmentID(request)
if err != nil {
// This case is never used as the router validates the id format
writeClientError(writer, err, http.StatusBadRequest, request.Context())
return
}
found, err := e.manager.Delete(environmentID)
if err != nil {
writeInternalServerError(writer, err, dto.ErrorUnknown, request.Context())
return
} else if !found {
writer.WriteHeader(http.StatusNotFound)
return
}
writer.WriteHeader(http.StatusNoContent)
}
// createOrUpdate creates/updates an execution environment on the executor.
func (e *EnvironmentController) createOrUpdate(writer http.ResponseWriter, request *http.Request) {
req := new(dto.ExecutionEnvironmentRequest)
if err := json.NewDecoder(request.Body).Decode(req); err != nil {
writeClientError(writer, err, http.StatusBadRequest, request.Context())
return
}
environmentID, err := parseEnvironmentID(request)
if err != nil {
writeClientError(writer, err, http.StatusBadRequest, request.Context())
return
}
var created bool
logging.StartSpan("api.env.update", "Create Environment", request.Context(), func(ctx context.Context) {
created, err = e.manager.CreateOrUpdate(environmentID, *req, ctx)
})
if err != nil {
writeInternalServerError(writer, err, dto.ErrorUnknown, request.Context())
}
if created {
writer.WriteHeader(http.StatusCreated)
} else {
writer.WriteHeader(http.StatusNoContent)
}
}
func parseEnvironmentID(request *http.Request) (dto.EnvironmentID, error) {
id, ok := mux.Vars(request)[executionEnvironmentIDKey]
if !ok {
return 0, fmt.Errorf("could not find %s: %w", executionEnvironmentIDKey, ErrMissingURLParameter)
}
environmentID, err := dto.NewEnvironmentID(id)
if err != nil {
return 0, fmt.Errorf("could not update environment: %w", err)
}
return environmentID, nil
}
func parseFetchParameter(request *http.Request) (fetch bool, err error) {
fetchString := request.FormValue(fetchEnvironmentKey)
if fetchString != "" {
fetch, err = strconv.ParseBool(fetchString)
if err != nil {
return false, fmt.Errorf("could not parse fetch parameter: %w", err)
}
}
return fetch, nil
}

42
internal/api/health.go Normal file
View File

@ -0,0 +1,42 @@
package api
import (
"errors"
"fmt"
"github.com/openHPI/poseidon/internal/config"
"github.com/openHPI/poseidon/internal/environment"
"github.com/openHPI/poseidon/pkg/dto"
"net/http"
"strings"
)
var ErrorPrewarmingPoolDepleting = errors.New("the prewarming pool is depleting")
// Health handles the health route.
// It responds that the server is alive.
// If it is not, the response won't reach the client.
func Health(manager environment.Manager) http.HandlerFunc {
return func(writer http.ResponseWriter, request *http.Request) {
if err := checkPrewarmingPool(manager); err != nil {
sendJSON(writer, &dto.InternalServerError{Message: err.Error(), ErrorCode: dto.PrewarmingPoolDepleting},
http.StatusServiceUnavailable, request.Context())
return
}
writer.WriteHeader(http.StatusNoContent)
}
}
func checkPrewarmingPool(manager environment.Manager) error {
var depletingEnvironments []int
for _, data := range manager.Statistics() {
if float64(data.IdleRunners)/float64(data.PrewarmingPoolSize) < config.Config.Server.Alert.PrewarmingPoolThreshold {
depletingEnvironments = append(depletingEnvironments, data.ID)
}
}
if len(depletingEnvironments) > 0 {
arrayToString := strings.Trim(strings.Join(strings.Fields(fmt.Sprint(depletingEnvironments)), ", "), "[]")
return fmt.Errorf("%w: environments %s", ErrorPrewarmingPoolDepleting, arrayToString)
}
return nil
}

41
internal/api/helpers.go Normal file
View File

@ -0,0 +1,41 @@
package api
import (
"context"
"encoding/json"
"fmt"
"github.com/openHPI/poseidon/pkg/dto"
"net/http"
)
func writeInternalServerError(writer http.ResponseWriter, err error, errorCode dto.ErrorCode, ctx context.Context) {
sendJSON(writer, &dto.InternalServerError{Message: err.Error(), ErrorCode: errorCode},
http.StatusInternalServerError, ctx)
}
func writeClientError(writer http.ResponseWriter, err error, status uint16, ctx context.Context) {
sendJSON(writer, &dto.ClientError{Message: err.Error()}, int(status), ctx)
}
func sendJSON(writer http.ResponseWriter, content interface{}, httpStatusCode int, ctx context.Context) {
writer.Header().Set("Content-Type", "application/json")
writer.WriteHeader(httpStatusCode)
response, err := json.Marshal(content)
if err != nil {
// cannot produce infinite recursive loop, since json.Marshal of dto.InternalServerError won't return an error
writeInternalServerError(writer, err, dto.ErrorUnknown, ctx)
return
}
if _, err = writer.Write(response); err != nil {
log.WithError(err).WithContext(ctx).Error("Could not write JSON response")
http.Error(writer, err.Error(), http.StatusInternalServerError)
}
}
func parseJSONRequestBody(writer http.ResponseWriter, request *http.Request, structure interface{}) error {
if err := json.NewDecoder(request.Body).Decode(structure); err != nil {
writeClientError(writer, err, http.StatusBadRequest, request.Context())
return fmt.Errorf("error parsing JSON request body: %w", err)
}
return nil
}

259
internal/api/runners.go Normal file
View File

@ -0,0 +1,259 @@
package api
import (
"context"
"errors"
"fmt"
"github.com/google/uuid"
"github.com/gorilla/mux"
"github.com/openHPI/poseidon/internal/config"
"github.com/openHPI/poseidon/internal/runner"
"github.com/openHPI/poseidon/pkg/dto"
"github.com/openHPI/poseidon/pkg/logging"
"github.com/openHPI/poseidon/pkg/monitoring"
"io"
"net/http"
"net/url"
"strconv"
"strings"
)
const (
ExecutePath = "/execute"
WebsocketPath = "/websocket"
UpdateFileSystemPath = "/files"
ListFileSystemRouteName = UpdateFileSystemPath + "_list"
FileContentRawPath = UpdateFileSystemPath + "/raw"
ProvideRoute = "provideRunner"
DeleteRoute = "deleteRunner"
RunnerIDKey = "runnerId"
ExecutionIDKey = "executionID"
PathKey = "path"
RecursiveKey = "recursive"
PrivilegedExecutionKey = "privilegedExecution"
)
var ErrForbiddenCharacter = errors.New("use of forbidden character")
type RunnerController struct {
manager runner.Accessor
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(RunnersPath).Subrouter()
runnersRouter.HandleFunc("", r.provide).Methods(http.MethodPost).Name(ProvideRoute)
r.runnerRouter = runnersRouter.PathPrefix(fmt.Sprintf("/{%s}", RunnerIDKey)).Subrouter()
r.runnerRouter.Use(r.findRunnerMiddleware)
r.runnerRouter.HandleFunc(UpdateFileSystemPath, r.listFileSystem).Methods(http.MethodGet).
Name(ListFileSystemRouteName)
r.runnerRouter.HandleFunc(UpdateFileSystemPath, r.updateFileSystem).Methods(http.MethodPatch).
Name(UpdateFileSystemPath)
r.runnerRouter.HandleFunc(FileContentRawPath, r.fileContent).Methods(http.MethodGet).Name(FileContentRawPath)
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("", 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.
func (r *RunnerController) provide(writer http.ResponseWriter, request *http.Request) {
runnerRequest := new(dto.RunnerRequest)
if err := parseJSONRequestBody(writer, request, runnerRequest); err != nil {
return
}
environmentID := dto.EnvironmentID(runnerRequest.ExecutionEnvironmentID)
var nextRunner runner.Runner
var err error
logging.StartSpan("api.runner.claim", "Claim Runner", request.Context(), func(_ context.Context) {
nextRunner, err = r.manager.Claim(environmentID, runnerRequest.InactivityTimeout)
})
if err != nil {
switch {
case errors.Is(err, runner.ErrUnknownExecutionEnvironment):
writeClientError(writer, err, http.StatusNotFound, request.Context())
case errors.Is(err, runner.ErrNoRunnersAvailable):
log.WithContext(request.Context()).Warn("No runners available")
writeInternalServerError(writer, err, dto.Errork8sOverload, request.Context())
default:
writeInternalServerError(writer, err, dto.ErrorUnknown, request.Context())
}
return
}
monitoring.AddRunnerMonitoringData(request, nextRunner.ID(), nextRunner.Environment())
sendJSON(writer, &dto.RunnerResponse{ID: nextRunner.ID(), MappedPorts: nextRunner.MappedPorts()},
http.StatusOK, request.Context())
}
// listFileSystem handles the files API route with the method GET.
// It returns a listing of the file system of the provided runner.
func (r *RunnerController) listFileSystem(writer http.ResponseWriter, request *http.Request) {
targetRunner, _ := runner.FromContext(request.Context())
recursiveRaw := request.URL.Query().Get(RecursiveKey)
recursive, err := strconv.ParseBool(recursiveRaw)
recursive = err != nil || recursive
path := request.URL.Query().Get(PathKey)
if path == "" {
path = "./"
}
privilegedExecution, err := strconv.ParseBool(request.URL.Query().Get(PrivilegedExecutionKey))
if err != nil {
privilegedExecution = false
}
writer.Header().Set("Content-Type", "application/json")
logging.StartSpan("api.fs.list", "List File System", request.Context(), func(ctx context.Context) {
err = targetRunner.ListFileSystem(path, recursive, writer, privilegedExecution, ctx)
})
if errors.Is(err, runner.ErrFileNotFound) {
writeClientError(writer, err, http.StatusFailedDependency, request.Context())
return
} else if err != nil {
log.WithContext(request.Context()).WithError(err).Error("Could not perform the requested listFileSystem.")
writeInternalServerError(writer, err, dto.ErrorUnknown, request.Context())
return
}
}
// 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) {
monitoring.AddRequestSize(request)
fileCopyRequest := new(dto.UpdateFileSystemRequest)
if err := parseJSONRequestBody(writer, request, fileCopyRequest); err != nil {
return
}
targetRunner, _ := runner.FromContext(request.Context())
var err error
logging.StartSpan("api.fs.update", "Update File System", request.Context(), func(ctx context.Context) {
err = targetRunner.UpdateFileSystem(fileCopyRequest, ctx)
})
if err != nil {
log.WithContext(request.Context()).WithError(err).Error("Could not perform the requested updateFileSystem.")
writeInternalServerError(writer, err, dto.ErrorUnknown, request.Context())
return
}
writer.WriteHeader(http.StatusNoContent)
}
func (r *RunnerController) fileContent(writer http.ResponseWriter, request *http.Request) {
targetRunner, _ := runner.FromContext(request.Context())
path := request.URL.Query().Get(PathKey)
privilegedExecution, err := strconv.ParseBool(request.URL.Query().Get(PrivilegedExecutionKey))
if err != nil {
privilegedExecution = false
}
writer.Header().Set("Content-Disposition", "attachment; filename=\""+path+"\"")
logging.StartSpan("api.fs.read", "File Content", request.Context(), func(ctx context.Context) {
err = targetRunner.GetFileContent(path, writer, privilegedExecution, ctx)
})
if errors.Is(err, runner.ErrFileNotFound) {
writeClientError(writer, err, http.StatusFailedDependency, request.Context())
return
} else if err != nil {
log.WithContext(request.Context()).WithError(err).Error("Could not retrieve the requested file.")
writeInternalServerError(writer, err, dto.ErrorUnknown, request.Context())
return
}
}
// 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.
func (r *RunnerController) execute(writer http.ResponseWriter, request *http.Request) {
executionRequest := new(dto.ExecutionRequest)
if err := parseJSONRequestBody(writer, request, executionRequest); err != nil {
return
}
forbiddenCharacters := "'"
if strings.ContainsAny(executionRequest.Command, forbiddenCharacters) {
writeClientError(writer, ErrForbiddenCharacter, http.StatusBadRequest, request.Context())
return
}
var scheme string
if config.Config.Server.TLS.Active {
scheme = "wss"
} else {
scheme = "ws"
}
targetRunner, _ := runner.FromContext(request.Context())
path, err := r.runnerRouter.Get(WebsocketPath).URL(RunnerIDKey, targetRunner.ID())
if err != nil {
log.WithContext(request.Context()).WithError(err).Error("Could not create runner websocket URL.")
writeInternalServerError(writer, err, dto.ErrorUnknown, request.Context())
return
}
newUUID, err := uuid.NewRandom()
if err != nil {
log.WithContext(request.Context()).WithError(err).Error("Could not create execution id")
writeInternalServerError(writer, err, dto.ErrorUnknown, request.Context())
return
}
id := newUUID.String()
logging.StartSpan("api.runner.exec", "Store Execution", request.Context(), func(ctx context.Context) {
targetRunner.StoreExecution(id, executionRequest)
})
webSocketURL := url.URL{
Scheme: scheme,
Host: request.Host,
Path: path.String(),
RawQuery: fmt.Sprintf("%s=%s", ExecutionIDKey, id),
}
sendJSON(writer, &dto.ExecutionResponse{WebSocketURL: webSocketURL.String()}, http.StatusOK, request.Context())
}
// The findRunnerMiddleware looks up the runnerId for routes containing it
// and adds the runner to the context of the request.
func (r *RunnerController) findRunnerMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
runnerID := mux.Vars(request)[RunnerIDKey]
targetRunner, err := r.manager.Get(runnerID)
if err != nil {
// We discard the request body because an early write causes errors for some clients.
// See https://github.com/openHPI/poseidon/issues/54
_, readErr := io.ReadAll(request.Body)
if readErr != nil {
log.WithContext(request.Context()).WithError(readErr).Debug("Failed to discard the request body")
}
writeClientError(writer, err, http.StatusGone, request.Context())
return
}
ctx := runner.NewContext(request.Context(), targetRunner)
ctx = context.WithValue(ctx, dto.ContextKey(dto.KeyRunnerID), targetRunner.ID())
ctx = context.WithValue(ctx, dto.ContextKey(dto.KeyEnvironmentID), targetRunner.Environment().ToString())
requestWithRunner := request.WithContext(ctx)
monitoring.AddRunnerMonitoringData(requestWithRunner, targetRunner.ID(), targetRunner.Environment())
next.ServeHTTP(writer, requestWithRunner)
})
}
// 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())
var err error
logging.StartSpan("api.runner.delete", "Return Runner", request.Context(), func(ctx context.Context) {
err = r.manager.Return(targetRunner)
})
if err != nil {
writeInternalServerError(writer, err, dto.Errork8sInternalServerError, request.Context())
return
}
writer.WriteHeader(http.StatusNoContent)
}

113
internal/api/websocket.go Normal file
View File

@ -0,0 +1,113 @@
package api
import (
"context"
"errors"
"fmt"
"github.com/gorilla/websocket"
"github.com/openHPI/poseidon/internal/api/ws"
"github.com/openHPI/poseidon/internal/runner"
"github.com/openHPI/poseidon/pkg/dto"
"github.com/openHPI/poseidon/pkg/logging"
"net/http"
)
var ErrUnknownExecutionID = errors.New("execution id unknown")
// webSocketProxy is an encapsulation of logic for forwarding between Runners and CodeOcean.
type webSocketProxy struct {
ctx context.Context
Input ws.WebSocketReader
Output ws.WebSocketWriter
}
// upgradeConnection upgrades a connection to a websocket and returns a webSocketProxy for this connection.
func upgradeConnection(writer http.ResponseWriter, request *http.Request) (ws.Connection, error) {
connUpgrader := websocket.Upgrader{}
connection, err := connUpgrader.Upgrade(writer, request, nil)
if err != nil {
log.WithContext(request.Context()).WithError(err).Warn("Connection upgrade failed")
return nil, fmt.Errorf("error upgrading the connection: %w", err)
}
return connection, nil
}
// newWebSocketProxy returns an initiated and started webSocketProxy.
// As this proxy is already started, a start message is send to the client.
func newWebSocketProxy(connection ws.Connection, proxyCtx context.Context) *webSocketProxy {
// wsCtx is detached from the proxyCtx
// as it should send all messages in the buffer even shortly after the execution/proxy is done.
wsCtx := context.WithoutCancel(proxyCtx)
wsCtx, cancelWsCommunication := context.WithCancel(wsCtx)
proxy := &webSocketProxy{
ctx: wsCtx,
Input: ws.NewCodeOceanToRawReader(connection, wsCtx, proxyCtx),
Output: ws.NewCodeOceanOutputWriter(connection, wsCtx, cancelWsCommunication),
}
connection.SetCloseHandler(func(code int, text string) error {
log.WithContext(wsCtx).WithField("code", code).WithField("text", text).Debug("The client closed the connection.")
cancelWsCommunication()
return nil
})
return proxy
}
// waitForExit waits for an exit of either the runner (when the command terminates) or the client closing the WebSocket
// and handles WebSocket exit messages.
func (wp *webSocketProxy) waitForExit(exit <-chan runner.ExitInfo, cancelExecution context.CancelFunc) {
wp.Input.Start()
var exitInfo runner.ExitInfo
select {
case <-wp.ctx.Done():
log.WithContext(wp.ctx).Info("Client closed the connection")
wp.Input.Stop()
wp.Output.Close(nil)
cancelExecution()
<-exit // /internal/runner/runner.go handleExitOrContextDone does not require client connection anymore.
<-exit // The goroutine closes this channel indicating that it does not use the connection to the executor anymore.
case exitInfo = <-exit:
log.WithContext(wp.ctx).Info("Execution returned")
wp.Input.Stop()
wp.Output.Close(&exitInfo)
}
}
// connectToRunner is the endpoint for websocket connections.
func (r *RunnerController) connectToRunner(writer http.ResponseWriter, request *http.Request) {
targetRunner, _ := runner.FromContext(request.Context())
executionID := request.URL.Query().Get(ExecutionIDKey)
if !targetRunner.ExecutionExists(executionID) {
writeClientError(writer, ErrUnknownExecutionID, http.StatusNotFound, request.Context())
return
}
connection, err := upgradeConnection(writer, request)
if err != nil {
writeInternalServerError(writer, err, dto.ErrorUnknown, request.Context())
return
}
// We do not inherit from the request.Context() here because we rely on the WebSocket Close Handler.
proxyCtx := context.WithoutCancel(request.Context())
proxyCtx, cancelProxy := context.WithCancel(proxyCtx)
defer cancelProxy()
proxy := newWebSocketProxy(connection, proxyCtx)
log.WithContext(proxyCtx).
WithField("executionID", logging.RemoveNewlineSymbol(executionID)).
Info("Running execution")
logging.StartSpan("api.runner.connect", "Execute Interactively", request.Context(), func(ctx context.Context) {
exit, cancel, err := targetRunner.ExecuteInteractively(executionID,
proxy.Input, proxy.Output.StdOut(), proxy.Output.StdErr(), ctx)
if err != nil {
log.WithContext(ctx).WithError(err).Warn("Cannot execute request.")
return // The proxy is stopped by the deferred cancel.
}
proxy.waitForExit(exit, cancel)
})
}

View File

@ -0,0 +1,177 @@
package ws
import (
"context"
"github.com/gorilla/websocket"
"github.com/openHPI/poseidon/pkg/logging"
"io"
)
const CodeOceanToRawReaderBufferSize = 1024
var log = logging.GetLogger("ws")
// WebSocketReader is an interface that is intended for providing abstraction around reading from a WebSocket.
// Besides, io.Reader, it also implements io.Writer. The Write method is used to inject data into the WebSocket stream.
type WebSocketReader interface {
io.Reader
io.Writer
Start()
Stop()
}
// codeOceanToRawReader is an io.Reader implementation that provides the content of the WebSocket connection
// to CodeOcean. You have to start the Reader by calling readInputLoop. After that you can use the Read function.
type codeOceanToRawReader struct {
connection Connection
// readCtx is the context in that messages from CodeOcean are read.
readCtx context.Context
cancelReadCtx context.CancelFunc
// executorCtx is the context in that messages are forwarded to the executor.
executorCtx context.Context
// A buffered channel of bytes is used to store data coming from CodeOcean via WebSocket
// and retrieve it when Read(...) is called. Since channels are thread-safe, we use one here
// instead of bytes.Buffer.
buffer chan byte
// The priorityBuffer is a buffer for injecting data into stdin of the execution from Poseidon,
// for example the character that causes the tty to generate a SIGQUIT signal.
// It is always read before the regular buffer.
priorityBuffer chan byte
}
func NewCodeOceanToRawReader(connection Connection, wsCtx, executorCtx context.Context) *codeOceanToRawReader {
return &codeOceanToRawReader{
connection: connection,
readCtx: wsCtx, // This context may be canceled before the executorCtx.
cancelReadCtx: func() {},
executorCtx: executorCtx,
buffer: make(chan byte, CodeOceanToRawReaderBufferSize),
priorityBuffer: make(chan byte, CodeOceanToRawReaderBufferSize),
}
}
// readInputLoop reads from the WebSocket connection and buffers the user's input.
// This is necessary because input must be read for the connection to handle special messages like close and call the
// CloseHandler.
func (cr *codeOceanToRawReader) readInputLoop(ctx context.Context) {
readMessage := make(chan bool)
loopContext, cancelInputLoop := context.WithCancel(ctx)
defer cancelInputLoop()
readingContext, cancelNextMessage := context.WithCancel(loopContext)
defer cancelNextMessage()
for loopContext.Err() == nil {
var messageType int
var reader io.Reader
var err error
go func() {
messageType, reader, err = cr.connection.NextReader()
select {
case <-readingContext.Done():
case readMessage <- true:
}
}()
select {
case <-loopContext.Done():
return
case <-readMessage:
}
if inputContainsError(messageType, err, loopContext) {
return
}
if handleInput(reader, cr.buffer, loopContext) {
return
}
}
}
// handleInput receives a new message from the client and may forward it to the executor.
func handleInput(reader io.Reader, buffer chan byte, ctx context.Context) (done bool) {
message, err := io.ReadAll(reader)
if err != nil {
log.WithContext(ctx).WithError(err).Warn("error while reading WebSocket message")
return true
}
log.WithContext(ctx).WithField("message", string(message)).Trace("Received message from client")
for _, character := range message {
select {
case <-ctx.Done():
return true
case buffer <- character:
}
}
return false
}
func inputContainsError(messageType int, err error, ctx context.Context) (done bool) {
if err != nil && websocket.IsCloseError(err, websocket.CloseNormalClosure) {
log.WithContext(ctx).Debug("ReadInputLoop: The client closed the connection!")
// The close handler will do something soon.
return true
} else if err != nil {
log.WithContext(ctx).WithError(err).Warn("Error reading client message")
return true
}
if messageType != websocket.TextMessage {
log.WithContext(ctx).WithField("messageType", messageType).Warn("Received message of wrong type")
return true
}
return false
}
// Start starts the read input loop asynchronously.
func (cr *codeOceanToRawReader) Start() {
ctx, cancel := context.WithCancel(cr.readCtx)
cr.cancelReadCtx = cancel
go cr.readInputLoop(ctx)
}
// Stop stops the asynchronous read input loop.
func (cr *codeOceanToRawReader) Stop() {
cr.cancelReadCtx()
}
// Read implements the io.Reader interface.
// It returns bytes from the buffer or priorityBuffer.
func (cr *codeOceanToRawReader) Read(p []byte) (int, error) {
if len(p) == 0 {
return 0, nil
}
// Ensure to not return until at least one byte has been read to avoid busy waiting.
select {
case <-cr.executorCtx.Done():
return 0, io.EOF
case p[0] = <-cr.priorityBuffer:
case p[0] = <-cr.buffer:
}
var n int
for n = 1; n < len(p); n++ {
select {
case p[n] = <-cr.priorityBuffer:
case p[n] = <-cr.buffer:
default:
return n, nil
}
}
return n, nil
}
// Write implements the io.Writer interface.
// Data written to a codeOceanToRawReader using this method is returned by Read before other data from the WebSocket.
func (cr *codeOceanToRawReader) Write(p []byte) (n int, err error) {
var c byte
for n, c = range p {
select {
case cr.priorityBuffer <- c:
default:
break
}
}
return n, nil
}

View File

@ -0,0 +1,174 @@
package ws
import (
"context"
"encoding/json"
"errors"
"fmt"
"github.com/gorilla/websocket"
"github.com/openHPI/poseidon/internal/runner"
"github.com/openHPI/poseidon/pkg/dto"
"io"
)
// CodeOceanOutputWriterBufferSize defines the number of messages.
const CodeOceanOutputWriterBufferSize = 64
// rawToCodeOceanWriter is a simple io.Writer implementation that just forwards the call to sendMessage.
type rawToCodeOceanWriter struct {
outputType dto.WebSocketMessageType
sendMessage func(*dto.WebSocketMessage)
ctx context.Context
}
// Write implements the io.Writer interface.
func (rc *rawToCodeOceanWriter) Write(p []byte) (int, error) {
switch {
case rc.ctx.Err() != nil:
return 0, fmt.Errorf("CodeOceanWriter context done: %w", rc.ctx.Err())
case len(p) == 0:
return 0, nil
default:
rc.sendMessage(&dto.WebSocketMessage{Type: rc.outputType, Data: string(p)})
return len(p), nil
}
}
// WebSocketWriter is an interface that defines which data is required and which information can be passed.
type WebSocketWriter interface {
StdOut() io.Writer
StdErr() io.Writer
Close(info *runner.ExitInfo)
}
// codeOceanOutputWriter is a concrete WebSocketWriter implementation.
// It forwards the data written to stdOut or stdErr (Nomad, AWS) to the WebSocket connection (CodeOcean).
type codeOceanOutputWriter struct {
connection Connection
stdOut *rawToCodeOceanWriter
stdErr *rawToCodeOceanWriter
queue chan *writingLoopMessage
ctx context.Context
}
// writingLoopMessage is an internal data structure to notify the writing loop when it should stop.
type writingLoopMessage struct {
done bool
data *dto.WebSocketMessage
}
// NewCodeOceanOutputWriter provides an codeOceanOutputWriter for the time the context ctx is active.
// The codeOceanOutputWriter handles all the messages defined in the websocket.schema.json (start, timeout, stdout, ...).
func NewCodeOceanOutputWriter(
connection Connection, ctx context.Context, done context.CancelFunc) WebSocketWriter {
cw := &codeOceanOutputWriter{
connection: connection,
queue: make(chan *writingLoopMessage, CodeOceanOutputWriterBufferSize),
ctx: ctx,
}
cw.stdOut = &rawToCodeOceanWriter{
outputType: dto.WebSocketOutputStdout,
sendMessage: cw.send,
ctx: ctx,
}
cw.stdErr = &rawToCodeOceanWriter{
outputType: dto.WebSocketOutputStderr,
sendMessage: cw.send,
ctx: ctx,
}
go cw.startWritingLoop(done)
cw.send(&dto.WebSocketMessage{Type: dto.WebSocketMetaStart})
return cw
}
// StdOut provides an io.Writer that forwards the written data to CodeOcean as StdOut stream.
func (cw *codeOceanOutputWriter) StdOut() io.Writer {
return cw.stdOut
}
// StdErr provides an io.Writer that forwards the written data to CodeOcean as StdErr stream.
func (cw *codeOceanOutputWriter) StdErr() io.Writer {
return cw.stdErr
}
// Close forwards the kind of exit (timeout, error, normal) to CodeOcean.
// This results in the closing of the WebSocket connection.
// The call of Close is mandatory!
func (cw *codeOceanOutputWriter) Close(info *runner.ExitInfo) {
defer func() { cw.queue <- &writingLoopMessage{done: true} }()
// Mask the internal stop reason before disclosing/forwarding it externally/to CodeOcean.
switch {
case info == nil:
return
case info.Err == nil:
cw.send(&dto.WebSocketMessage{Type: dto.WebSocketExit, ExitCode: info.Code})
case errors.Is(info.Err, runner.ErrorExecutionTimeout) || errors.Is(info.Err, runner.ErrorRunnerInactivityTimeout):
cw.send(&dto.WebSocketMessage{Type: dto.WebSocketMetaTimeout})
case errors.Is(info.Err, runner.ErrOOMKilled):
cw.send(&dto.WebSocketMessage{Type: dto.WebSocketOutputError, Data: dto.ErrOOMKilled.Error()})
case errors.Is(info.Err, runner.ErrDestroyedByAPIRequest):
message := "the allocation stopped as expected"
log.WithContext(cw.ctx).WithError(info.Err).Trace(message)
cw.send(&dto.WebSocketMessage{Type: dto.WebSocketOutputError, Data: message})
default:
errorMessage := "Error executing the request"
log.WithContext(cw.ctx).WithError(info.Err).Warn(errorMessage)
cw.send(&dto.WebSocketMessage{Type: dto.WebSocketOutputError, Data: errorMessage})
}
}
// send forwards the passed dto.WebSocketMessage to the writing loop.
func (cw *codeOceanOutputWriter) send(message *dto.WebSocketMessage) {
cw.queue <- &writingLoopMessage{done: false, data: message}
}
// startWritingLoop enables the writing loop.
// This is the central and only place where written changes to the WebSocket connection should be done.
// It synchronizes the messages to provide state checks of the WebSocket connection.
func (cw *codeOceanOutputWriter) startWritingLoop(writingLoopDone context.CancelFunc) {
defer func() {
message := websocket.FormatCloseMessage(websocket.CloseNormalClosure, "")
err := cw.connection.WriteMessage(websocket.CloseMessage, message)
err2 := cw.connection.Close()
if err != nil || err2 != nil {
log.WithContext(cw.ctx).WithError(err).WithField("err2", err2).Warn("Error during websocket close")
}
}()
for {
message := <-cw.queue
done := true
if message.data != nil {
done = sendMessage(cw.connection, message.data, cw.ctx)
}
if done || message.done {
log.WithContext(cw.ctx).Trace("Writing loop done")
writingLoopDone()
return
}
}
}
// sendMessage is a helper function for the writing loop. It must not be called from somewhere else!
func sendMessage(connection Connection, message *dto.WebSocketMessage, ctx context.Context) (done bool) {
if message == nil {
return false
}
encodedMessage, err := json.Marshal(message)
if err != nil {
log.WithContext(ctx).WithField("message", message).WithError(err).Warn("Marshal error")
return false
}
log.WithContext(ctx).WithField("message", message).Trace("Sending message to client")
err = connection.WriteMessage(websocket.TextMessage, encodedMessage)
if err != nil {
errorMessage := "Error writing the message"
log.WithContext(ctx).WithField("message", message).WithError(err).Warn(errorMessage)
return true
}
return false
}

View File

@ -0,0 +1,14 @@
package ws
import (
"io"
)
// Connection is an internal interface for websocket.Conn in order to mock it for unit tests.
type Connection interface {
WriteMessage(messageType int, data []byte) error
Close() error
NextReader() (messageType int, r io.Reader, err error)
CloseHandler() func(code int, text string) error
SetCloseHandler(handler func(code int, text string) error)
}

287
internal/config/config.go Normal file
View File

@ -0,0 +1,287 @@
package config
import (
"crypto/rand"
"crypto/tls"
"encoding/base64"
"errors"
"flag"
"fmt"
"github.com/getsentry/sentry-go"
"github.com/openHPI/poseidon/pkg/dto"
"github.com/openHPI/poseidon/pkg/logging"
"github.com/sirupsen/logrus"
"gopkg.in/yaml.v3"
"k8s.io/client-go/rest"
"net/url"
"os"
"reflect"
"strconv"
"strings"
)
const (
defaultPoseidonPort = 7200
defaultNomadPort = 4646
defaultMemoryUsageAlertThreshold = 1_000
)
// Config contains the default configuration of Poseidon.
var (
Config = &configuration{
Server: server{
Address: "127.0.0.1",
Port: defaultPoseidonPort,
SystemdSocketActivation: false,
Token: "",
TLS: TLS{
Active: false,
CAFile: "",
CertFile: "",
KeyFile: "",
},
InteractiveStderr: true,
TemplateJobFile: "",
Alert: alert{
PrewarmingPoolThreshold: 0,
PrewarmingPoolReloadTimeout: 0,
},
LoggingFilterToken: randomFilterToken(),
},
AWS: AWS{
Enabled: false,
Endpoint: "",
Functions: []string{},
},
Kubernetes: Kubernetes{
Enabled: false,
Address: "",
Port: 0,
Token: "",
},
Logger: Logger{
Level: "INFO",
Formatter: dto.FormatterText,
},
Profiling: Profiling{
MemoryThreshold: defaultMemoryUsageAlertThreshold,
},
Sentry: sentry.ClientOptions{
AttachStacktrace: true,
},
InfluxDB: InfluxDB{
URL: "",
Token: "",
Organization: "",
Bucket: "",
Stage: "",
},
}
configurationFilePath = "./configuration.yaml"
configurationInitialized = false
log = logging.GetLogger("config")
TLSConfig = &tls.Config{
MinVersion: tls.VersionTLS13,
CurvePreferences: []tls.CurveID{tls.CurveP521, tls.CurveP384, tls.CurveP256},
}
ErrConfigInitialized = errors.New("configuration is already initialized")
)
type alert struct {
PrewarmingPoolThreshold float64
PrewarmingPoolReloadTimeout uint
}
// server configures the Poseidon webserver.
type server struct {
Address string
Port int
SystemdSocketActivation bool
Token string
TLS TLS
InteractiveStderr bool
TemplateJobFile string
Alert alert
LoggingFilterToken string
}
// URL returns the URL of the Poseidon webserver.
func (s *server) URL() *url.URL {
return parseURL(s.Address, s.Port, s.TLS.Active)
}
// AWS configures the AWS Lambda usage.
type AWS struct {
Enabled bool
Endpoint string
Functions []string
}
type Kubernetes struct {
Enabled bool
Namespace string
Config *rest.Config
Images []string
}
// TLS configures TLS on a connection.
type TLS struct {
Active bool
CAFile string
CertFile string
KeyFile string
}
// Logger configures the used Logger.
type Logger struct {
Formatter dto.Formatter
Level string
}
// Profiling configures the usage of a runtime profiler to create optimized binaries.
type Profiling struct {
CPUEnabled bool
CPUFile string
MemoryInterval uint
MemoryThreshold uint
}
// InfluxDB configures the usage of an Influx db monitoring.
type InfluxDB struct {
URL string
Token string
Organization string
Bucket string
Stage string
}
// configuration contains the complete configuration of Poseidon.
type configuration struct {
Server server
AWS AWS
Kubernetes Kubernetes
Logger Logger
Profiling Profiling
Sentry sentry.ClientOptions
InfluxDB InfluxDB
}
// InitConfig merges configuration options from environment variables and
// a configuration file into the default configuration. Calls of InitConfig
// after the first call have no effect and return an error. InitConfig
// should be called directly after starting the program.
func InitConfig() error {
if configurationInitialized {
return ErrConfigInitialized
}
configurationInitialized = true
content := readConfigFile()
Config.mergeYaml(content)
Config.mergeEnvironmentVariables()
return nil
}
func parseURL(address string, port int, tlsEnabled bool) *url.URL {
scheme := "http"
if tlsEnabled {
scheme = "https"
}
return &url.URL{
Scheme: scheme,
Host: fmt.Sprintf("%s:%d", address, port),
}
}
func readConfigFile() []byte {
parseFlags()
data, err := os.ReadFile(configurationFilePath)
if err != nil {
log.WithError(err).Info("Using default configuration...")
return nil
}
return data
}
func parseFlags() {
if flag.Lookup("config") == nil {
flag.StringVar(&configurationFilePath, "config", configurationFilePath, "path of the yaml config file")
}
flag.Parse()
}
func (c *configuration) mergeYaml(content []byte) {
if err := yaml.Unmarshal(content, c); err != nil {
log.WithError(err).Fatal("Could not parse configuration file")
}
}
func (c *configuration) mergeEnvironmentVariables() {
readFromEnvironment("POSEIDON", reflect.ValueOf(c).Elem())
}
func readFromEnvironment(prefix string, value reflect.Value) {
logEntry := log.WithField("prefix", prefix)
// if value was not derived from a pointer, it is not possible to alter its contents
if !value.CanSet() {
logEntry.Warn("Cannot overwrite struct field that can not be set")
return
}
if value.Kind() != reflect.Struct {
loadValue(prefix, value, logEntry)
} else {
for i := 0; i < value.NumField(); i++ {
fieldName := value.Type().Field(i).Name
newPrefix := fmt.Sprintf("%s_%s", prefix, strings.ToUpper(fieldName))
readFromEnvironment(newPrefix, value.Field(i))
}
}
}
func loadValue(prefix string, value reflect.Value, logEntry *logrus.Entry) {
content, ok := os.LookupEnv(prefix)
if !ok {
return
}
logEntry = logEntry.WithField("content", content)
switch value.Kind() {
case reflect.String:
value.SetString(content)
case reflect.Int:
integer, err := strconv.Atoi(content)
if err != nil {
logEntry.Warn("Could not parse environment variable as integer")
return
}
value.SetInt(int64(integer))
case reflect.Bool:
boolean, err := strconv.ParseBool(content)
if err != nil {
logEntry.Warn("Could not parse environment variable as boolean")
return
}
value.SetBool(boolean)
case reflect.Slice:
if content != "" && content[0] == '"' && content[len(content)-1] == '"' {
content = content[1 : len(content)-1] // remove wrapping quotes
}
parts := strings.Fields(content)
value.Set(reflect.AppendSlice(value, reflect.ValueOf(parts)))
default:
// ignore this field
logEntry.WithField("type", value.Type().Name()).
Warn("Setting configuration option via environment variables is not supported")
}
}
func randomFilterToken() string {
const tokenLength = 32
randomBytes := make([]byte, tokenLength) //nolint:all // length required to be filled by rand.Read.
n, err := rand.Read(randomBytes)
if n != tokenLength || err != nil {
log.WithError(err).WithField("byteCount", n).Fatal("Failed to generate random token")
}
return base64.URLEncoding.EncodeToString(randomBytes)
}

View File

@ -0,0 +1,77 @@
package environment
import (
"context"
"fmt"
"github.com/openHPI/poseidon/internal/runner"
"github.com/openHPI/poseidon/pkg/dto"
)
// AbstractManager is used to have a fallback environment manager in the chain of responsibility
// following the null object pattern.
type AbstractManager struct {
nextHandler ManagerHandler
runnerManager runner.Manager
}
func (n *AbstractManager) SetNextHandler(next ManagerHandler) {
n.nextHandler = next
}
func (n *AbstractManager) NextHandler() ManagerHandler {
if n.HasNextHandler() {
return n.nextHandler
} else {
return &AbstractManager{}
}
}
func (n *AbstractManager) HasNextHandler() bool {
return n.nextHandler != nil
}
func (n *AbstractManager) List(_ bool) ([]runner.ExecutionEnvironment, error) {
return []runner.ExecutionEnvironment{}, nil
}
func (n *AbstractManager) Get(_ dto.EnvironmentID, _ bool) (runner.ExecutionEnvironment, error) {
return nil, runner.ErrRunnerNotFound
}
func (n *AbstractManager) CreateOrUpdate(_ dto.EnvironmentID, _ dto.ExecutionEnvironmentRequest, _ context.Context) (
bool, error) {
return false, nil
}
func (n *AbstractManager) Delete(id dto.EnvironmentID) (bool, error) {
if n.runnerManager == nil {
return false, nil
}
e, ok := n.runnerManager.GetEnvironment(id)
if !ok {
isFound, err := n.NextHandler().Delete(id)
if err != nil {
return false, fmt.Errorf("abstract wrapped: %w", err)
}
return isFound, nil
}
n.runnerManager.DeleteEnvironment(id)
if err := e.Delete(runner.ErrDestroyedByAPIRequest); err != nil {
return true, fmt.Errorf("could not delete environment: %w", err)
}
return true, nil
}
func (n *AbstractManager) Statistics() map[dto.EnvironmentID]*dto.StatisticalExecutionEnvironmentData {
if n.runnerManager == nil {
return map[dto.EnvironmentID]*dto.StatisticalExecutionEnvironmentData{}
}
statistics := n.NextHandler().Statistics()
for k, v := range n.runnerManager.EnvironmentStatistics() {
statistics[k] = v
}
return statistics
}

View File

@ -0,0 +1,139 @@
package environment
import (
"encoding/json"
"fmt"
"github.com/openHPI/poseidon/internal/runner"
"github.com/openHPI/poseidon/pkg/dto"
"k8s.io/client-go/kubernetes"
)
type KubernetesEnvironment struct {
id dto.EnvironmentID
image string
cpuLimit uint
memoryLimit uint
networkEnabled bool
mappedPorts []uint16
prewarmingPool uint
onDestroyRunner runner.DestroyRunnerHandler
clientset *kubernetes.Clientset
}
func NewKubernetesEnvironment(onDestroyRunner runner.DestroyRunnerHandler, clientset *kubernetes.Clientset) *KubernetesEnvironment {
return &KubernetesEnvironment{
onDestroyRunner: onDestroyRunner,
clientset: clientset,
cpuLimit: 500, // Default CPU limit (in millicores)
memoryLimit: 512, // Default memory limit (in MB)
networkEnabled: false,
prewarmingPool: 1,
}
}
func (k *KubernetesEnvironment) MarshalJSON() ([]byte, error) {
res, err := json.Marshal(dto.ExecutionEnvironmentData{
ID: int(k.ID()),
ExecutionEnvironmentRequest: dto.ExecutionEnvironmentRequest{Image: k.Image()},
})
if err != nil {
return res, fmt.Errorf("couldn't marshal kubernetes execution environment: %w", err)
}
return res, nil
}
func (k *KubernetesEnvironment) ID() dto.EnvironmentID {
return k.id
}
func (k *KubernetesEnvironment) SetID(id dto.EnvironmentID) {
k.id = id
}
func (k *KubernetesEnvironment) Image() string {
return k.image
}
func (k *KubernetesEnvironment) SetImage(image string) {
k.image = image
}
func (k *KubernetesEnvironment) Delete(_ runner.DestroyReason) error {
// Implement Kubernetes-specific deletion logic here
return nil
}
func (k *KubernetesEnvironment) Sample() (r runner.Runner, ok bool) {
workload, err := runner.NewKubernetesPodWorkload(k, k.onDestroyRunner, k.clientset)
if err != nil {
return nil, false
}
return workload, true
}
func (k *KubernetesEnvironment) IdleRunnerCount() uint {
// Implement logic to count idle runners in Kubernetes
return 0
}
func (k *KubernetesEnvironment) PrewarmingPoolSize() uint {
return k.prewarmingPool
}
func (k *KubernetesEnvironment) SetPrewarmingPoolSize(size uint) {
k.prewarmingPool = size
}
func (k *KubernetesEnvironment) ApplyPrewarmingPoolSize() error {
// Implement logic to apply prewarming pool size in Kubernetes
return nil
}
func (k *KubernetesEnvironment) CPULimit() uint {
return k.cpuLimit
}
func (k *KubernetesEnvironment) SetCPULimit(limit uint) {
k.cpuLimit = limit
}
func (k *KubernetesEnvironment) MemoryLimit() uint {
return k.memoryLimit
}
func (k *KubernetesEnvironment) SetMemoryLimit(limit uint) {
k.memoryLimit = limit
}
func (k *KubernetesEnvironment) NetworkAccess() (enabled bool, mappedPorts []uint16) {
return k.networkEnabled, k.mappedPorts
}
func (k *KubernetesEnvironment) SetNetworkAccess(enabled bool, ports []uint16) {
k.networkEnabled = enabled
k.mappedPorts = ports
}
func (k *KubernetesEnvironment) SetConfigFrom(env runner.ExecutionEnvironment) {
if kEnv, ok := env.(*KubernetesEnvironment); ok {
k.cpuLimit = kEnv.cpuLimit
k.memoryLimit = kEnv.memoryLimit
k.networkEnabled = kEnv.networkEnabled
k.mappedPorts = kEnv.mappedPorts
k.prewarmingPool = kEnv.prewarmingPool
}
}
func (k *KubernetesEnvironment) Register() error {
// Implement Kubernetes-specific registration logic here
return nil
}
func (k *KubernetesEnvironment) AddRunner(runner runner.Runner) {
// Implement logic to add a runner to the Kubernetes environment
}
func (k *KubernetesEnvironment) DeleteRunner(id string) (r runner.Runner, ok bool) {
// Implement logic to delete a runner from the Kubernetes environment
return nil, false
}

View File

@ -0,0 +1,71 @@
package environment
import (
"context"
"fmt"
"github.com/openHPI/poseidon/internal/config"
"github.com/openHPI/poseidon/internal/runner"
"github.com/openHPI/poseidon/pkg/dto"
"k8s.io/client-go/kubernetes"
)
// KubernetesEnvironmentManager manages Kubernetes environments.
type KubernetesEnvironmentManager struct {
*AbstractManager
clientSet *kubernetes.Clientset
}
func NewKubernetesEnvironmentManager(runnerManager runner.Manager, clientset *kubernetes.Clientset) *KubernetesEnvironmentManager {
return &KubernetesEnvironmentManager{
AbstractManager: &AbstractManager{nil, runnerManager},
clientSet: clientset,
}
}
func (k *KubernetesEnvironmentManager) List(fetch bool) ([]runner.ExecutionEnvironment, error) {
list, err := k.NextHandler().List(fetch)
if err != nil {
return nil, fmt.Errorf("kubernetes wrapped: %w", err)
}
return append(list, k.runnerManager.ListEnvironments()...), nil
}
func (k *KubernetesEnvironmentManager) Get(id dto.EnvironmentID, fetch bool) (runner.ExecutionEnvironment, error) {
e, ok := k.runnerManager.GetEnvironment(id)
if ok {
return e, nil
} else {
e, err := k.NextHandler().Get(id, fetch)
if err != nil {
return nil, fmt.Errorf("kubernetes wrapped: %w", err)
}
return e, nil
}
}
func (k *KubernetesEnvironmentManager) CreateOrUpdate(
id dto.EnvironmentID, request dto.ExecutionEnvironmentRequest, ctx context.Context) (bool, error) {
if !isKubernetesEnvironment(request) {
isCreated, err := k.NextHandler().CreateOrUpdate(id, request, ctx)
if err != nil {
return false, fmt.Errorf("kubernetes wrapped: %w", err)
}
return isCreated, nil
}
_, ok := k.runnerManager.GetEnvironment(id)
e := NewKubernetesEnvironment(k.runnerManager.Return, k.clientSet)
e.SetID(id)
e.SetImage(request.Image)
k.runnerManager.StoreEnvironment(e)
return !ok, nil
}
func isKubernetesEnvironment(request dto.ExecutionEnvironmentRequest) bool {
for _, image := range config.Config.Kubernetes.Images {
if request.Image == image {
return true
}
}
return false
}

View File

@ -0,0 +1,43 @@
package environment
import (
"context"
"github.com/openHPI/poseidon/internal/runner"
"github.com/openHPI/poseidon/pkg/dto"
)
// ManagerHandler is one handler in the chain of responsibility of environment managers.
// Each manager can handle different requests.
type ManagerHandler interface {
Manager
SetNextHandler(next ManagerHandler)
NextHandler() ManagerHandler
HasNextHandler() bool
}
// Manager encapsulates API calls to the executor API for creation and deletion of execution environments.
type Manager interface {
// List returns all environments known by Poseidon.
// When `fetch` is set the environments are fetched from the executor before returning.
List(fetch bool) ([]runner.ExecutionEnvironment, error)
// Get returns the details of the requested environment.
// When `fetch` is set the requested environment is fetched from the executor before returning.
Get(id dto.EnvironmentID, fetch bool) (runner.ExecutionEnvironment, error)
// CreateOrUpdate creates/updates an execution environment on the executor.
// If the job was created, the returned boolean is true, if it was updated, it is false.
// If err is not nil, that means the environment was neither created nor updated.
CreateOrUpdate(
id dto.EnvironmentID,
request dto.ExecutionEnvironmentRequest,
ctx context.Context,
) (bool, error)
// Delete removes the specified execution environment.
// Iff the specified environment could not be found Delete returns false.
Delete(id dto.EnvironmentID) (bool, error)
// Statistics returns statistical data for each execution environment.
Statistics() map[dto.EnvironmentID]*dto.StatisticalExecutionEnvironmentData
}

View File

@ -0,0 +1,84 @@
// This is the default job configuration that is used when no path to another default configuration is given
job "template-0" {
datacenters = ["dc1"]
type = "batch"
group "default-group" {
ephemeral_disk {
migrate = false
size = 10
sticky = false
}
count = 1
spread {
// see https://www.nomadproject.io/docs/job-specification/spread#even-spread-across-data-center
// This spreads the load evenly amongst our nodes
attribute = "${node.unique.name}"
weight = 100
}
restart {
attempts = 3
delay = "15s"
interval = "1h"
mode = "fail"
}
reschedule {
unlimited = false
attempts = 3
interval = "6h"
delay = "1m"
max_delay = "4m"
delay_function = "exponential"
}
task "default-task" {
driver = "docker"
kill_timeout = "0s"
kill_signal = "SIGKILL"
config {
image = "openhpi/docker_exec_phusion"
command = "sleep"
args = ["infinity"]
network_mode = "none"
}
logs {
max_files = 1
max_file_size = 1
}
resources {
cpu = 40
memory = 30
}
}
}
group "config" {
// We want to store whether a task is in use in order to recover from a downtime.
// Without a separate config task, marking a task as used would result in a restart of that task,
// as the meta information is passed to the container as environment variables.
count = 0
task "config" {
driver = "exec"
config {
command = "true"
}
logs {
max_files = 1
max_file_size = 1
}
resources {
// minimum values
cpu = 1
memory = 10
}
}
meta {
used = "false"
prewarmingPoolSize = "0"
}
}
}

View File

@ -0,0 +1,125 @@
package runner
import (
"context"
"errors"
"fmt"
"github.com/influxdata/influxdb-client-go/v2/api/write"
"github.com/openHPI/poseidon/pkg/dto"
"github.com/openHPI/poseidon/pkg/monitoring"
"github.com/openHPI/poseidon/pkg/storage"
"time"
)
var ErrNullObject = errors.New("functionality not available for the null object")
// AbstractManager is used to have a fallback runner manager in the chain of responsibility
// following the null object pattern.
// Remember all functions that can call the NextHandler should call it (See AccessorHandler).
type AbstractManager struct {
nextHandler AccessorHandler
environments storage.Storage[ExecutionEnvironment]
usedRunners storage.Storage[Runner]
}
// NewAbstractManager creates a new abstract runner manager that keeps track of all runners of one kind.
// Since this manager is currently directly bound to the lifespan of Poseidon, it does not need a context cancel.
func NewAbstractManager(ctx context.Context) *AbstractManager {
return &AbstractManager{
environments: storage.NewMonitoredLocalStorage[ExecutionEnvironment](
monitoring.MeasurementEnvironments, monitorEnvironmentData, 0, ctx),
usedRunners: storage.NewMonitoredLocalStorage[Runner](
monitoring.MeasurementUsedRunner, MonitorRunnersEnvironmentID, time.Hour, ctx),
}
}
// MonitorEnvironmentID adds the passed environment id to the monitoring Point p.
func MonitorEnvironmentID[T any](id dto.EnvironmentID) storage.WriteCallback[T] {
return func(p *write.Point, _ T, _ storage.EventType) {
p.AddTag(monitoring.InfluxKeyEnvironmentID, id.ToString())
}
}
// MonitorRunnersEnvironmentID passes the id of the environment e into the monitoring Point p.
func MonitorRunnersEnvironmentID(p *write.Point, e Runner, _ storage.EventType) {
if e != nil {
p.AddTag(monitoring.InfluxKeyEnvironmentID, e.Environment().ToString())
}
}
func (n *AbstractManager) SetNextHandler(next AccessorHandler) {
n.nextHandler = next
}
func (n *AbstractManager) NextHandler() AccessorHandler {
if n.HasNextHandler() {
return n.nextHandler
} else {
ctx, cancel := context.WithCancel(context.Background())
cancel()
return NewAbstractManager(ctx)
}
}
func (n *AbstractManager) HasNextHandler() bool {
return n.nextHandler != nil
}
func (n *AbstractManager) ListEnvironments() []ExecutionEnvironment {
return n.environments.List()
}
func (n *AbstractManager) GetEnvironment(id dto.EnvironmentID) (ExecutionEnvironment, bool) {
return n.environments.Get(id.ToString())
}
func (n *AbstractManager) StoreEnvironment(environment ExecutionEnvironment) {
n.environments.Add(environment.ID().ToString(), environment)
}
func (n *AbstractManager) DeleteEnvironment(id dto.EnvironmentID) {
n.environments.Delete(id.ToString())
}
func (n *AbstractManager) EnvironmentStatistics() map[dto.EnvironmentID]*dto.StatisticalExecutionEnvironmentData {
environments := make(map[dto.EnvironmentID]*dto.StatisticalExecutionEnvironmentData)
for _, e := range n.environments.List() {
environments[e.ID()] = &dto.StatisticalExecutionEnvironmentData{
ID: int(e.ID()),
PrewarmingPoolSize: e.PrewarmingPoolSize(),
IdleRunners: e.IdleRunnerCount(),
UsedRunners: 0, // Increased below.
}
}
for _, r := range n.usedRunners.List() {
environments[r.Environment()].UsedRunners++
}
return environments
}
func (n *AbstractManager) Claim(_ dto.EnvironmentID, _ int) (Runner, error) {
return nil, ErrNullObject
}
func (n *AbstractManager) Get(runnerID string) (Runner, error) {
runner, ok := n.usedRunners.Get(runnerID)
if ok {
return runner, nil
}
if !n.HasNextHandler() {
return nil, ErrRunnerNotFound
}
r, err := n.NextHandler().Get(runnerID)
if err != nil {
return r, fmt.Errorf("abstract manager wrapped: %w", err)
}
return r, nil
}
func (n *AbstractManager) Return(_ Runner) error {
return nil
}

View File

@ -0,0 +1,66 @@
package runner
import (
"encoding/json"
"github.com/influxdata/influxdb-client-go/v2/api/write"
"github.com/openHPI/poseidon/pkg/dto"
"github.com/openHPI/poseidon/pkg/storage"
"strconv"
)
// ExecutionEnvironment are groups of runner that share the configuration stored in the environment.
type ExecutionEnvironment interface {
json.Marshaler
// ID returns the id of the environment.
ID() dto.EnvironmentID
SetID(id dto.EnvironmentID)
// PrewarmingPoolSize sets the number of idle runner of this environment that should be prewarmed.
PrewarmingPoolSize() uint
SetPrewarmingPoolSize(count uint)
// ApplyPrewarmingPoolSize creates idle runners according to the PrewarmingPoolSize.
ApplyPrewarmingPoolSize() error
// CPULimit sets the share of cpu that a runner should receive at minimum.
CPULimit() uint
SetCPULimit(limit uint)
// MemoryLimit sets the amount of memory that should be available for each runner.
MemoryLimit() uint
SetMemoryLimit(limit uint)
// Image sets the image of the runner, e.g. Docker image.
Image() string
SetImage(image string)
// NetworkAccess sets if a runner should have network access and if ports should be mapped.
NetworkAccess() (bool, []uint16)
SetNetworkAccess(allow bool, ports []uint16)
// SetConfigFrom copies all above attributes from the passed environment to the object itself.
SetConfigFrom(environment ExecutionEnvironment)
// Register saves this environment at the executor.
Register() error
// Delete removes this environment and all it's runner from the executor and Poseidon itself.
// Iff local the environment is just removed from Poseidon without external escalation.
Delete(reason DestroyReason) error
// Sample returns and removes an arbitrary available runner.
// ok is true iff a runner was returned.
Sample() (r Runner, ok bool)
// AddRunner adds an existing runner to the idle runners of the environment.
AddRunner(r Runner)
// DeleteRunner removes an idle runner from the environment and returns it.
// This function handles only the environment. The runner has to be destroyed separately.
// ok is true iff the runner was found (and deleted).
DeleteRunner(id string) (r Runner, ok bool)
// IdleRunnerCount returns the number of idle runners of the environment.
IdleRunnerCount() uint
}
// monitorEnvironmentData passes the configuration of the environment e into the monitoring Point p.
func monitorEnvironmentData(p *write.Point, e ExecutionEnvironment, eventType storage.EventType) {
if eventType == storage.Creation && e != nil {
p.AddTag("image", e.Image())
p.AddTag("cpu_limit", strconv.Itoa(int(e.CPULimit())))
p.AddTag("memory_limit", strconv.Itoa(int(e.MemoryLimit())))
hasNetworkAccess, _ := e.NetworkAccess()
p.AddTag("network_access", strconv.FormatBool(hasNetworkAccess))
}
}

View File

@ -0,0 +1,111 @@
package runner
import (
"errors"
"github.com/openHPI/poseidon/pkg/dto"
"sync"
"time"
)
// InactivityTimer is a wrapper around a timer that is used to delete a a Runner after some time of inactivity.
type InactivityTimer interface {
// SetupTimeout starts the timeout after a runner gets deleted.
SetupTimeout(duration time.Duration)
// ResetTimeout resets the current timeout so that the runner gets deleted after the time set in Setup from now.
// It does not make an already expired timer run again.
ResetTimeout()
// StopTimeout stops the timeout but does not remove the runner.
StopTimeout()
// TimeoutPassed returns true if the timeout expired and false otherwise.
TimeoutPassed() bool
}
type TimerState uint8
const (
TimerInactive TimerState = 0
TimerRunning TimerState = 1
TimerExpired TimerState = 2
)
var (
ErrorRunnerInactivityTimeout DestroyReason = errors.New("runner inactivity timeout exceeded")
ErrorExecutionTimeout = errors.New("execution timeout exceeded")
)
type InactivityTimerImplementation struct {
timer *time.Timer
duration time.Duration
state TimerState
runner Runner
onDestroy DestroyRunnerHandler
mu sync.Mutex
}
func NewInactivityTimer(runner Runner, onDestroy DestroyRunnerHandler) InactivityTimer {
return &InactivityTimerImplementation{
state: TimerInactive,
runner: runner,
onDestroy: onDestroy,
}
}
func (t *InactivityTimerImplementation) SetupTimeout(duration time.Duration) {
t.mu.Lock()
defer t.mu.Unlock()
// Stop old timer if present.
if t.timer != nil {
t.timer.Stop()
}
if duration == 0 {
t.state = TimerInactive
return
}
t.state = TimerRunning
t.duration = duration
t.timer = time.AfterFunc(duration, func() {
t.mu.Lock()
t.state = TimerExpired
// The timer must be unlocked here already in order to avoid a deadlock with the call to StopTimout in Manager.Return.
t.mu.Unlock()
err := t.onDestroy(t.runner)
if err != nil {
log.WithError(err).WithField(dto.KeyRunnerID, t.runner.ID()).
Warn("Returning runner after inactivity caused an error")
} else {
log.WithField(dto.KeyRunnerID, t.runner.ID()).Info("Returning runner due to inactivity timeout")
}
})
}
func (t *InactivityTimerImplementation) ResetTimeout() {
t.mu.Lock()
defer t.mu.Unlock()
if t.state != TimerRunning {
// The timer has already expired or been stopped. We don't want to restart it.
return
}
if t.timer.Stop() {
t.timer.Reset(t.duration)
} else {
log.Error("Timer is in state running but stopped. This should never happen")
}
}
func (t *InactivityTimerImplementation) StopTimeout() {
t.mu.Lock()
defer t.mu.Unlock()
if t.state != TimerRunning {
return
}
t.timer.Stop()
t.state = TimerInactive
}
func (t *InactivityTimerImplementation) TimeoutPassed() bool {
return t.state == TimerExpired
}

View File

@ -0,0 +1,70 @@
package runner
import (
"context"
"errors"
"fmt"
"github.com/openHPI/poseidon/pkg/dto"
"github.com/openHPI/poseidon/pkg/logging"
"k8s.io/client-go/kubernetes"
"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")
)
type KubernetesRunnerManager struct {
*AbstractManager
clientSet *kubernetes.Clientset
}
// NewKubernetesRunnerManager creates a new runner manager that keeps track of all runners in Kubernetes.
func NewKubernetesRunnerManager(ctx context.Context, clientSet *kubernetes.Clientset) *KubernetesRunnerManager {
return &KubernetesRunnerManager{
AbstractManager: NewAbstractManager(ctx),
clientSet: clientSet,
}
}
func (k *KubernetesRunnerManager) Claim(id dto.EnvironmentID, duration int) (Runner, error) {
environment, ok := k.GetEnvironment(id)
if !ok {
r, err := k.NextHandler().Claim(id, duration)
if err != nil {
return nil, fmt.Errorf("kubernetes wrapped: %w", err)
}
return r, nil
}
runner, ok := environment.Sample()
if !ok {
log.Warn("no kubernetes runner available")
return nil, ErrNoRunnersAvailable
}
k.usedRunners.Add(runner.ID(), runner)
runner.SetupTimeout(time.Duration(duration) * time.Second)
// Here you might want to add Kubernetes-specific logic
// For example, updating the pod status or adding labels
return runner, nil
}
func (k *KubernetesRunnerManager) Return(r Runner) error {
_, isKubernetesRunner := r.(*KubernetesPodWorkload)
if isKubernetesRunner {
k.usedRunners.Delete(r.ID())
// Here you might want to add Kubernetes-specific logic
// For example, cleaning up the pod or updating its status
} else if err := k.NextHandler().Return(r); err != nil {
return fmt.Errorf("kubernetes wrapped: %w", err)
}
return nil
}

View File

@ -0,0 +1,251 @@
package runner
import (
"context"
"errors"
"fmt"
"github.com/google/uuid"
"github.com/openHPI/poseidon/pkg/dto"
"github.com/openHPI/poseidon/pkg/monitoring"
"github.com/openHPI/poseidon/pkg/storage"
"io"
corev1 "k8s.io/api/core/v1"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/client-go/kubernetes"
"net/http"
"time"
)
var ErrPodCreationFailed = errors.New("failed to create pod")
var (
ErrorUnknownExecution = errors.New("unknown execution")
ErrFileNotFound = errors.New("file not found or insufficient permissions")
ErrOOMKilled DestroyReason = errors.New("the runner was killed due to out of memory")
ErrDestroyedByAPIRequest DestroyReason = errors.New("the client wants to stop the runner")
)
// KubernetesPodWorkload is an abstraction to manage a Kubernetes pod.
// It is not persisted on a Poseidon restart.
// The InactivityTimer is used actively. It stops and deletes the pod.
type KubernetesPodWorkload struct {
InactivityTimer
id string
fs map[dto.FilePath][]byte
executions storage.Storage[*dto.ExecutionRequest]
runningExecutions map[string]context.CancelFunc
onDestroy DestroyRunnerHandler
environment ExecutionEnvironment
ctx context.Context
cancel context.CancelFunc
clientset *kubernetes.Clientset
podName string
namespace string
}
// NewKubernetesPodWorkload creates a new KubernetesPodWorkload with the provided id.
func NewKubernetesPodWorkload(
environment ExecutionEnvironment, onDestroy DestroyRunnerHandler, clientset *kubernetes.Clientset) (*KubernetesPodWorkload, error) {
newUUID, err := uuid.NewUUID()
if err != nil {
return nil, fmt.Errorf("failed generating runner id: %w", err)
}
ctx, cancel := context.WithCancel(context.Background())
workload := &KubernetesPodWorkload{
id: newUUID.String(),
fs: make(map[dto.FilePath][]byte),
runningExecutions: make(map[string]context.CancelFunc),
onDestroy: onDestroy,
environment: environment,
ctx: ctx,
cancel: cancel,
clientset: clientset,
namespace: "default", // You might want to make this configurable
podName: fmt.Sprintf("workload-%s", newUUID.String()),
}
workload.executions = storage.NewMonitoredLocalStorage[*dto.ExecutionRequest](
monitoring.MeasurementExecutionsK8s, monitorExecutionsRunnerID(environment.ID(), workload.id), time.Minute, ctx)
workload.InactivityTimer = NewInactivityTimer(workload, func(_ Runner) error {
return workload.Destroy(nil)
})
return workload, nil
}
func (w *KubernetesPodWorkload) ID() string {
return w.id
}
func (w *KubernetesPodWorkload) Environment() dto.EnvironmentID {
return w.environment.ID()
}
func (w *KubernetesPodWorkload) MappedPorts() []*dto.MappedPort {
// Implement port mapping logic for Kubernetes
return []*dto.MappedPort{}
}
func (w *KubernetesPodWorkload) StoreExecution(id string, request *dto.ExecutionRequest) {
w.executions.Add(id, request)
}
func (w *KubernetesPodWorkload) ExecutionExists(id string) bool {
_, ok := w.executions.Get(id)
return ok
}
// ExecuteInteractively runs the execution request in a Kubernetes pod.
func (w *KubernetesPodWorkload) ExecuteInteractively(
id string, _ io.ReadWriter, stdout, stderr io.Writer, ctx context.Context) (
<-chan ExitInfo, context.CancelFunc, error) {
w.ResetTimeout()
request, ok := w.executions.Pop(id)
if !ok {
return nil, nil, ErrorUnknownExecution
}
hideEnvironmentVariablesK8s(request, "K8S")
command, executionCtx, cancel := prepareExecution(request, w.ctx)
exitInternal := make(chan ExitInfo)
exit := make(chan ExitInfo, 1)
go w.executeCommand(executionCtx, command, stdout, stderr, exitInternal)
go w.handleRunnerTimeout(executionCtx, exitInternal, exit, id)
return exit, cancel, nil
}
func (w *KubernetesPodWorkload) ListFileSystem(path string, recursive bool, writer io.Writer, humanReadable bool, ctx context.Context) error {
// Implement file system listing for Kubernetes pods
return dto.ErrNotSupported
}
func (w *KubernetesPodWorkload) UpdateFileSystem(request *dto.UpdateFileSystemRequest, ctx context.Context) error {
// Implement file system update for Kubernetes pods
return nil
}
func (w *KubernetesPodWorkload) GetFileContent(path string, writer http.ResponseWriter, humanReadable bool, ctx context.Context) error {
// Implement file content retrieval for Kubernetes pods
return dto.ErrNotSupported
}
func (w *KubernetesPodWorkload) Destroy(_ DestroyReason) error {
w.cancel()
err := w.clientset.CoreV1().Pods(w.namespace).Delete(context.Background(), w.podName, metav1.DeleteOptions{})
if err != nil {
return fmt.Errorf("error while destroying kubernetes pod: %w", err)
}
if err := w.onDestroy(w); err != nil {
return fmt.Errorf("error while destroying kubernetes runner: %w", err)
}
return nil
}
func (w *KubernetesPodWorkload) executeCommand(ctx context.Context, command string,
stdout, stderr io.Writer, exit chan<- ExitInfo,
) {
defer close(exit)
pod := &corev1.Pod{
ObjectMeta: metav1.ObjectMeta{
Name: w.podName,
},
Spec: corev1.PodSpec{
RestartPolicy: corev1.RestartPolicyNever,
Containers: []corev1.Container{
{
Name: "workload",
Image: w.environment.Image(),
Command: []string{"/bin/sh", "-c", command},
},
},
},
}
_, err := w.clientset.CoreV1().Pods(w.namespace).Create(ctx, pod, metav1.CreateOptions{})
if err != nil {
exit <- ExitInfo{1, fmt.Errorf("%w: %v", ErrPodCreationFailed, err)}
return
}
req := w.clientset.CoreV1().Pods(w.namespace).GetLogs(w.podName, &corev1.PodLogOptions{
Follow: true,
})
podLogs, err := req.Stream(ctx)
if err != nil {
exit <- ExitInfo{1, fmt.Errorf("error in opening stream: %v", err)}
return
}
defer func(podLogs io.ReadCloser) {
err := podLogs.Close()
if err != nil {
exit <- ExitInfo{1, fmt.Errorf("error in closing stream: %v", err)}
}
}(podLogs)
_, err = io.Copy(stdout, podLogs)
if err != nil {
exit <- ExitInfo{1, fmt.Errorf("error in copying logs: %v", err)}
return
}
// Wait for the pod to complete
watch, err := w.clientset.CoreV1().Pods(w.namespace).Watch(ctx, metav1.ListOptions{
FieldSelector: fmt.Sprintf("metadata.name=%s", w.podName),
})
if err != nil {
exit <- ExitInfo{1, fmt.Errorf("error watching pod: %v", err)}
return
}
defer watch.Stop()
for event := range watch.ResultChan() {
pod, ok := event.Object.(*corev1.Pod)
if !ok {
continue
}
if pod.Status.Phase == corev1.PodSucceeded || pod.Status.Phase == corev1.PodFailed {
exitCode := uint8(0)
if pod.Status.Phase == corev1.PodFailed {
exitCode = 1
}
exit <- ExitInfo{exitCode, nil}
return
}
}
}
func (w *KubernetesPodWorkload) handleRunnerTimeout(ctx context.Context,
exitInternal <-chan ExitInfo, exit chan<- ExitInfo, executionID string) {
executionCtx, cancelExecution := context.WithCancel(ctx)
w.runningExecutions[executionID] = cancelExecution
defer delete(w.runningExecutions, executionID)
defer close(exit)
select {
case exitInfo := <-exitInternal:
exit <- exitInfo
case <-executionCtx.Done():
exit <- ExitInfo{255, ErrorRunnerInactivityTimeout}
}
}
// hideEnvironmentVariables sets the CODEOCEAN variable and unsets all variables starting with the passed prefix.
func hideEnvironmentVariablesK8s(request *dto.ExecutionRequest, unsetPrefix string) {
if request.Environment == nil {
request.Environment = make(map[string]string)
}
request.Command = "unset \"${!" + unsetPrefix + "@}\" && " + request.Command
}
func prepareExecution(request *dto.ExecutionRequest, environmentCtx context.Context) (
command string, ctx context.Context, cancel context.CancelFunc,
) {
command = request.FullCommand()
if request.TimeLimit == 0 {
ctx, cancel = context.WithCancel(environmentCtx)
} else {
ctx, cancel = context.WithTimeout(environmentCtx, time.Duration(request.TimeLimit)*time.Second)
}
return command, ctx, cancel
}

View File

@ -0,0 +1,54 @@
package runner
import "github.com/openHPI/poseidon/pkg/dto"
// 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 {
EnvironmentAccessor
AccessorHandler
}
// EnvironmentAccessor provides access to the stored environments.
type EnvironmentAccessor interface {
// ListEnvironments returns all execution environments known by Poseidon.
ListEnvironments() []ExecutionEnvironment
// GetEnvironment returns the details of the requested environment.
// Iff the requested environment is not stored it returns false.
GetEnvironment(id dto.EnvironmentID) (ExecutionEnvironment, bool)
// StoreEnvironment stores the environment in Poseidons memory.
StoreEnvironment(environment ExecutionEnvironment)
// DeleteEnvironment removes the specified execution environment in Poseidons memory.
// It does nothing if the specified environment can not be found.
DeleteEnvironment(id dto.EnvironmentID)
// EnvironmentStatistics returns statistical data for each execution environment.
EnvironmentStatistics() map[dto.EnvironmentID]*dto.StatisticalExecutionEnvironmentData
}
// AccessorHandler is one handler in the chain of responsibility of runner accessors.
// Each runner accessor can handle different requests.
type AccessorHandler interface {
Accessor
SetNextHandler(m AccessorHandler)
NextHandler() AccessorHandler
HasNextHandler() bool
}
// Accessor manages the lifecycle of Runner.
type Accessor interface {
// Claim returns a new runner. The runner is deleted after duration seconds if duration is not 0.
// It makes sure that the runner is not in use yet and returns an error if no runner could be provided.
Claim(id dto.EnvironmentID, duration int) (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 signals that the runner is no longer used by the caller and can be claimed by someone else.
// The runner is deleted or cleaned up for reuse depending on the used executor.
Return(r Runner) error
}

91
internal/runner/runner.go Normal file
View File

@ -0,0 +1,91 @@
package runner
import (
"context"
"github.com/influxdata/influxdb-client-go/v2/api/write"
"github.com/openHPI/poseidon/pkg/dto"
"github.com/openHPI/poseidon/pkg/monitoring"
"github.com/openHPI/poseidon/pkg/storage"
"io"
"net/http"
)
type ExitInfo struct {
Code uint8
Err error
}
const (
// runnerContextKey is the key used to store runners in context.Context.
runnerContextKey dto.ContextKey = "runner"
)
type DestroyRunnerHandler = func(r Runner) error
// DestroyReason specifies errors that are expected as reason for destroying a runner.
type DestroyReason error
type Runner interface {
InactivityTimer
// ID returns the id of the runner.
ID() string
// Environment returns the id of the Environment to which the Runner belongs.
Environment() dto.EnvironmentID
// MappedPorts returns the mapped ports of the runner.
MappedPorts() []*dto.MappedPort
// StoreExecution adds a new execution to the runner that can then be executed using ExecuteInteractively.
StoreExecution(id string, executionRequest *dto.ExecutionRequest)
// ExecutionExists returns whether the execution with the given id is already stored.
ExecutionExists(id string) bool
// 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.
// Output from the runner is forwarded immediately.
ExecuteInteractively(
id string,
stdin io.ReadWriter,
stdout,
stderr io.Writer,
ctx context.Context,
) (exit <-chan ExitInfo, cancel context.CancelFunc, err error)
// ListFileSystem streams the listing of the file system of the requested directory into the Writer provided.
// The result is streamed via the io.Writer in order to not overload the memory with user input.
ListFileSystem(path string, recursive bool, result io.Writer, privilegedExecution bool, ctx context.Context) error
// UpdateFileSystem processes a dto.UpdateFileSystemRequest by first deleting each given dto.FilePath recursively
// and then copying each given dto.File to the runner.
UpdateFileSystem(request *dto.UpdateFileSystemRequest, ctx context.Context) error
// GetFileContent streams the file content at the requested path into the Writer provided at content.
// The result is streamed via the io.Writer in order to not overload the memory with user input.
GetFileContent(path string, content http.ResponseWriter, privilegedExecution bool, ctx context.Context) error
// Destroy destroys the Runner in Nomad.
// Depending on the reason special cases of the Destruction will be handled.
Destroy(reason DestroyReason) error
}
// NewContext creates a context containing a runner.
func NewContext(ctx context.Context, runner Runner) context.Context {
return context.WithValue(ctx, runnerContextKey, runner)
}
// FromContext returns a runner from a context.
func FromContext(ctx context.Context) (Runner, bool) {
runner, ok := ctx.Value(runnerContextKey).(Runner)
return runner, ok
}
// monitorExecutionsRunnerID passes the id of the runner executing the execution into the monitoring Point p.
func monitorExecutionsRunnerID(env dto.EnvironmentID, runnerID string) storage.WriteCallback[*dto.ExecutionRequest] {
return func(p *write.Point, _ *dto.ExecutionRequest, _ storage.EventType) {
p.AddTag(monitoring.InfluxKeyEnvironmentID, env.ToString())
p.AddTag(monitoring.InfluxKeyRunnerID, runnerID)
}
}