diff options
| -rw-r--r-- | backend/api/generated.go | 90 | ||||
| -rw-r--r-- | backend/game/hub.go | 7 | ||||
| -rw-r--r-- | backend/game/message.go | 12 | ||||
| -rw-r--r-- | frontend/app/.server/api/schema.d.ts | 11 | ||||
| -rw-r--r-- | frontend/app/components/GolfPlayApp.client.tsx | 9 | ||||
| -rw-r--r-- | frontend/app/components/GolfPlayApps/GolfPlayAppGaming.tsx | 27 | ||||
| -rw-r--r-- | openapi.yaml | 20 | ||||
| -rw-r--r-- | worker/Dockerfile | 16 | ||||
| -rw-r--r-- | worker/Makefile | 4 | ||||
| -rw-r--r-- | worker/exec.go | 186 | ||||
| -rw-r--r-- | worker/go.mod | 20 | ||||
| -rw-r--r-- | worker/go.sum | 39 | ||||
| -rw-r--r-- | worker/handlers.go | 67 | ||||
| -rw-r--r-- | worker/main.go | 64 | ||||
| -rw-r--r-- | worker/models.go | 84 |
15 files changed, 577 insertions, 79 deletions
diff --git a/backend/api/generated.go b/backend/api/generated.go index bb9b624..f7da9ee 100644 --- a/backend/api/generated.go +++ b/backend/api/generated.go @@ -103,6 +103,17 @@ type GamePlayerMessageC2SReady struct { Type string `json:"type"` } +// GamePlayerMessageC2SSubmit defines model for GamePlayerMessageC2SSubmit. +type GamePlayerMessageC2SSubmit struct { + Data GamePlayerMessageC2SSubmitPayload `json:"data"` + Type string `json:"type"` +} + +// GamePlayerMessageC2SSubmitPayload defines model for GamePlayerMessageC2SSubmitPayload. +type GamePlayerMessageC2SSubmitPayload struct { + Code string `json:"code"` +} + // GamePlayerMessageS2C defines model for GamePlayerMessageS2C. type GamePlayerMessageS2C struct { union json.RawMessage @@ -429,6 +440,32 @@ func (t *GamePlayerMessageC2S) MergeGamePlayerMessageC2SCode(v GamePlayerMessage return err } +// AsGamePlayerMessageC2SSubmit returns the union data inside the GamePlayerMessageC2S as a GamePlayerMessageC2SSubmit +func (t GamePlayerMessageC2S) AsGamePlayerMessageC2SSubmit() (GamePlayerMessageC2SSubmit, error) { + var body GamePlayerMessageC2SSubmit + err := json.Unmarshal(t.union, &body) + return body, err +} + +// FromGamePlayerMessageC2SSubmit overwrites any union data inside the GamePlayerMessageC2S as the provided GamePlayerMessageC2SSubmit +func (t *GamePlayerMessageC2S) FromGamePlayerMessageC2SSubmit(v GamePlayerMessageC2SSubmit) error { + b, err := json.Marshal(v) + t.union = b + return err +} + +// MergeGamePlayerMessageC2SSubmit performs a merge with any union data inside the GamePlayerMessageC2S, using the provided GamePlayerMessageC2SSubmit +func (t *GamePlayerMessageC2S) MergeGamePlayerMessageC2SSubmit(v GamePlayerMessageC2SSubmit) error { + b, err := json.Marshal(v) + if err != nil { + return err + } + + merged, err := runtime.JSONMerge(t.union, b) + t.union = merged + return err +} + func (t GamePlayerMessageC2S) MarshalJSON() ([]byte, error) { b, err := t.union.MarshalJSON() return b, err @@ -1549,32 +1586,33 @@ func (sh *strictHandler) GetToken(ctx echo.Context, params GetTokenParams) error // Base64 encoded, gzipped, json marshaled Swagger object var swaggerSpec = []string{ - "H4sIAAAAAAAC/+xZX2/bNhD/Kho3oBugxY4TFJ3f0qzNOnSd0TTYQxEYtHS2mUmkSlJNvELffSCpP6ZF", - "W7SjpunWPAS2yLv78e53vPPpE4pYmjEKVAo0/oQyzHEKErj+tgQcA5/iXC4ZJ/9gSRhVzwlF43IRhYji", - "FNAYnVm7QsThQ044xGgseQ4hEtESUqzE5SpTAkJyQheoKEKUYbmcLnAKUxLXBtTDRn216qGYUAkL4KhQ", - "qjmIjFEB+kDPcfwWPuQgpPoWMSqB6o84yxISaeiDG2FO2ej9gcMcjdH3g8ZZA7MqBi84Z6WpGETESWa8", - "pGwFvDRWhOgl4zMSx0A/v+XGVBGiN0y+ZDmNP7/ZN0wGc22qCNEVrVgDD2DasqaWSwml0AgpbnOWAZfE", - "UCEFIfAC1Ee4w2mWKOa8oh9xQpq4hQ6uNvR7Xyu5rjey2Q1EOuAXmrebZmMisgSvprRcbWyr/cFx22SI", - "4pxrb00FRIzGwpI7eToMW8QP0Voy1VuPXRszzmYJpF2un5TblG8l5hLiKZaW9l9Onz59dvps6IQjJJbm", - "vDRPleeihAlQ2XyLiSR0MQUqufJR80TbQQohZJgDKi0rp+jzmQ9zQolYQqxi0DizVr87fs2lYgCGdnwc", - "rt8W6UmCV8D/aEjFKPw5R+P3u93aEr0cnaMi3FPofHSJimsXErVyOJjz0eULKvnqIERvAceHSZ6zGLaf", - "R6+28wrLzutjm7YJXiUM62vLxFZfV6pGoExvH0cjMY6U3S5C6dXQoPGiygaE1rmi8rQNtTNOqPzxyW+Q", - "JCwMbhlP4u+e/NSJTCvyhWSi3gKzwzugJbzc4wvCEGgfEFxL9AdCJeO9UnlSXl17Z8Hl6PxSX3+HSL64", - "g+gtiDyRW7LI3tNPLlk6uzNKjKIx3EHEDYbe88oJp3VSETFup9exql80TxI8U19Nh+muZ7lYL2gijyIQ", - "wi5D1cOu45XqwhKQ7wkrevUWwVKhX/iautx/7DaAtA64b9eyAakS94VjcrE3N2t1fk6umqD+XWyBaGeG", - "Wt2jxWsT2ohvQ/MXltHywIbJltUd07VT7d73d0vc/xJuiZou5hBJ1/3tVn8wI53qdjDy1uzXlOytEdoJ", - "osdOKCwTyuP30OY9UcuFuxuoXTHsL0heBXY9VD1XWA9A7Zva1/Xhl6vGSkMMnNv02rKP5faliCz+dfp5", - "nVIbZb9WX+PxDsQ9C5RbnyfJ+itRu2E8aI2aNA3GhkvXB0/rNHi3JCIgIsBB1V24LiKz5JUOkshk48Yr", - "UbnGRO4Ox/DMaLKHZq5DXwng+4ysfmdLGvzKwHVSEjE61SNcS2RAUrwAMbhhS3p0ky2comKK45TY/p3j", - "RDTJP2MsAawHnLlwXC+jE5dH1db2KRSUTn9WVtaUtIZFNe62b5U6QudM/4A1cUVnyQxLzoQIFEROcRLc", - "wiw4m7xCIfoIXJjZ5vDo+Gio0LMMKM4IGqOTo+HREJmxuQ7RQNsdLHBqQrYAnRQqinp89SpW9tSeC5AX", - "eldozfq39EbNloHzXUBxvTFgHw2He017babV+ImEVPhcXM3dhDDneOWc8IktAbFnyK+JkAGbB0aiCNHp", - "8HgbhPrMA3vyrIROuoXWBvSqpuRpivmqglDaL0IrqoNP5ayy8IpvT+ENO+WsNzefgQ5+JHAE3SvmZ9rb", - "DxZsJXHaLVG/srHZcQEywDXgLN9GgUn+xSmg3548Z2Z8d2D0H+pNyfaS7NV4drwK8WteH8mrEUe+2C9b", - "i1Z+n7Z6InRp2ux5niSrIM9iLKtsGXZzf+0N7deRlVf6gHViNte26hO6i/GV3vUYi3GN36sY69axqxgb", - "lfsUYyPx5YoxTpIKg4rs7gbrW2/1FfVWvl3Vt4bqf9FQKUokbGF+dGZMOJgwYUK+1lv6anEyLMQt4/HG", - "WLN8ejw6cfU49/wRSysyl6avDyr792GhZH/DxuzkTv0drf3vHidpJT6UtNoRRTegUkGtGLc3Te3yL4AH", - "hjiaQ/Xhtl0m7/SGx1gh/lNxMbktlozLnxPyEeIAa3OBAVgURfFvAAAA//89QklP/CgAAA==", + "H4sIAAAAAAAC/+xZX2/bNhD/Kho3oBugxY4TFJ3f0qzNOnSdUTfYQxEYtHS2mUmkSlJNvELffSApS6ZF", + "W7Sj/OnWPAS2xLv78e7Hu/PxC4pYmjEKVAo0/IIyzHEKErj+tgAcA5/gXC4YJ/9gSRhVzwlFw/IlChHF", + "KaAhOrNWhYjDp5xwiNFQ8hxCJKIFpFiJy2WmBITkhM5RUYQow3IxmeMUJiSuDKiHtfrVWw/FhEqYA0eF", + "Us1BZIwK0Bt6ieP38CkHIdW3iFEJVH/EWZaQSEPvXQuzy1rvDxxmaIi+79XO6pm3oveKc1aaikFEnGTG", + "S8pWwEtjRYheMz4lcQz0/i3XpooQvWPyNctpfP9m3zEZzLSpIkSXdMUaeADTljX1upRQCo2Q4jZnGXBJ", + "DBVSEALPQX2EW5xmiWLOG/oZJ6SOW+jgak2/j5WSq2ohm15DpAN+oXm7aTYmIkvwckLLt7VttT44bpoM", + "UZxz7a2JgIjRWFhyJ8/7YYP4IVo7TNXSY9fCjLNpAmmb60flMuVbibmEeIKlpf2X0+fPX5y+6DvhCIml", + "2S/NU+W5KGEC1Gm+wUQSOp8AlVz5qH6i7SCFEDLMAZWWlVP0/syHGaFELCBWMaidWanfHb86qRiAoR0f", + "h+u3RXqU4CXwP2pSMQp/ztDw4263NkTHg3NUhHsKnQ/GqLhyIVFvDgdzPhi/opIvD0L0HnB8mOQ5i+Eg", + "wXE+TYnc7gqtuHkksWzNPNu0jfAyYVhnPEMLnelUeUGZXj6MBmIYKbttXNRvQ4PGi2UbEBr7isrd1qci", + "44TKH5/9BknCwuCG8ST+7tlPrci0Il9IhjANMDu8A1rCyz2+IAz39gHBtUSnIEo2dsY3o8+PccLYvg/O", + "2TCeAutUzrxTxh2VFWbvnDMenI91lTpE8tUtRO9B5Mm2jGWv6YZHls52LolBNIRbiLjB0DmfnHAaOxUR", + "4zapjlWbQfMkwVP11fwQcLcduVjvO0QeRSCE3S2sHrZtr1QXloB8d7iiV2cRLBX6ha9un7qP3QaQxgb3", + "bS43IK3EfeGYs9iZm7U6PyevetXuXWyBaJ4M9XaPTrxJaCO+Dc1fWEaLA/taW1Y3tldOtXvn74a4fxJu", + "iHo3mw1JV/52qz+YkU51Oxh5Y9ZrSnbWdO4E0WH9D8sD5fGzdTNPVHLh7rZhVwy7C5JXgV0PVccV1gNQ", + "M1P7uj58vGqsNMTAuU2vLetYbidFZPGv1c/rlNoo+5X6Co93IO5YoNz6PEnWXYnaDeNBa9SobjA2XLo+", + "H1ynwYcFEQERAQ5W3YUrEZlXXsdBEplsZLwSlWua5+5wDM+MJnu26dr0pQC+z2Txd7agwa8MXDslEaMT", + "PWm3RHokxXMQvWu2oEfX2dwpKiY4Tont3xlORH34p4wlgPUcOheO9DI4cXlULW3uQkFp9efKypqSxkyv", + "wt30rVJH6IzpYYGJKzpLplhyJkSgIHKKk+AGpsHZ6A0K0Wfgwoyg+0fHR32FnmVAcUbQEJ0c9Y/6yNxu", + "6BD1tN3eHKcmZHPQh0JFUU8Z38TKnlpzAfJCrwqtK5ktvVG9pOe8simuNu5BBv3+XkN5m2kVfiIhFT6J", + "q85NCHOOl85BrNgSEHvU/5YIGbBZYCSKEJ32j7dBqPbcsy8IlNBJu9DaPYqqKXmaYr5cQSjtF6EV1d6X", + "cqRceMW3o/CGrXLWBds90MGPBI6ge8X8THv7wYKtJE7bJaqbNZsdFyADXAHO8m0UGOWPTgF9yfWSmVHp", + "gdF/qAut7SXZq/FsubHya16fyA2W47zYd+JF43yfNnoiNDZt9ixPkmWQZzGWq9PSb+f+2kX613EqL/UG", + "q4NZp23VJ7QX40u96ikW4wq/VzHWrWNbMTYq9ynGRuLxijFOkhUGFdndDda33uor6q18u6pvDdX/oqFS", + "lEjY3PzozJhwMGHEhHyrl3TV4mRYiBvG442xZvn0eHDi6nHu+COWrshcmr46qOzfhYWS/Q0bs5Nb9Xe0", + "9r99nKSV+FDSakcU3YBKBXXFuL1papd/ATwwxNEcqja3LZl80AueYoX4T8XFnG2xYFz+nJDPEAdYmwsM", + "wKIoin8DAAD//5oSt8+jKgAA", } // GetSwagger returns the content of the embedded swagger specification file diff --git a/backend/game/hub.go b/backend/game/hub.go index c73009b..239f5da 100644 --- a/backend/game/hub.go +++ b/backend/game/hub.go @@ -170,8 +170,13 @@ func (hub *gameHub) run() { }, } } + case *playerMessageC2SSubmit: + // TODO: assert game state is gaming + log.Printf("submit: %v", message.message) + // code := msg.Data.Code + // TODO default: - log.Fatalf("unexpected message type: %T", message.message) + log.Printf("unexpected message type: %T", message.message) } case <-ticker.C: if hub.game.state == gameStateStarting { diff --git a/backend/game/message.go b/backend/game/message.go index 0b413d6..d4007a3 100644 --- a/backend/game/message.go +++ b/backend/game/message.go @@ -14,6 +14,7 @@ const ( playerMessageTypeC2SEntry = "player:c2s:entry" playerMessageTypeC2SReady = "player:c2s:ready" playerMessageTypeC2SCode = "player:c2s:code" + playerMessageTypeC2SSubmit = "player:c2s:submit" ) type playerMessageC2SWithClient struct { @@ -36,6 +37,8 @@ type playerMessageC2SEntry = api.GamePlayerMessageC2SEntry type playerMessageC2SReady = api.GamePlayerMessageC2SReady type playerMessageC2SCode = api.GamePlayerMessageC2SCode type playerMessageC2SCodePayload = api.GamePlayerMessageC2SCodePayload +type playerMessageC2SSubmit = api.GamePlayerMessageC2SSubmit +type playerMessageC2SSubmitPayload = api.GamePlayerMessageC2SSubmitPayload func asPlayerMessageC2S(raw map[string]json.RawMessage) (playerMessageC2S, error) { var typ string @@ -61,6 +64,15 @@ func asPlayerMessageC2S(raw map[string]json.RawMessage) (playerMessageC2S, error Type: playerMessageTypeC2SCode, Data: payload, }, nil + case playerMessageTypeC2SSubmit: + var payload playerMessageC2SSubmitPayload + if err := json.Unmarshal(raw["data"], &payload); err != nil { + return nil, err + } + return &playerMessageC2SSubmit{ + Type: playerMessageTypeC2SSubmit, + Data: payload, + }, nil default: return nil, fmt.Errorf("unknown message type: %s", typ) } diff --git a/frontend/app/.server/api/schema.d.ts b/frontend/app/.server/api/schema.d.ts index 705380d..1c8cead 100644 --- a/frontend/app/.server/api/schema.d.ts +++ b/frontend/app/.server/api/schema.d.ts @@ -201,7 +201,7 @@ export interface components { /** @example 100 */ score: number | null; }; - GamePlayerMessageC2S: components["schemas"]["GamePlayerMessageC2SEntry"] | components["schemas"]["GamePlayerMessageC2SReady"] | components["schemas"]["GamePlayerMessageC2SCode"]; + GamePlayerMessageC2S: components["schemas"]["GamePlayerMessageC2SEntry"] | components["schemas"]["GamePlayerMessageC2SReady"] | components["schemas"]["GamePlayerMessageC2SCode"] | components["schemas"]["GamePlayerMessageC2SSubmit"]; GamePlayerMessageC2SEntry: { /** @constant */ type: "player:c2s:entry"; @@ -219,6 +219,15 @@ export interface components { /** @example print('Hello, world!') */ code: string; }; + GamePlayerMessageC2SSubmit: { + /** @constant */ + type: "player:c2s:submit"; + data: components["schemas"]["GamePlayerMessageC2SSubmitPayload"]; + }; + GamePlayerMessageC2SSubmitPayload: { + /** @example print('Hello, world!') */ + code: string; + }; GameWatcherMessage: components["schemas"]["GameWatcherMessageS2C"]; GameWatcherMessageS2C: components["schemas"]["GameWatcherMessageS2CStart"] | components["schemas"]["GameWatcherMessageS2CCode"] | components["schemas"]["GameWatcherMessageS2CExecResult"]; GameWatcherMessageS2CStart: { diff --git a/frontend/app/components/GolfPlayApp.client.tsx b/frontend/app/components/GolfPlayApp.client.tsx index ace0710..80e7182 100644 --- a/frontend/app/components/GolfPlayApp.client.tsx +++ b/frontend/app/components/GolfPlayApp.client.tsx @@ -81,6 +81,14 @@ export default function GolfPlayApp({ }); }, 1000); + const onCodeSubmit = useDebouncedCallback((code: string) => { + console.log("player:c2s:submit"); + sendJsonMessage({ + type: "player:c2s:submit", + data: { code }, + }); + }, 1000); + if (readyState === ReadyState.UNINSTANTIATED) { throw new Error("WebSocket is not connected"); } @@ -140,6 +148,7 @@ export default function GolfPlayApp({ <GolfPlayAppGaming problem={problem!.description} onCodeChange={onCodeChange} + onCodeSubmit={onCodeSubmit} currentScore={currentScore} /> ); diff --git a/frontend/app/components/GolfPlayApps/GolfPlayAppGaming.tsx b/frontend/app/components/GolfPlayApps/GolfPlayAppGaming.tsx index 75ab18e..9fddb01 100644 --- a/frontend/app/components/GolfPlayApps/GolfPlayAppGaming.tsx +++ b/frontend/app/components/GolfPlayApps/GolfPlayAppGaming.tsx @@ -1,18 +1,30 @@ +import React, { useRef } from "react"; + type Props = { problem: string; onCodeChange: (code: string) => void; + onCodeSubmit: (code: string) => void; currentScore: number | null; }; export default function GolfPlayAppGaming({ problem, onCodeChange, + onCodeSubmit, currentScore, }: Props) { + const textareaRef = useRef<HTMLTextAreaElement>(null); + const handleTextChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => { onCodeChange(e.target.value); }; + const handleSubmitButtonClick = () => { + if (textareaRef.current) { + onCodeSubmit(textareaRef.current.value); + } + }; + return ( <div className="min-h-screen flex"> <div className="mx-auto flex min-h-full flex-grow"> @@ -22,16 +34,25 @@ export default function GolfPlayAppGaming({ <div className="text-gray-700">{problem}</div> </div> <div className="mb-4 mt-auto"> - <div className="font-semibold text-green-500"> - Score: {currentScore == null ? "-" : `${currentScore}`} + <div className="mb-2"> + <div className="font-semibold text-green-500"> + Score: {currentScore == null ? "-" : `${currentScore}`} + </div> </div> + <button + onClick={handleSubmitButtonClick} + className="focus:shadow-outline rounded bg-blue-500 px-4 py-2 font-bold text-white hover:bg-blue-700 focus:outline-none" + > + Submit + </button> </div> </div> <div className="w-1/2 p-4 flex"> <div className="flex-grow"> <textarea - className="h-full w-full rounded-lg border border-gray-300 p-2 focus:outline-none focus:ring-2 focus:ring-blue-500" + ref={textareaRef} onChange={handleTextChange} + className="h-full w-full rounded-lg border border-gray-300 p-2 focus:outline-none focus:ring-2 focus:ring-blue-500" ></textarea> </div> </div> diff --git a/openapi.yaml b/openapi.yaml index 683fadf..d04951d 100644 --- a/openapi.yaml +++ b/openapi.yaml @@ -427,6 +427,7 @@ components: - $ref: '#/components/schemas/GamePlayerMessageC2SEntry' - $ref: '#/components/schemas/GamePlayerMessageC2SReady' - $ref: '#/components/schemas/GamePlayerMessageC2SCode' + - $ref: '#/components/schemas/GamePlayerMessageC2SSubmit' GamePlayerMessageC2SEntry: type: object properties: @@ -462,6 +463,25 @@ components: example: "print('Hello, world!')" required: - code + GamePlayerMessageC2SSubmit: + type: object + properties: + type: + type: string + const: "player:c2s:submit" + data: + $ref: '#/components/schemas/GamePlayerMessageC2SSubmitPayload' + required: + - type + - data + GamePlayerMessageC2SSubmitPayload: + type: object + properties: + code: + type: string + example: "print('Hello, world!')" + required: + - code GameWatcherMessage: oneOf: - $ref: '#/components/schemas/GameWatcherMessageS2C' diff --git a/worker/Dockerfile b/worker/Dockerfile index 1d1523d..2373f57 100644 --- a/worker/Dockerfile +++ b/worker/Dockerfile @@ -1,13 +1,21 @@ FROM golang:1.22.3 AS builder WORKDIR /build -COPY . /build -RUN go build -o /build/server . -################################################################################ -FROM golang:1.22.3 +RUN apt-get update && apt-get install -y curl xz-utils +RUN curl https://wasmtime.dev/install.sh -sSf | bash -s -- --version v23.0.1 + +COPY go.mod go.sum ./ +RUN go mod download +COPY *.go /build +RUN CGO_ENABLED=0 go build -o /build/server . + +# ################################################################################ +FROM ghcr.io/swiftwasm/swift:5.10-focal WORKDIR /app + +COPY --from=builder /root/.wasmtime/bin/wasmtime /usr/bin/wasmtime COPY --from=builder /build/server /app/server CMD ["/app/server"] diff --git a/worker/Makefile b/worker/Makefile index 4d6cf39..3c69e4b 100644 --- a/worker/Makefile +++ b/worker/Makefile @@ -5,3 +5,7 @@ fmt: .PHONY: check check: go build -o /dev/null ./... + +.PHONY: lint +lint: + go vet ./... diff --git a/worker/exec.go b/worker/exec.go new file mode 100644 index 0000000..2ef16fa --- /dev/null +++ b/worker/exec.go @@ -0,0 +1,186 @@ +package main + +import ( + "bytes" + "context" + "crypto/md5" + "fmt" + "os" + "os/exec" + "strings" + "time" +) + +const ( + dataRootDir = "/app/data" + // Stores *.swift files. + dataSwiftRootDir = dataRootDir + "/swift" + // Stores *.wasm files. + dataWasmRootDir = dataRootDir + "/wasm" + // Stores *.cwasm files (compiled wasm generated by "wasmtime compile"). + dataCwasmRootDir = dataRootDir + "/cwasm" + + wasmMaxMemorySize = 10 * 1024 * 1024 // 10 MiB +) + +func prepareDirectories() error { + if err := os.MkdirAll(dataSwiftRootDir, 0755); err != nil { + return err + } + if err := os.MkdirAll(dataWasmRootDir, 0755); err != nil { + return err + } + if err := os.MkdirAll(dataCwasmRootDir, 0755); err != nil { + return err + } + return nil +} + +func calcHash(code string) string { + return fmt.Sprintf("%x", md5.Sum([]byte(code))) +} + +func calcFilePath(hash, ext string) string { + return fmt.Sprintf("%s/%s/%s.%s", dataRootDir, ext, hash, ext) +} + +func execCommandWithTimeout( + ctx context.Context, + maxDuration time.Duration, + makeCmd func(context.Context) *exec.Cmd, +) (string, string, error) { + ctx, cancel := context.WithTimeout(ctx, maxDuration) + defer cancel() + + cmd := makeCmd(ctx) + + var stdout bytes.Buffer + var stderr bytes.Buffer + cmd.Stdout = &stdout + cmd.Stderr = &stderr + + exitCh := make(chan error) + go func() { + exitCh <- cmd.Run() + }() + + select { + case <-ctx.Done(): + return stdout.String(), stderr.String(), ctx.Err() + case err := <-exitCh: + return stdout.String(), stderr.String(), err + } +} + +func convertCommandErrorToResultType(err error) string { + if err != nil { + if err == context.DeadlineExceeded { + return resultTimeout + } else { + return resultFailure + } + } else { + return resultSuccess + } +} + +func execSwiftCompile( + ctx context.Context, + code string, + maxDuration time.Duration, +) swiftCompileResponseData { + hash := calcHash(code) + inPath := calcFilePath(hash, "swift") + outPath := calcFilePath(hash, "wasm") + + if err := os.WriteFile(inPath, []byte(code), 0644); err != nil { + return swiftCompileResponseData{ + Result: resultInternalError, + Stdout: "", + Stderr: err.Error(), + } + } + + stdout, stderr, err := execCommandWithTimeout( + ctx, + maxDuration, + func(ctx context.Context) *exec.Cmd { + return exec.CommandContext( + ctx, + "swiftc", + "-target", "wasm32-unknown-wasi", + "-o", outPath, + inPath, + ) + }, + ) + + return swiftCompileResponseData{ + Result: convertCommandErrorToResultType(err), + Stdout: stdout, + Stderr: stderr, + } +} + +func execWasmCompile( + ctx context.Context, + code string, + maxDuration time.Duration, +) wasmCompileResponseData { + hash := calcHash(code) + inPath := calcFilePath(hash, "wasm") + outPath := calcFilePath(hash, "cwasm") + + stdout, stderr, err := execCommandWithTimeout( + ctx, + maxDuration, + func(ctx context.Context) *exec.Cmd { + return exec.CommandContext( + ctx, + "wasmtime", "compile", + "-O", "opt-level=0", + "-C", "cache=n", + "-W", fmt.Sprintf("max-memory-size=%d", wasmMaxMemorySize), + "-o", outPath, + inPath, + ) + }, + ) + + return wasmCompileResponseData{ + Result: convertCommandErrorToResultType(err), + Stdout: stdout, + Stderr: stderr, + } +} + +func execTestRun( + ctx context.Context, + code string, + stdin string, + maxDuration time.Duration, +) testRunResponseData { + hash := calcHash(code) + inPath := calcFilePath(hash, "cwasm") + + stdout, stderr, err := execCommandWithTimeout( + ctx, + maxDuration, + func(ctx context.Context) *exec.Cmd { + cmd := exec.CommandContext( + ctx, + "wasmtime", "run", + "--allow-precompiled", + inPath, + ) + cmd.Stdin = strings.NewReader(stdin) + return cmd + }, + ) + + return testRunResponseData{ + Result: convertCommandErrorToResultType(err), + Stdout: stdout, + Stderr: stderr, + } +} diff --git a/worker/go.mod b/worker/go.mod index 007435d..1d01907 100644 --- a/worker/go.mod +++ b/worker/go.mod @@ -1,3 +1,23 @@ module github.com/nsfisis/iosdc-japan-2024-albatross/worker go 1.22.3 + +require ( + github.com/labstack/echo-jwt/v4 v4.2.0 + github.com/labstack/echo/v4 v4.12.0 +) + +require ( + github.com/golang-jwt/jwt v3.2.2+incompatible // indirect + github.com/golang-jwt/jwt/v5 v5.2.1 // indirect + github.com/labstack/gommon v0.4.2 // indirect + github.com/mattn/go-colorable v0.1.13 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/valyala/bytebufferpool v1.0.0 // indirect + github.com/valyala/fasttemplate v1.2.2 // indirect + golang.org/x/crypto v0.22.0 // indirect + golang.org/x/net v0.24.0 // indirect + golang.org/x/sys v0.19.0 // indirect + golang.org/x/text v0.14.0 // indirect + golang.org/x/time v0.5.0 // indirect +) diff --git a/worker/go.sum b/worker/go.sum new file mode 100644 index 0000000..26616a1 --- /dev/null +++ b/worker/go.sum @@ -0,0 +1,39 @@ +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/golang-jwt/jwt v3.2.2+incompatible h1:IfV12K8xAKAnZqdXVzCZ+TOjboZ2keLg81eXfW3O+oY= +github.com/golang-jwt/jwt v3.2.2+incompatible/go.mod h1:8pz2t5EyA70fFQQSrl6XZXzqecmYZeUEB8OUGHkxJ+I= +github.com/golang-jwt/jwt/v5 v5.2.1 h1:OuVbFODueb089Lh128TAcimifWaLhJwVflnrgM17wHk= +github.com/golang-jwt/jwt/v5 v5.2.1/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= +github.com/labstack/echo-jwt/v4 v4.2.0 h1:odSISV9JgcSCuhgQSV/6Io3i7nUmfM/QkBeR5GVJj5c= +github.com/labstack/echo-jwt/v4 v4.2.0/go.mod h1:MA2RqdXdEn4/uEglx0HcUOgQSyBaTh5JcaHIan3biwU= +github.com/labstack/echo/v4 v4.12.0 h1:IKpw49IMryVB2p1a4dzwlhP1O2Tf2E0Ir/450lH+kI0= +github.com/labstack/echo/v4 v4.12.0/go.mod h1:UP9Cr2DJXbOK3Kr9ONYzNowSh7HP0aG0ShAyycHSJvM= +github.com/labstack/gommon v0.4.2 h1:F8qTUNXgG1+6WQmqoUWnz8WiEU60mXVVw0P4ht1WRA0= +github.com/labstack/gommon v0.4.2/go.mod h1:QlUFxVM+SNXhDL/Z7YhocGIBYOiwB0mXm1+1bAPHPyU= +github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= +github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= +github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk= +github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= +github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw= +github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc= +github.com/valyala/fasttemplate v1.2.2 h1:lxLXG0uE3Qnshl9QyaK6XJxMXlQZELvChBOCmQD0Loo= +github.com/valyala/fasttemplate v1.2.2/go.mod h1:KHLXt3tVN2HBp8eijSv/kGJopbvo7S+qRAEEKiv+SiQ= +golang.org/x/crypto v0.22.0 h1:g1v0xeRhjcugydODzvb3mEM9SQ0HGp9s/nh3COQ/C30= +golang.org/x/crypto v0.22.0/go.mod h1:vr6Su+7cTlO45qkww3VDJlzDn0ctJvRgYbC2NvXHt+M= +golang.org/x/net v0.24.0 h1:1PcaxkF854Fu3+lvBIx5SYn9wRlBzzcnHZSiaFFAb0w= +golang.org/x/net v0.24.0/go.mod h1:2Q7sJY5mzlzWjKtYUEXSlBWCdyaioyXzRB2RtU8KVE8= +golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.19.0 h1:q5f1RH2jigJ1MoAWp2KTp3gm5zAGFUTarQZ5U386+4o= +golang.org/x/sys v0.19.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ= +golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= +golang.org/x/time v0.5.0 h1:o7cqy6amK/52YcAKIPlM3a+Fpj35zvRj2TP+e1xFSfk= +golang.org/x/time v0.5.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/worker/handlers.go b/worker/handlers.go new file mode 100644 index 0000000..e7ceef6 --- /dev/null +++ b/worker/handlers.go @@ -0,0 +1,67 @@ +package main + +import ( + "fmt" + "net/http" + + "github.com/labstack/echo/v4" +) + +func newBadRequestError(err error) *echo.HTTPError { + return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("Invalid request: %s", err.Error())) +} + +func handleSwiftCompile(c echo.Context) error { + var req swiftCompileRequestData + if err := c.Bind(&req); err != nil { + return newBadRequestError(err) + } + if err := req.validate(); err != nil { + return newBadRequestError(err) + } + + res := execSwiftCompile( + c.Request().Context(), + req.Code, + req.maxDuration(), + ) + + return c.JSON(http.StatusOK, res) +} + +func handleWasmCompile(c echo.Context) error { + var req wasmCompileRequestData + if err := c.Bind(&req); err != nil { + return newBadRequestError(err) + } + if err := req.validate(); err != nil { + return newBadRequestError(err) + } + + res := execWasmCompile( + c.Request().Context(), + req.Code, + req.maxDuration(), + ) + + return c.JSON(http.StatusOK, res) +} + +func handleTestRun(c echo.Context) error { + var req testRunRequestData + if err := c.Bind(&req); err != nil { + return newBadRequestError(err) + } + if err := req.validate(); err != nil { + return newBadRequestError(err) + } + + res := execTestRun( + c.Request().Context(), + req.Code, + req.Stdin, + req.maxDuration(), + ) + + return c.JSON(http.StatusOK, res) +} diff --git a/worker/main.go b/worker/main.go index 0a52b0e..8134a56 100644 --- a/worker/main.go +++ b/worker/main.go @@ -1,57 +1,33 @@ package main import ( - "encoding/json" + "log" "net/http" - "strconv" - "time" -) - -type RequestBody struct { - Code string `json:"code"` - Stdin string `json:"stdin"` -} -type ResponseBody struct { - Result string `json:"result"` - Stdout string `json:"stdout"` - Stderr string `json:"stderr"` -} - -func doExec(code string, stdin string, maxDuration time.Duration) ResponseBody { - _ = code - _ = stdin - _ = maxDuration + echojwt "github.com/labstack/echo-jwt/v4" + "github.com/labstack/echo/v4" + "github.com/labstack/echo/v4/middleware" +) - return ResponseBody{ - Result: "success", - Stdout: "42", - Stderr: "", +func main() { + if err := prepareDirectories(); err != nil { + log.Fatal(err) } -} -func execHandler(w http.ResponseWriter, r *http.Request) { - maxDurationStr := r.URL.Query().Get("max_duration") - maxDuration, err := strconv.Atoi(maxDurationStr) - if err != nil || maxDuration <= 0 { - http.Error(w, "Invalid max_duration parameter", http.StatusBadRequest) - return - } + e := echo.New() - var reqBody RequestBody - err = json.NewDecoder(r.Body).Decode(&reqBody) - if err != nil { - http.Error(w, "Invalid request body", http.StatusBadRequest) - return - } + e.Use(middleware.Logger()) + e.Use(middleware.Recover()) - resBody := doExec(reqBody.Code, reqBody.Stdin, time.Duration(maxDuration)*time.Second) + e.Use(echojwt.WithConfig(echojwt.Config{ + SigningKey: []byte("TODO"), + })) - w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(resBody) -} + e.POST("/api/swiftc", handleSwiftCompile) + e.POST("/api/wasmc", handleWasmCompile) + e.POST("/api/testrun", handleTestRun) -func main() { - http.HandleFunc("/api/exec", execHandler) - http.ListenAndServe(":80", nil) + if err := e.Start(":80"); err != http.ErrServerClosed { + log.Fatal(err) + } } diff --git a/worker/models.go b/worker/models.go new file mode 100644 index 0000000..b838fe0 --- /dev/null +++ b/worker/models.go @@ -0,0 +1,84 @@ +package main + +import ( + "errors" + "time" +) + +const ( + resultSuccess = "success" + resultFailure = "failure" + resultTimeout = "timeout" + resultInternalError = "internal_error" +) + +var ( + errInvalidMaxDuration = errors.New("'max_duration_ms' must be positive") +) + +type swiftCompileRequestData struct { + MaxDurationMilliseconds int `json:"max_duration_ms"` + Code string `json:"code"` +} + +func (req *swiftCompileRequestData) maxDuration() time.Duration { + return time.Duration(req.MaxDurationMilliseconds) * time.Millisecond +} + +func (req *swiftCompileRequestData) validate() error { + if req.MaxDurationMilliseconds <= 0 { + return errInvalidMaxDuration + } + return nil +} + +type swiftCompileResponseData struct { + Result string `json:"result"` + Stdout string `json:"stdout"` + Stderr string `json:"stderr"` +} + +type wasmCompileRequestData struct { + MaxDurationMilliseconds int `json:"max_duration_ms"` + Code string `json:"code"` +} + +type wasmCompileResponseData struct { + Result string `json:"result"` + Stdout string `json:"stdout"` + Stderr string `json:"stderr"` +} + +func (req *wasmCompileRequestData) maxDuration() time.Duration { + return time.Duration(req.MaxDurationMilliseconds) * time.Millisecond +} + +func (req *wasmCompileRequestData) validate() error { + if req.MaxDurationMilliseconds <= 0 { + return errInvalidMaxDuration + } + return nil +} + +type testRunRequestData struct { + MaxDurationMilliseconds int `json:"max_duration_ms"` + Code string `json:"code"` + Stdin string `json:"stdin"` +} + +func (req *testRunRequestData) maxDuration() time.Duration { + return time.Duration(req.MaxDurationMilliseconds) * time.Millisecond +} + +func (req *testRunRequestData) validate() error { + if req.MaxDurationMilliseconds <= 0 { + return errInvalidMaxDuration + } + return nil +} + +type testRunResponseData struct { + Result string `json:"result"` + Stdout string `json:"stdout"` + Stderr string `json:"stderr"` +} |
