Files
ci/internal/server/server.go
2026-06-18 21:19:47 +08:00

185 lines
4.6 KiB
Go

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()
}