package main import ( "context" "log" "net/http" "os" "github.com/flatrender/render-svc/internal/db" "github.com/flatrender/render-svc/internal/handlers" "github.com/flatrender/render-svc/internal/middleware" "github.com/flatrender/render-svc/internal/notifier" "github.com/gin-gonic/gin" "github.com/jackc/pgx/v5/pgxpool" "github.com/minio/minio-go/v7" "github.com/minio/minio-go/v7/pkg/credentials" ) func getEnv(key, fallback string) string { if v := os.Getenv(key); v != "" { return v } return fallback } func main() { // ── Config ──────────────────────────────────────────────────────────────── dbURL := getEnv("DATABASE_URL", "postgres://postgres:postgres@localhost:5432/flatrender?search_path=render,public") jwtSecret := getEnv("JWT_SECRET", "change-me") nodeSecret := getEnv("NODE_HMAC_SECRET", "node-secret") minioEndpoint := getEnv("MINIO_ENDPOINT", "localhost:9000") minioAccessKey := getEnv("MINIO_ACCESS_KEY", "minioadmin") minioSecretKey := getEnv("MINIO_SECRET_KEY", "minioadmin") minioUseSSL := getEnv("MINIO_USE_SSL", "false") == "true" minioBucket := getEnv("MINIO_BUCKET", "flatrender-exports") minioTemplatesBucket := getEnv("MINIO_TEMPLATES_BUCKET", "flatrender-templates") notificationURL := getEnv("NOTIFICATION_URL", "http://localhost:8080") serviceToken := getEnv("SERVICE_TOKEN", "internal-service-secret") port := getEnv("PORT", "8080") // ── Database ────────────────────────────────────────────────────────────── pool, err := pgxpool.New(context.Background(), dbURL) if err != nil { log.Fatalf("connect db: %v", err) } defer pool.Close() if err := pool.Ping(context.Background()); err != nil { log.Fatalf("ping db: %v", err) } // ── MinIO ────────────────────────────────────────────────────────────────── mc, err := minio.New(minioEndpoint, &minio.Options{ Creds: credentials.NewStaticV4(minioAccessKey, minioSecretKey, ""), Secure: minioUseSSL, }) if err != nil { log.Fatalf("minio client: %v", err) } // ── Store + handlers ────────────────────────────────────────────────────── store := db.NewStore(pool) notifyClient := notifier.New(notificationURL, serviceToken) renderH := handlers.NewRenderHandler(store) snapH := handlers.NewSnapshotHandler(store) exportH := handlers.NewExportHandler(store, mc, minioBucket) nodeH := handlers.NewNodeHandler(store) internalH := handlers.NewInternalHandler(store, notifyClient, mc, minioTemplatesBucket, minioBucket) // ── Router ──────────────────────────────────────────────────────────────── r := gin.Default() r.GET("/health", func(c *gin.Context) { if err := pool.Ping(c.Request.Context()); err != nil { c.JSON(http.StatusServiceUnavailable, gin.H{"status": "down", "error": err.Error()}) return } c.JSON(http.StatusOK, gin.H{"status": "ok"}) }) auth := middleware.JWTAuth(jwtSecret) admin := middleware.RequireAdmin() nodeAuth := middleware.NodeHMAC(nodeSecret) v1 := r.Group("/v1") // ── Render jobs ─────────────────────────────────────────────────────────── renders := v1.Group("/renders", auth) { renders.GET("", renderH.List) renders.POST("", renderH.Create) renders.GET("/:job_id", renderH.Get) renders.POST("/:job_id/cancel", renderH.Cancel) renders.POST("/:job_id/retry", renderH.Retry) renders.GET("/:job_id/progress", renderH.Progress) renders.GET("/:job_id/logs", renderH.Logs) } // ── Snapshots ───────────────────────────────────────────────────────────── snaps := v1.Group("/snapshots", auth) { snaps.POST("", snapH.Create) snaps.GET("/:snapshot_id", snapH.Get) } // ── Exports ─────────────────────────────────────────────────────────────── exports := v1.Group("/exports", auth) { exports.GET("", exportH.List) exports.GET("/:export_id", exportH.Get) exports.DELETE("/:export_id", exportH.Delete) exports.POST("/:export_id/extend", exportH.Extend) exports.GET("/:export_id/download-url", exportH.DownloadURL) } // ── Nodes (admin) ───────────────────────────────────────────────────────── nodes := v1.Group("/nodes", auth, admin) { nodes.GET("", nodeH.List) nodes.POST("", nodeH.Create) nodes.GET("/:node_id", nodeH.Get) nodes.PATCH("/:node_id", nodeH.Patch) nodes.POST("/:node_id/restart", nodeH.Restart) nodes.POST("/:node_id/release", nodeH.Release) nodes.POST("/:node_id/close-ae", nodeH.CloseAE) nodes.GET("/:node_id/health", nodeH.Health) nodes.GET("/:node_id/health/history", nodeH.HealthHistory) nodes.GET("/:node_id/crashes", nodeH.Crashes) } // ── Node updates (admin) ────────────────────────────────────────────────── v1.GET("/node-updates", auth, admin, nodeH.ListUpdates) v1.POST("/node-updates/:update_id/rollout", auth, admin, nodeH.Rollout) // ── Internal (node agents only — HMAC auth) ─────────────────────────────── internal := v1.Group("/internal", nodeAuth) { internal.POST("/nodes/:node_id/heartbeat", internalH.Heartbeat) internal.POST("/nodes/:node_id/online", internalH.Online) internal.POST("/nodes/:node_id/cache-update", internalH.CacheUpdate) internal.POST("/render/jobs/claim", internalH.Claim) internal.POST("/render/jobs/:job_id/preview", internalH.Preview) internal.POST("/render/jobs/:job_id/output-upload-url", internalH.OutputUploadURL) internal.POST("/render/jobs/:job_id/frames", internalH.FrameProgress) internal.POST("/render/jobs/:job_id/complete", internalH.Complete) internal.POST("/render/jobs/:job_id/fail", internalH.Fail) internal.POST("/render/jobs/:job_id/crash", internalH.Crash) internal.POST("/render/jobs/:job_id/replica-ready", internalH.ReplicaReady) } log.Printf("render-svc listening on :%s", port) if err := r.Run(":" + port); err != nil { log.Fatalf("server: %v", err) } }