184
internal/server/server.go
Normal file
184
internal/server/server.go
Normal file
@@ -0,0 +1,184 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"embed"
|
||||
"io/fs"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"ci/internal/handler"
|
||||
"ci/internal/service"
|
||||
"ci/internal/store"
|
||||
"ci/internal/workspace"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// Config holds server configuration.
|
||||
type Config struct {
|
||||
Port string
|
||||
DataDir string
|
||||
}
|
||||
|
||||
// DefaultConfig returns the default server configuration.
|
||||
func DefaultConfig() Config {
|
||||
return Config{
|
||||
Port: "8080",
|
||||
DataDir: "data",
|
||||
}
|
||||
}
|
||||
|
||||
// Server wraps the Gin engine and related services.
|
||||
type Server struct {
|
||||
engine *gin.Engine
|
||||
config Config
|
||||
svc *service.ProjectService
|
||||
}
|
||||
|
||||
// New creates a new Server with the given config and embedded frontend.
|
||||
// Pass nil for frontendFS when using local file serving (dev mode).
|
||||
func New(config Config, frontendFS *embed.FS) (*Server, error) {
|
||||
// Resolve absolute data directory
|
||||
absDataDir, err := filepath.Abs(config.DataDir)
|
||||
if err != nil {
|
||||
absDataDir = config.DataDir
|
||||
}
|
||||
|
||||
// Initialize store
|
||||
st, err := store.New(absDataDir)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Initialize workspace
|
||||
workspaceDir := filepath.Join(absDataDir, "workspaces")
|
||||
wm, err := workspace.New(workspaceDir)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Initialize service
|
||||
svc := service.NewProjectService(st, wm)
|
||||
|
||||
// Initialize handlers
|
||||
projectH := handler.NewProjectHandler(svc)
|
||||
buildH := handler.NewBuildHandler(svc)
|
||||
uploadH := handler.NewUploadHandler(svc)
|
||||
deployH := handler.NewDeployHandler(svc)
|
||||
|
||||
gin.SetMode(gin.ReleaseMode)
|
||||
r := gin.Default()
|
||||
|
||||
// CORS middleware for development
|
||||
r.Use(func(c *gin.Context) {
|
||||
c.Header("Access-Control-Allow-Origin", "*")
|
||||
c.Header("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS")
|
||||
c.Header("Access-Control-Allow-Headers", "Content-Type, Authorization")
|
||||
if c.Request.Method == "OPTIONS" {
|
||||
c.AbortWithStatus(http.StatusNoContent)
|
||||
return
|
||||
}
|
||||
c.Next()
|
||||
})
|
||||
|
||||
// Health check
|
||||
r.GET("/api/health", func(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{"status": "ok"})
|
||||
})
|
||||
|
||||
// API routes
|
||||
api := r.Group("/api")
|
||||
{
|
||||
// Projects CRUD
|
||||
api.GET("/projects", projectH.ListProjects)
|
||||
api.POST("/projects", projectH.CreateProject)
|
||||
api.GET("/projects/:id", projectH.GetProject)
|
||||
api.PUT("/projects/:id", projectH.UpdateProject)
|
||||
api.DELETE("/projects/:id", projectH.DeleteProject)
|
||||
|
||||
// Build & Run
|
||||
api.POST("/projects/:id/build", buildH.TriggerBuild)
|
||||
api.POST("/projects/:id/start", buildH.StartProject)
|
||||
api.POST("/projects/:id/stop", buildH.StopProject)
|
||||
api.POST("/projects/:id/restart", buildH.RestartProject)
|
||||
api.GET("/projects/:id/builds", buildH.GetBuilds)
|
||||
api.GET("/projects/:id/logs/:buildId", buildH.GetBuildLog)
|
||||
api.GET("/projects/:id/logs/stream", buildH.StreamLogs)
|
||||
|
||||
// Deploy config
|
||||
api.GET("/projects/:id/deploy-config", deployH.GetDeployConfig)
|
||||
api.PUT("/projects/:id/deploy-config", deployH.UpdateDeployConfig)
|
||||
api.DELETE("/projects/:id/deploy-config", deployH.DeleteDeployConfig)
|
||||
|
||||
// Deploy execution
|
||||
api.POST("/projects/:id/deploy", deployH.TriggerDeploy)
|
||||
api.GET("/projects/:id/deploy-records", deployH.GetDeployRecords)
|
||||
api.GET("/projects/:id/deploy-records/:did/log", deployH.GetDeployLog)
|
||||
|
||||
// Upload
|
||||
api.POST("/projects/:id/upload", uploadH.UploadFiles)
|
||||
|
||||
// Webhook
|
||||
api.POST("/webhook/:id", buildH.Webhook)
|
||||
}
|
||||
|
||||
// Serve frontend
|
||||
if frontendFS != nil {
|
||||
// Embedded mode: serve from embedded dist files
|
||||
distFS, err := fs.Sub(frontendFS, "web/dist")
|
||||
if err != nil {
|
||||
// Try without the prefix
|
||||
distFS = frontendFS
|
||||
}
|
||||
|
||||
staticFS, err := fs.Sub(distFS, ".")
|
||||
_ = staticFS
|
||||
_ = err
|
||||
|
||||
// Use gin's static file serving with embedded FS
|
||||
r.NoRoute(gin.WrapH(http.FileServer(http.FS(distFS))))
|
||||
} else {
|
||||
// Dev mode: try serving from web/dist directory
|
||||
localDist := "web/dist"
|
||||
if _, err := os.Stat(localDist); err == nil {
|
||||
r.NoRoute(func(c *gin.Context) {
|
||||
path := c.Request.URL.Path
|
||||
if path == "/" {
|
||||
c.File(filepath.Join(localDist, "index.html"))
|
||||
return
|
||||
}
|
||||
// Try the specific file first
|
||||
targetPath := filepath.Join(localDist, path)
|
||||
if _, err := os.Stat(targetPath); err == nil {
|
||||
c.File(targetPath)
|
||||
return
|
||||
}
|
||||
// SPA fallback
|
||||
c.File(filepath.Join(localDist, "index.html"))
|
||||
})
|
||||
} else {
|
||||
r.NoRoute(func(c *gin.Context) {
|
||||
c.String(http.StatusOK, "CI/CD Server is running. Frontend not yet built — run `cd web && npm run build`.")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
srv := &Server{
|
||||
engine: r,
|
||||
config: config,
|
||||
svc: svc,
|
||||
}
|
||||
|
||||
return srv, nil
|
||||
}
|
||||
|
||||
// Run starts the HTTP server.
|
||||
func (s *Server) Run() error {
|
||||
return s.engine.Run(":" + s.config.Port)
|
||||
}
|
||||
|
||||
// Shutdown gracefully shuts down the server.
|
||||
func (s *Server) Shutdown() {
|
||||
s.svc.Shutdown()
|
||||
}
|
||||
Reference in New Issue
Block a user