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)
|
||||
}
|
Reference in New Issue
Block a user