Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -34,4 +34,4 @@ config.yaml
past/

# Private interview template content
frontend/src/util/templateContent.ts
frontend/src/util/reactTemplateContent.ts
26 changes: 26 additions & 0 deletions Dockerfile.runner
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
# Build stage
FROM golang:1.25-alpine AS build

WORKDIR /app

COPY go.mod go.sum ./
RUN go mod download

COPY . .
RUN CGO_ENABLED=0 GOOS=linux go build -o runner ./cmd/runner

# Production stage
FROM alpine:latest

RUN apk --no-cache add ca-certificates docker-cli && \
ln -s /usr/bin/docker /usr/local/bin/docker

WORKDIR /app

COPY --from=build /app/runner .
COPY --from=build /app/config ./config
COPY --from=build /app/docker ./docker

EXPOSE 0

CMD ["./runner"]
145 changes: 145 additions & 0 deletions cmd/runner/main.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,145 @@
package main

import (
"context"
"encoding/json"
"log"
"os"
"os/signal"
"syscall"
"time"

"github.com/redis/go-redis/v9"

"goderpad/config"
"goderpad/execution"
"goderpad/models"
"goderpad/redisclient"
"goderpad/services"
)

const runnerGroup = "runners"

func runnerConsumerName() string {
hostname, _ := os.Hostname()
return "runner-" + hostname
}

func main() {
if err := config.Load("config/config.yml"); err != nil {
log.Fatalf("failed to load config: %v", err)
}

if err := redisclient.Init(); err != nil {
log.Fatalf("failed to connect to redis: %v", err)
}
defer redisclient.Close()

// Build sandbox Docker images before accepting jobs
if config.GetEnableCodeExecution() {
execution.BuildImages()
}

ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
defer stop()

rdb := redisclient.GetClient()
consumer := runnerConsumerName()

// Create consumer group for the jobs stream
err := rdb.XGroupCreateMkStream(ctx, services.JobsStream, runnerGroup, "0").Err()
if err != nil && err.Error() != "BUSYGROUP Consumer Group name already exists" {
log.Printf("warning: could not create jobs consumer group: %v", err)
}

log.Printf("code runner started (consumer=%s), waiting for jobs...", consumer)

for {
select {
case <-ctx.Done():
log.Println("runner shutting down")
return
default:
}

streams, err := rdb.XReadGroup(ctx, &redis.XReadGroupArgs{
Group: runnerGroup,
Consumer: consumer,
Streams: []string{services.JobsStream, ">"},
Count: 1,
Block: 5 * time.Second,
}).Result()

if err != nil {
if err == redis.Nil || ctx.Err() != nil {
continue
}
log.Printf("error reading jobs stream: %v", err)
time.Sleep(1 * time.Second)
continue
}

for _, stream := range streams {
for _, msg := range stream.Messages {
processJob(ctx, rdb, msg)
}
}
}
}

func processJob(ctx context.Context, rdb *redis.Client, msg redis.XMessage) {
data, ok := msg.Values["data"].(string)
if !ok {
log.Printf("invalid job message: %s", msg.ID)
rdb.XAck(ctx, services.JobsStream, runnerGroup, msg.ID)
return
}

var job models.ExecuteJob
if err := json.Unmarshal([]byte(data), &job); err != nil {
log.Printf("failed to unmarshal job: %v", err)
rdb.XAck(ctx, services.JobsStream, runnerGroup, msg.ID)
return
}

log.Printf("executing job %s: %s for room %s", job.JobID, job.Language, job.RoomID)

result := models.ExecuteResult{
JobID: job.JobID,
RoomID: job.RoomID,
UserID: job.UserID,
}

execResult, err := execution.RunCode(job.Language, job.Code)
if err != nil {
result.Stdout = ""
result.Stderr = err.Error()
result.Code = -1
} else {
result.Stdout = execResult.Stdout
result.Stderr = execResult.Stderr
result.Code = execResult.Code
}

// Publish result back to Redis
resultData, err := json.Marshal(result)
if err != nil {
log.Printf("failed to marshal result for job %s: %v", job.JobID, err)
rdb.XAck(ctx, services.JobsStream, runnerGroup, msg.ID)
return
}

if err := rdb.XAdd(ctx, &redis.XAddArgs{
Stream: services.ResultsStream,
MaxLen: 1000,
Approx: true,
Values: map[string]interface{}{
"data": string(resultData),
},
}).Err(); err != nil {
log.Printf("failed to publish result for job %s: %v", job.JobID, err)
}

rdb.XAck(ctx, services.JobsStream, runnerGroup, msg.ID)
log.Printf("completed job %s (exit code: %d)", job.JobID, result.Code)
}
20 changes: 20 additions & 0 deletions cmd/server/main.go
Original file line number Diff line number Diff line change
@@ -1,16 +1,21 @@
package main

import (
"context"
"log"
"os/signal"
"strconv"
"syscall"

"github.com/gin-contrib/cors"
"github.com/gin-gonic/gin"
"github.com/prometheus/client_golang/prometheus/promhttp"

"goderpad/config"
"goderpad/execution"
"goderpad/handlers"
"goderpad/metrics"
"goderpad/redisclient"
"goderpad/services"
)

Expand All @@ -36,8 +41,10 @@ func main() {
})
})

r.POST("/execute", handlers.ExecuteHandler)
r.POST("/createRoom", handlers.CreateRoomHandler)
r.POST("/joinRoom", handlers.JoinRoomHandler)
r.POST("/switchLanguage", handlers.SwitchLanguageHandler)
r.GET("/getRoomName/:roomID", handlers.GetRoomNameHandler)
r.GET("/past/:roomID", handlers.GetDocumentSaveHandler)
r.GET("/validateKey", handlers.ValidateKeyHandler)
Expand All @@ -48,6 +55,19 @@ func main() {

go services.DeleteRoomSaves()

if config.GetEnableCodeExecution() {
go execution.BuildImages()

if err := redisclient.Init(); err != nil {
log.Fatalf("Failed to connect to Redis: %v", err)
}
defer redisclient.Close()

ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
defer stop()
go services.StartResultListener(ctx)
}

r.Run(":" + config.GetPort())
}

Expand Down
9 changes: 8 additions & 1 deletion config/config.example.yml
Original file line number Diff line number Diff line change
@@ -1,6 +1,13 @@
server:
port: YOUR_PORT_HERE
api_key: YOUR_API_KEY_HERE
enable_code_execution: false
docker_binary_path: "" # leave empty to use "docker" from $PATH, or set full path e.g. /usr/local/bin/docker
allowed_origins:
- "http://localhost:7777"
- "http://localhost:5173"
- "http://localhost:5173"

redis:
addr: "localhost:6379"
password: ""
db: 0
44 changes: 41 additions & 3 deletions config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,12 +9,21 @@ import (

type Config struct {
Server ServerConfig `yaml:"server"`
Redis RedisConfig `yaml:"redis"`
}

type RedisConfig struct {
Addr string `yaml:"addr"`
Password string `yaml:"password"`
DB int `yaml:"db"`
}

type ServerConfig struct {
Port string `yaml:"port"`
APIKey string `yaml:"api_key"`
AllowedOrigins []string `yaml:"allowed_origins"`
Port string `yaml:"port"`
APIKey string `yaml:"api_key"`
AllowedOrigins []string `yaml:"allowed_origins"`
EnableCodeExecution bool `yaml:"enable_code_execution"`
DockerBinaryPath string `yaml:"docker_binary_path"`
}

var AppConfig Config
Expand Down Expand Up @@ -45,3 +54,32 @@ func GetAPIKey() string {
func GetAllowedOrigins() []string {
return AppConfig.Server.AllowedOrigins
}

func GetEnableCodeExecution() bool {
return AppConfig.Server.EnableCodeExecution
}

func GetRedisAddr() string {
if env := os.Getenv("REDIS_ADDR"); env != "" {
return env
}
if AppConfig.Redis.Addr != "" {
return AppConfig.Redis.Addr
}
return "localhost:6379"
}

func GetRedisPassword() string {
return AppConfig.Redis.Password
}

func GetRedisDB() int {
return AppConfig.Redis.DB
}

func GetDockerBinaryPath() string {
if AppConfig.Server.DockerBinaryPath != "" {
return AppConfig.Server.DockerBinaryPath
}
return "docker"
}
26 changes: 25 additions & 1 deletion docker-compose.dev.yml
Original file line number Diff line number Diff line change
@@ -1,14 +1,38 @@
services:
goderpad-redis:
image: redis:7-alpine
container_name: goderpad-redis
ports:
- "6379:6379"
profiles:
- code-execution

goderpad-backend:
build:
context: .
dockerfile: Dockerfile.backend
container_name: goderpad-backend
ports:
- "7778:7778"
environment:
- REDIS_ADDR=goderpad-redis:6379
volumes:
- ./past:/app/past

goderpad-runner:
build:
context: .
dockerfile: Dockerfile.runner
container_name: goderpad-runner
environment:
- REDIS_ADDR=goderpad-redis:6379
volumes:
- /var/run/docker.sock:/var/run/docker.sock
depends_on:
- goderpad-redis
profiles:
- code-execution

goderpad-frontend:
build:
context: ./frontend
Expand All @@ -24,7 +48,7 @@ services:
environment:
- VITE_API_URL=http://localhost:7778
- VITE_WS_URL=ws://localhost:7778

goderpad-nginx:
image: nginx:alpine
volumes:
Expand Down
22 changes: 22 additions & 0 deletions docker-compose.yml
Original file line number Diff line number Diff line change
@@ -1,12 +1,34 @@
services:
goderpad-redis:
image: redis:7-alpine
container_name: goderpad-redis
profiles:
- code-execution

goderpad-backend:
build:
context: .
dockerfile: Dockerfile.backend
container_name: goderpad-backend
environment:
- REDIS_ADDR=goderpad-redis:6379
volumes:
- ./past:/app/past

goderpad-runner:
build:
context: .
dockerfile: Dockerfile.runner
container_name: goderpad-runner
environment:
- REDIS_ADDR=goderpad-redis:6379
volumes:
- /var/run/docker.sock:/var/run/docker.sock
depends_on:
- goderpad-redis
profiles:
- code-execution

goderpad-nginx:
build:
context: .
Expand Down
2 changes: 2 additions & 0 deletions docker/cpp.Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
FROM alpine:3.20
RUN apk add --no-cache g++
1 change: 1 addition & 0 deletions docker/java.Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
FROM eclipse-temurin:21-jdk-alpine
1 change: 1 addition & 0 deletions docker/javascript.Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
FROM node:20-alpine
Loading