package main import ( "bytes" "context" "encoding/json" "fmt" "io" "log" "net/http" "net/url" "os" "time" "github.com/gofiber/fiber/v2" "github.com/gofiber/fiber/v2/middleware/cors" "github.com/gofiber/fiber/v2/middleware/logger" "github.com/gooseek/backend/internal/db" "github.com/gooseek/backend/pkg/config" "github.com/gooseek/backend/pkg/middleware" ) var ( openSandboxURL string repo *db.LearningRepository ) func main() { cfg, err := config.Load() if err != nil { log.Fatal("Failed to load config:", err) } openSandboxURL = getEnv("OPENSANDBOX_URL", "http://opensandbox-server:8080") var database *db.PostgresDB if cfg.DatabaseURL != "" { maxRetries := 30 for i := 0; i < maxRetries; i++ { database, err = db.NewPostgresDB(cfg.DatabaseURL) if err == nil { break } log.Printf("Waiting for database (attempt %d/%d): %v", i+1, maxRetries, err) time.Sleep(2 * time.Second) } if err != nil { log.Fatal("Database required for sandbox-svc:", err) } defer database.Close() ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second) defer cancel() if err := database.RunMigrations(ctx); err != nil { log.Printf("Base migrations warning: %v", err) } repo = db.NewLearningRepository(database) if err := repo.RunMigrations(ctx); err != nil { log.Printf("Learning migrations warning: %v", err) } log.Println("PostgreSQL connected") } else { log.Fatal("DATABASE_URL required for sandbox-svc") } app := fiber.New(fiber.Config{ BodyLimit: 50 * 1024 * 1024, ReadTimeout: 60 * time.Second, WriteTimeout: 5 * time.Minute, }) app.Use(logger.New()) app.Use(cors.New()) app.Get("/health", func(c *fiber.Ctx) error { return c.JSON(fiber.Map{"status": "ok"}) }) api := app.Group("/api/v1/sandbox", middleware.JWT(middleware.JWTConfig{ Secret: cfg.JWTSecret, AuthSvcURL: cfg.AuthSvcURL, AllowGuest: false, })) api.Post("/sessions", handleCreateSession) api.Get("/sessions/:id", handleGetSession) api.Get("/sessions/:id/files", handleListFiles) api.Get("/sessions/:id/file", handleReadFile) api.Put("/sessions/:id/file", handleWriteFile) api.Post("/sessions/:id/commands/run", handleRunCommand) api.Post("/sessions/:id/verify", handleVerify) port := getEnvInt("SANDBOX_SVC_PORT", 3036) log.Printf("sandbox-svc listening on :%d", port) log.Fatal(app.Listen(fmt.Sprintf(":%d", port))) } func handleCreateSession(c *fiber.Ctx) error { userID := middleware.GetUserID(c) if userID == "" { return c.Status(401).JSON(fiber.Map{"error": "Authentication required"}) } var req struct { TaskID string `json:"taskId"` Image string `json:"image"` } if err := c.BodyParser(&req); err != nil { return c.Status(400).JSON(fiber.Map{"error": "Invalid request"}) } image := req.Image if image == "" { image = "opensandbox/code-interpreter:v1.0.1" } sandboxResp, err := createOpenSandbox(image) if err != nil { log.Printf("OpenSandbox create error: %v", err) return c.Status(503).JSON(fiber.Map{"error": "Sandbox creation failed: " + err.Error()}) } session := &db.SandboxSession{ UserID: userID, OpenSandboxID: sandboxResp.ID, Status: "ready", Metadata: json.RawMessage(fmt.Sprintf(`{"image":"%s"}`, image)), } if req.TaskID != "" { session.TaskID = &req.TaskID } if err := repo.CreateSandboxSession(c.Context(), session); err != nil { return c.Status(500).JSON(fiber.Map{"error": "Failed to save session"}) } logEvent(c.Context(), session.ID, "session_created", map[string]interface{}{"image": image}) return c.Status(201).JSON(session) } func handleGetSession(c *fiber.Ctx) error { userID := middleware.GetUserID(c) session, err := repo.GetSandboxSession(c.Context(), c.Params("id")) if err != nil || session == nil || session.UserID != userID { return c.Status(404).JSON(fiber.Map{"error": "Session not found"}) } return c.JSON(session) } func handleListFiles(c *fiber.Ctx) error { userID := middleware.GetUserID(c) session, err := repo.GetSandboxSession(c.Context(), c.Params("id")) if err != nil || session == nil || session.UserID != userID { return c.Status(404).JSON(fiber.Map{"error": "Session not found"}) } path := c.Query("path", "/home/user") result, err := sandboxFilesRequest(session.OpenSandboxID, path) if err != nil { return c.Status(500).JSON(fiber.Map{"error": "Failed to list files"}) } return c.JSON(result) } func handleReadFile(c *fiber.Ctx) error { userID := middleware.GetUserID(c) session, err := repo.GetSandboxSession(c.Context(), c.Params("id")) if err != nil || session == nil || session.UserID != userID { return c.Status(404).JSON(fiber.Map{"error": "Session not found"}) } path := c.Query("path") if path == "" { return c.Status(400).JSON(fiber.Map{"error": "path query required"}) } content, err := sandboxReadFile(session.OpenSandboxID, path) if err != nil { return c.Status(500).JSON(fiber.Map{"error": "Failed to read file"}) } logEvent(c.Context(), session.ID, "file_read", map[string]interface{}{"path": path}) return c.JSON(fiber.Map{"path": path, "content": content}) } func handleWriteFile(c *fiber.Ctx) error { userID := middleware.GetUserID(c) session, err := repo.GetSandboxSession(c.Context(), c.Params("id")) if err != nil || session == nil || session.UserID != userID { return c.Status(404).JSON(fiber.Map{"error": "Session not found"}) } var req struct { Path string `json:"path"` Content string `json:"content"` } if err := c.BodyParser(&req); err != nil { return c.Status(400).JSON(fiber.Map{"error": "Invalid request"}) } if err := sandboxWriteFile(session.OpenSandboxID, req.Path, req.Content); err != nil { return c.Status(500).JSON(fiber.Map{"error": "Failed to write file"}) } logEvent(c.Context(), session.ID, "file_write", map[string]interface{}{ "path": req.Path, "size": len(req.Content), }) return c.JSON(fiber.Map{"success": true}) } func handleRunCommand(c *fiber.Ctx) error { userID := middleware.GetUserID(c) session, err := repo.GetSandboxSession(c.Context(), c.Params("id")) if err != nil || session == nil || session.UserID != userID { return c.Status(404).JSON(fiber.Map{"error": "Session not found"}) } var req struct { Command string `json:"command"` Cwd string `json:"cwd"` } if err := c.BodyParser(&req); err != nil { return c.Status(400).JSON(fiber.Map{"error": "Invalid request"}) } result, err := sandboxRunCommand(session.OpenSandboxID, req.Command, req.Cwd) if err != nil { return c.Status(500).JSON(fiber.Map{"error": "Failed to run command"}) } logEvent(c.Context(), session.ID, "command_run", map[string]interface{}{ "command": req.Command, "exit_code": result["exit_code"], }) return c.JSON(result) } func handleVerify(c *fiber.Ctx) error { userID := middleware.GetUserID(c) session, err := repo.GetSandboxSession(c.Context(), c.Params("id")) if err != nil || session == nil || session.UserID != userID { return c.Status(404).JSON(fiber.Map{"error": "Session not found"}) } var req struct { Command string `json:"command"` } if err := c.BodyParser(&req); err != nil { return c.Status(400).JSON(fiber.Map{"error": "Invalid request"}) } if req.Command == "" { if session.TaskID != nil { task, _ := repo.GetTask(c.Context(), *session.TaskID) if task != nil && task.VerificationCmd != "" { req.Command = task.VerificationCmd } } if req.Command == "" { req.Command = "echo 'No verification command configured'" } } result, err := sandboxRunCommand(session.OpenSandboxID, req.Command, "") if err != nil { return c.Status(500).JSON(fiber.Map{"error": "Verification failed"}) } logEvent(c.Context(), session.ID, "verify", map[string]interface{}{ "command": req.Command, "exit_code": result["exit_code"], "stdout": result["stdout"], }) passed := false if exitCode, ok := result["exit_code"].(float64); ok && exitCode == 0 { passed = true } if session.TaskID != nil { resultJSON, _ := json.Marshal(result) submission := &db.LearningSubmission{ TaskID: *session.TaskID, SandboxSessionID: &session.ID, Result: resultJSON, Score: 0, MaxScore: 100, } if passed { submission.Score = 100 } repo.CreateSubmission(c.Context(), submission) repo.UpdateTaskStatus(c.Context(), *session.TaskID, "verified") } return c.JSON(fiber.Map{ "passed": passed, "result": result, "sessionId": session.ID, }) } // --- OpenSandbox HTTP client --- type sandboxCreateResponse struct { ID string `json:"id"` SandboxID string `json:"sandbox_id"` Data struct { ID string `json:"id"` } `json:"data"` } func createOpenSandbox(image string) (*sandboxCreateResponse, error) { payload, _ := json.Marshal(map[string]interface{}{ "image": image, "entrypoint": []string{"/opt/opensandbox/code-interpreter.sh"}, "timeout": "30m", }) ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second) defer cancel() req, err := http.NewRequestWithContext(ctx, "POST", openSandboxURL+"/api/v1/sandboxes", bytes.NewReader(payload)) if err != nil { return nil, err } req.Header.Set("Content-Type", "application/json") resp, err := http.DefaultClient.Do(req) if err != nil { return nil, fmt.Errorf("opensandbox unreachable: %w", err) } defer resp.Body.Close() if resp.StatusCode >= 400 { body, _ := io.ReadAll(resp.Body) return nil, fmt.Errorf("opensandbox error %d: %s", resp.StatusCode, string(body)) } var result sandboxCreateResponse if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { return nil, err } if result.ID == "" { if result.SandboxID != "" { result.ID = result.SandboxID } else if result.Data.ID != "" { result.ID = result.Data.ID } } if result.ID == "" { return nil, fmt.Errorf("opensandbox response missing sandbox id") } return &result, nil } func sandboxFilesRequest(sandboxID, path string) (interface{}, error) { reqURL := fmt.Sprintf("%s/api/v1/sandboxes/%s/files?path=%s", openSandboxURL, sandboxID, url.QueryEscape(path)) ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) defer cancel() req, err := http.NewRequestWithContext(ctx, "GET", reqURL, nil) if err != nil { return nil, err } resp, err := http.DefaultClient.Do(req) if err != nil { return nil, err } defer resp.Body.Close() if resp.StatusCode >= 400 { body, _ := io.ReadAll(resp.Body) return nil, fmt.Errorf("files request failed: status %d: %s", resp.StatusCode, string(body)) } var result interface{} if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { return nil, err } return result, nil } func sandboxReadFile(sandboxID, path string) (string, error) { reqURL := fmt.Sprintf("%s/api/v1/sandboxes/%s/files/read?path=%s", openSandboxURL, sandboxID, url.QueryEscape(path)) ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) defer cancel() req, err := http.NewRequestWithContext(ctx, "GET", reqURL, nil) if err != nil { return "", err } resp, err := http.DefaultClient.Do(req) if err != nil { return "", err } defer resp.Body.Close() if resp.StatusCode >= 400 { body, _ := io.ReadAll(resp.Body) return "", fmt.Errorf("read file failed: status %d: %s", resp.StatusCode, string(body)) } body, err := io.ReadAll(resp.Body) if err != nil { return "", err } var structured map[string]interface{} if err := json.Unmarshal(body, &structured); err == nil { if content, ok := structured["content"].(string); ok { return content, nil } if data, ok := structured["data"].(map[string]interface{}); ok { if content, ok := data["content"].(string); ok { return content, nil } } } return string(body), nil } func sandboxWriteFile(sandboxID, path, content string) error { payload, _ := json.Marshal(map[string]interface{}{ "entries": []map[string]interface{}{ {"path": path, "data": content, "mode": 644}, }, }) url := fmt.Sprintf("%s/api/v1/sandboxes/%s/files/write", openSandboxURL, sandboxID) ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) defer cancel() req, err := http.NewRequestWithContext(ctx, "POST", url, bytes.NewReader(payload)) if err != nil { return err } req.Header.Set("Content-Type", "application/json") resp, err := http.DefaultClient.Do(req) if err != nil { return err } defer resp.Body.Close() if resp.StatusCode >= 400 { body, _ := io.ReadAll(resp.Body) return fmt.Errorf("write file failed: status %d: %s", resp.StatusCode, string(body)) } return nil } func sandboxRunCommand(sandboxID, command, cwd string) (map[string]interface{}, error) { if cwd == "" { cwd = "/home/user" } payload, _ := json.Marshal(map[string]interface{}{ "cmd": command, "cwd": cwd, }) url := fmt.Sprintf("%s/api/v1/sandboxes/%s/commands/run", openSandboxURL, sandboxID) ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute) defer cancel() req, err := http.NewRequestWithContext(ctx, "POST", url, bytes.NewReader(payload)) if err != nil { return nil, err } req.Header.Set("Content-Type", "application/json") resp, err := http.DefaultClient.Do(req) if err != nil { return nil, err } defer resp.Body.Close() if resp.StatusCode >= 400 { body, _ := io.ReadAll(resp.Body) return nil, fmt.Errorf("run command failed: status %d: %s", resp.StatusCode, string(body)) } var result map[string]interface{} if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { return nil, err } normalizeCommandResult(result) return result, nil } func normalizeCommandResult(result map[string]interface{}) { if result == nil { return } if _, ok := result["exit_code"]; !ok { if exitCode, exists := result["exitCode"]; exists { result["exit_code"] = exitCode } } if _, ok := result["stdout"]; !ok { if output, exists := result["output"]; exists { if s, ok := output.(string); ok { result["stdout"] = s } } } if _, ok := result["stderr"]; !ok { result["stderr"] = "" } } func logEvent(ctx context.Context, sessionID, eventType string, data map[string]interface{}) { if repo == nil { return } payload, _ := json.Marshal(data) repo.CreateSandboxEvent(ctx, sessionID, eventType, payload) } func getEnv(key, defaultValue string) string { if val := os.Getenv(key); val != "" { return val } return defaultValue } func getEnvInt(key string, defaultValue int) int { if val := os.Getenv(key); val != "" { var result int if _, err := fmt.Sscanf(val, "%d", &result); err == nil { return result } } return defaultValue }