added poseidon with aws to k8s changes
This commit is contained in:
98
internal/api/api.go
Normal file
98
internal/api/api.go
Normal 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
38
internal/api/auth/auth.go
Normal 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)
|
||||
})
|
||||
}
|
159
internal/api/environments.go
Normal file
159
internal/api/environments.go
Normal 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
42
internal/api/health.go
Normal 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
41
internal/api/helpers.go
Normal 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
259
internal/api/runners.go
Normal 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
113
internal/api/websocket.go
Normal 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)
|
||||
})
|
||||
}
|
177
internal/api/ws/codeocean_reader.go
Normal file
177
internal/api/ws/codeocean_reader.go
Normal 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
|
||||
}
|
174
internal/api/ws/codeocean_writer.go
Normal file
174
internal/api/ws/codeocean_writer.go
Normal 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
|
||||
}
|
14
internal/api/ws/connection.go
Normal file
14
internal/api/ws/connection.go
Normal 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
287
internal/config/config.go
Normal 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)
|
||||
}
|
77
internal/environment/abstract_manager.go
Normal file
77
internal/environment/abstract_manager.go
Normal 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
|
||||
}
|
139
internal/environment/k8s_env.go
Normal file
139
internal/environment/k8s_env.go
Normal 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
|
||||
}
|
71
internal/environment/k8s_manager.go
Normal file
71
internal/environment/k8s_manager.go
Normal 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
|
||||
}
|
43
internal/environment/manager.go
Normal file
43
internal/environment/manager.go
Normal 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
|
||||
}
|
84
internal/environment/template-environment-job.hcl
Normal file
84
internal/environment/template-environment-job.hcl
Normal 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"
|
||||
}
|
||||
}
|
||||
}
|
125
internal/runner/abstract_manager.go
Normal file
125
internal/runner/abstract_manager.go
Normal 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
|
||||
}
|
66
internal/runner/execution_environment.go
Normal file
66
internal/runner/execution_environment.go
Normal 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))
|
||||
}
|
||||
}
|
111
internal/runner/inactivity_timer.go
Normal file
111
internal/runner/inactivity_timer.go
Normal 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
|
||||
}
|
70
internal/runner/k8s_manager.go
Normal file
70
internal/runner/k8s_manager.go
Normal 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
|
||||
}
|
251
internal/runner/k8s_runner.go
Normal file
251
internal/runner/k8s_runner.go
Normal 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
|
||||
}
|
54
internal/runner/manager.go
Normal file
54
internal/runner/manager.go
Normal 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
91
internal/runner/runner.go
Normal 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)
|
||||
}
|
||||
}
|
Reference in New Issue
Block a user