247 lines
8.6 KiB
Go
247 lines
8.6 KiB
Go
package runner
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"github.com/google/uuid"
|
|
"github.com/gorilla/websocket"
|
|
"github.com/openHPI/poseidon/internal/config"
|
|
"github.com/openHPI/poseidon/pkg/dto"
|
|
"github.com/openHPI/poseidon/pkg/monitoring"
|
|
"github.com/openHPI/poseidon/pkg/storage"
|
|
"io"
|
|
"net/http"
|
|
"time"
|
|
)
|
|
|
|
var ErrWrongMessageType = errors.New("received message that is not a text message")
|
|
|
|
type awsFunctionRequest struct {
|
|
Action string `json:"action"`
|
|
Cmd []string `json:"cmd"`
|
|
Files map[dto.FilePath][]byte `json:"files"`
|
|
}
|
|
|
|
// AWSFunctionWorkload is an abstraction to build a request to an AWS Lambda Function.
|
|
// It is not persisted on a Poseidon restart.
|
|
// The InactivityTimer is used actively. It stops listening to the Lambda function.
|
|
// AWS terminates the Lambda Function after the [Globals.Function.Timeout](deploy/aws/template.yaml).
|
|
type AWSFunctionWorkload 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
|
|
}
|
|
|
|
// NewAWSFunctionWorkload creates a new AWSFunctionWorkload with the provided id.
|
|
func NewAWSFunctionWorkload(
|
|
environment ExecutionEnvironment, onDestroy DestroyRunnerHandler) (*AWSFunctionWorkload, 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 := &AWSFunctionWorkload{
|
|
id: newUUID.String(),
|
|
fs: make(map[dto.FilePath][]byte),
|
|
runningExecutions: make(map[string]context.CancelFunc),
|
|
onDestroy: onDestroy,
|
|
environment: environment,
|
|
ctx: ctx,
|
|
cancel: cancel,
|
|
}
|
|
workload.executions = storage.NewMonitoredLocalStorage[*dto.ExecutionRequest](
|
|
monitoring.MeasurementExecutionsAWS, monitorExecutionsRunnerID(environment.ID(), workload.id), time.Minute, ctx)
|
|
workload.InactivityTimer = NewInactivityTimer(workload, func(_ Runner) error {
|
|
return workload.Destroy(nil)
|
|
})
|
|
return workload, nil
|
|
}
|
|
|
|
func (w *AWSFunctionWorkload) ID() string {
|
|
return w.id
|
|
}
|
|
|
|
func (w *AWSFunctionWorkload) Environment() dto.EnvironmentID {
|
|
return w.environment.ID()
|
|
}
|
|
|
|
func (w *AWSFunctionWorkload) MappedPorts() []*dto.MappedPort {
|
|
return []*dto.MappedPort{}
|
|
}
|
|
|
|
func (w *AWSFunctionWorkload) StoreExecution(id string, request *dto.ExecutionRequest) {
|
|
w.executions.Add(id, request)
|
|
}
|
|
|
|
func (w *AWSFunctionWorkload) ExecutionExists(id string) bool {
|
|
_, ok := w.executions.Get(id)
|
|
return ok
|
|
}
|
|
|
|
// ExecuteInteractively runs the execution request in an AWS function.
|
|
// It should be further improved by using the passed context to handle lost connections.
|
|
func (w *AWSFunctionWorkload) ExecuteInteractively(
|
|
id string, _ io.ReadWriter, stdout, stderr io.Writer, _ context.Context) (
|
|
<-chan ExitInfo, context.CancelFunc, error) {
|
|
w.ResetTimeout()
|
|
request, ok := w.executions.Pop(id)
|
|
if !ok {
|
|
return nil, nil, ErrorUnknownExecution
|
|
}
|
|
hideEnvironmentVariables(request, "AWS")
|
|
request.PrivilegedExecution = true // AWS does not support multiple users at this moment.
|
|
command, ctx, cancel := prepareExecution(request, w.ctx)
|
|
commands := []string{"/bin/bash", "-c", command}
|
|
exitInternal := make(chan ExitInfo)
|
|
exit := make(chan ExitInfo, 1)
|
|
|
|
go w.executeCommand(ctx, commands, stdout, stderr, exitInternal)
|
|
go w.handleRunnerTimeout(ctx, exitInternal, exit, id)
|
|
|
|
return exit, cancel, nil
|
|
}
|
|
|
|
// ListFileSystem is currently not supported with this aws serverless function.
|
|
// This is because the function execution ends with the termination of the workload code.
|
|
// So an on-demand file system listing after the termination is not possible. Also, we do not want to copy all files.
|
|
func (w *AWSFunctionWorkload) ListFileSystem(_ string, _ bool, _ io.Writer, _ bool, _ context.Context) error {
|
|
return dto.ErrNotSupported
|
|
}
|
|
|
|
// UpdateFileSystem copies Files into the executor.
|
|
// Current limitation: No files can be deleted apart from the previously added files.
|
|
// Future Work: Deduplication of the file systems, as the largest workload is likely to be used by additional
|
|
// CSV files or similar, which are the same for many executions.
|
|
func (w *AWSFunctionWorkload) UpdateFileSystem(request *dto.UpdateFileSystemRequest, _ context.Context) error {
|
|
for _, path := range request.Delete {
|
|
delete(w.fs, path)
|
|
}
|
|
for _, file := range request.Copy {
|
|
w.fs[file.Path] = file.Content
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// GetFileContent is currently not supported with this aws serverless function.
|
|
// This is because the function execution ends with the termination of the workload code.
|
|
// So an on-demand file streaming after the termination is not possible. Also, we do not want to copy all files.
|
|
func (w *AWSFunctionWorkload) GetFileContent(_ string, _ http.ResponseWriter, _ bool, _ context.Context) error {
|
|
return dto.ErrNotSupported
|
|
}
|
|
|
|
func (w *AWSFunctionWorkload) Destroy(_ DestroyReason) error {
|
|
w.cancel()
|
|
if err := w.onDestroy(w); err != nil {
|
|
return fmt.Errorf("error while destroying aws runner: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (w *AWSFunctionWorkload) executeCommand(ctx context.Context, command []string,
|
|
stdout, stderr io.Writer, exit chan<- ExitInfo,
|
|
) {
|
|
defer close(exit)
|
|
data := &awsFunctionRequest{
|
|
Action: w.environment.Image(),
|
|
Cmd: command,
|
|
Files: w.fs,
|
|
}
|
|
log.WithContext(ctx).WithField("request", data).Trace("Sending request to AWS")
|
|
rawData, err := json.Marshal(data)
|
|
if err != nil {
|
|
exit <- ExitInfo{uint8(1), fmt.Errorf("cannot stingify aws function request: %w", err)}
|
|
return
|
|
}
|
|
|
|
wsConn, response, err := websocket.DefaultDialer.Dial(config.Config.AWS.Endpoint, nil)
|
|
if err != nil {
|
|
exit <- ExitInfo{uint8(1), fmt.Errorf("failed to establish aws connection: %w", err)}
|
|
return
|
|
}
|
|
_ = response.Body.Close()
|
|
defer wsConn.Close()
|
|
err = wsConn.WriteMessage(websocket.TextMessage, rawData)
|
|
if err != nil {
|
|
exit <- ExitInfo{uint8(1), fmt.Errorf("cannot send aws request: %w", err)}
|
|
return
|
|
}
|
|
|
|
// receiveOutput listens for the execution timeout (or the exit code).
|
|
exitCode, err := w.receiveOutput(wsConn, stdout, stderr, ctx)
|
|
// TimeoutPassed checks the runner timeout
|
|
if w.TimeoutPassed() {
|
|
err = ErrorRunnerInactivityTimeout
|
|
}
|
|
exit <- ExitInfo{exitCode, err}
|
|
}
|
|
|
|
func (w *AWSFunctionWorkload) receiveOutput(
|
|
conn *websocket.Conn, stdout, stderr io.Writer, ctx context.Context) (uint8, error) {
|
|
for ctx.Err() == nil {
|
|
messageType, reader, err := conn.NextReader()
|
|
if err != nil {
|
|
return 1, fmt.Errorf("cannot read from aws connection: %w", err)
|
|
}
|
|
if messageType != websocket.TextMessage {
|
|
return 1, ErrWrongMessageType
|
|
}
|
|
var wsMessage dto.WebSocketMessage
|
|
err = json.NewDecoder(reader).Decode(&wsMessage)
|
|
if err != nil {
|
|
return 1, fmt.Errorf("failed to decode message from aws: %w", err)
|
|
}
|
|
|
|
log.WithField("msg", wsMessage).Info("New Message from AWS function")
|
|
|
|
switch wsMessage.Type {
|
|
default:
|
|
log.WithContext(ctx).WithField("data", wsMessage).Warn("unexpected message from aws function")
|
|
case dto.WebSocketExit:
|
|
return wsMessage.ExitCode, nil
|
|
case dto.WebSocketOutputStdout:
|
|
// We do not check the written bytes as the rawToCodeOceanWriter receives everything or nothing.
|
|
_, err = stdout.Write([]byte(wsMessage.Data))
|
|
case dto.WebSocketOutputStderr, dto.WebSocketOutputError:
|
|
_, err = stderr.Write([]byte(wsMessage.Data))
|
|
}
|
|
if err != nil {
|
|
return 1, fmt.Errorf("failed to forward message: %w", err)
|
|
}
|
|
}
|
|
return 1, fmt.Errorf("receiveOutput stpped by context: %w", ctx.Err())
|
|
}
|
|
|
|
// handleRunnerTimeout listens for a runner timeout and aborts the execution in that case.
|
|
// It listens via a context in runningExecutions that is canceled on the timeout event.
|
|
func (w *AWSFunctionWorkload) 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 hideEnvironmentVariables(request *dto.ExecutionRequest, unsetPrefix string) {
|
|
if request.Environment == nil {
|
|
request.Environment = make(map[string]string)
|
|
}
|
|
request.Command = "unset \"${!" + unsetPrefix + "@}\" && " + request.Command
|
|
}
|