- Add Gitea Actions workflow for automated build & deploy - Add K8s manifests: webui, travel-svc, medicine-svc, sandbox-svc - Update kustomization for localhost:5000 registry - Add ingress for gooseek.ru and api.gooseek.ru - Learning cabinet with onboarding, courses, sandbox integration - Medicine service with symptom analysis and doctor matching - Travel service with itinerary planning - Server setup scripts (NVIDIA/CUDA, K3s, Gitea runner) Made-with: Cursor
541 lines
14 KiB
Go
541 lines
14 KiB
Go
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
|
|
}
|
|
|