550 lines
14 KiB
Go
550 lines
14 KiB
Go
package service
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"os"
|
|
"path/filepath"
|
|
"sync"
|
|
"time"
|
|
|
|
"ci/internal/builder"
|
|
"ci/internal/deployer"
|
|
gitsvc "ci/internal/git"
|
|
"ci/internal/model"
|
|
"ci/internal/process"
|
|
"ci/internal/store"
|
|
"ci/internal/workspace"
|
|
)
|
|
|
|
// ProjectService handles all business logic for project management.
|
|
type ProjectService struct {
|
|
Store *store.Store
|
|
Workspace *workspace.Manager
|
|
Git *gitsvc.Service
|
|
Builder *builder.Service
|
|
Process *process.Manager
|
|
Deployer *deployer.Service
|
|
|
|
// SSE subscribers for log streaming
|
|
mu sync.RWMutex
|
|
subscribers map[uint][]chan string // projectID -> list of subscriber channels
|
|
}
|
|
|
|
// NewProjectService creates a new ProjectService.
|
|
func NewProjectService(s *store.Store, w *workspace.Manager) *ProjectService {
|
|
return &ProjectService{
|
|
Store: s,
|
|
Workspace: w,
|
|
Git: gitsvc.New(),
|
|
Builder: builder.New(),
|
|
Process: process.NewManager(),
|
|
Deployer: deployer.New(),
|
|
subscribers: make(map[uint][]chan string),
|
|
}
|
|
}
|
|
|
|
// CreateProject creates a new project and initializes its workspace.
|
|
func (s *ProjectService) CreateProject(p *model.Project) error {
|
|
p.Status = "stopped"
|
|
if err := s.Store.CreateProject(p); err != nil {
|
|
return err
|
|
}
|
|
return s.Workspace.InitProject(p.ID)
|
|
}
|
|
|
|
// ListProjects returns all projects.
|
|
func (s *ProjectService) ListProjects() ([]model.Project, error) {
|
|
projects, err := s.Store.ListProjects()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
// Update status from process manager
|
|
for i := range projects {
|
|
status := s.Process.GetStatus(projects[i].ID)
|
|
if status == "running" {
|
|
projects[i].Status = "running"
|
|
} else if projects[i].Status == "running" {
|
|
projects[i].Status = "stopped"
|
|
}
|
|
}
|
|
return projects, nil
|
|
}
|
|
|
|
// GetProject returns a single project.
|
|
func (s *ProjectService) GetProject(id uint) (*model.Project, error) {
|
|
p, err := s.Store.GetProject(id)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
status := s.Process.GetStatus(id)
|
|
if status == "running" {
|
|
p.Status = "running"
|
|
} else if p.Status == "running" {
|
|
p.Status = "stopped"
|
|
}
|
|
return p, nil
|
|
}
|
|
|
|
// UpdateProject updates a project's configuration.
|
|
func (s *ProjectService) UpdateProject(p *model.Project) error {
|
|
return s.Store.UpdateProject(p)
|
|
}
|
|
|
|
// DeleteProject deletes a project, stops its process, and cleans up its workspace.
|
|
func (s *ProjectService) DeleteProject(id uint) error {
|
|
s.Process.Stop(id)
|
|
s.Workspace.CleanProject(id)
|
|
s.Store.DeleteDeployConfig(id) // clean up deploy config if exists
|
|
return s.Store.DeleteProject(id)
|
|
}
|
|
|
|
// BuildProject triggers a build for the project.
|
|
func (s *ProjectService) BuildProject(ctx context.Context, projectID uint) (*model.BuildRecord, error) {
|
|
project, err := s.Store.GetProject(projectID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// Start a build record
|
|
now := time.Now()
|
|
record := &model.BuildRecord{
|
|
ProjectID: projectID,
|
|
Status: "running",
|
|
StartedAt: now,
|
|
}
|
|
if err := s.Store.CreateBuildRecord(record); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// Update project status
|
|
project.Status = "building"
|
|
s.Store.UpdateProject(project)
|
|
|
|
// For git mode, pull/clone first
|
|
var commitHash string
|
|
if project.Mode == "git" && project.GitURL != "" {
|
|
srcDir := s.Workspace.SrcDir(projectID)
|
|
hash, err := s.Git.CloneOrPull(project.GitURL, project.GitBranch, project.GitUsername, project.GitPassword, srcDir)
|
|
if err != nil {
|
|
s.finishBuild(record, project, "failed", err.Error(), "")
|
|
return record, err
|
|
}
|
|
commitHash = hash
|
|
record.CommitHash = hash
|
|
s.Store.UpdateBuildRecord(record)
|
|
}
|
|
|
|
// Execute build
|
|
srcDir := s.Workspace.SrcDir(projectID)
|
|
logsDir := s.Workspace.LogsDir(projectID)
|
|
|
|
// Notify SSE subscribers about the build starting
|
|
s.broadcast(projectID, fmt.Sprintf("[Build #%d] Starting build for project %s...", record.ID, project.Name))
|
|
|
|
result, err := s.Builder.Execute(ctx, project, srcDir, logsDir, commitHash, func(line string) {
|
|
s.broadcast(projectID, line)
|
|
})
|
|
|
|
if err != nil || !result.Success {
|
|
errMsg := "build failed"
|
|
if err != nil {
|
|
errMsg = err.Error()
|
|
}
|
|
if result != nil && result.Output != "" {
|
|
errMsg = result.Output
|
|
}
|
|
s.finishBuild(record, project, "failed", errMsg, result.LogPath)
|
|
if err != nil {
|
|
return record, err
|
|
}
|
|
return record, fmt.Errorf("%s", errMsg)
|
|
}
|
|
|
|
finishTime := time.Now()
|
|
record.Status = "success"
|
|
record.LogPath = result.LogPath
|
|
record.CommitHash = result.CommitHash
|
|
record.FinishedAt = &finishTime
|
|
s.Store.UpdateBuildRecord(record)
|
|
|
|
project.Status = "stopped"
|
|
s.Store.UpdateProject(project)
|
|
|
|
s.broadcast(projectID, fmt.Sprintf("[Build #%d] Build completed successfully", record.ID))
|
|
|
|
// Auto-deploy if configured
|
|
if autoCfg, _ := s.Store.GetDeployConfig(projectID); autoCfg != nil && autoCfg.AutoDeploy {
|
|
s.broadcast(projectID, "[Deploy] Auto-deploy enabled, triggering deployment...")
|
|
go func() {
|
|
depCtx, depCancel := context.WithTimeout(context.Background(), 10*time.Minute)
|
|
defer depCancel()
|
|
s.TriggerDeploy(depCtx, projectID, &record.ID)
|
|
}()
|
|
}
|
|
|
|
return record, nil
|
|
}
|
|
|
|
// StartProject starts the built binary for a project.
|
|
func (s *ProjectService) StartProject(ctx context.Context, projectID uint) error {
|
|
project, err := s.Store.GetProject(projectID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
if s.Process.GetStatus(projectID) == "running" {
|
|
return fmt.Errorf("project %s is already running", project.Name)
|
|
}
|
|
|
|
// Parse env vars from JSON string
|
|
var envVars map[string]string
|
|
if project.EnvVars != "" {
|
|
json.Unmarshal([]byte(project.EnvVars), &envVars)
|
|
}
|
|
if envVars == nil {
|
|
envVars = make(map[string]string)
|
|
}
|
|
|
|
// Determine working directory
|
|
workDir := s.Workspace.SrcDir(projectID)
|
|
|
|
// Determine the command to run
|
|
runCommand := project.RunCommand
|
|
if runCommand == "" {
|
|
if project.OutputBinary != "" {
|
|
runCommand = "./" + project.OutputBinary
|
|
} else if project.Mode == "git" {
|
|
runCommand = "./app"
|
|
} else {
|
|
return fmt.Errorf("no run command configured")
|
|
}
|
|
}
|
|
|
|
// If port is specified, add it as an env var (PORT)
|
|
if project.Port > 0 {
|
|
envVars["PORT"] = fmt.Sprintf("%d", project.Port)
|
|
}
|
|
|
|
logsDir := s.Workspace.LogsDir(projectID)
|
|
logFileName := fmt.Sprintf("%s/run-%d.log", logsDir, time.Now().UnixMilli())
|
|
|
|
// Create log file for run output
|
|
logFile, err := os.Create(logFileName)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to create log file: %w", err)
|
|
}
|
|
|
|
mp, err := s.Process.Start(projectID, workDir, runCommand, envVars, logFile)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
// Subscribe to process output for SSE
|
|
mp.Subscribe(func(line string) {
|
|
s.broadcast(projectID, line)
|
|
})
|
|
|
|
project.Status = "running"
|
|
project.PID = mp.PID
|
|
s.Store.UpdateProject(project)
|
|
|
|
s.broadcast(projectID, fmt.Sprintf("[Process] Started with PID %d: %s", mp.PID, runCommand))
|
|
return nil
|
|
}
|
|
|
|
// StopProject stops a running project process.
|
|
func (s *ProjectService) StopProject(projectID uint) error {
|
|
if err := s.Process.Stop(projectID); err != nil {
|
|
return err
|
|
}
|
|
|
|
project, err := s.Store.GetProject(projectID)
|
|
if err == nil {
|
|
project.Status = "stopped"
|
|
project.PID = 0
|
|
s.Store.UpdateProject(project)
|
|
}
|
|
|
|
s.broadcast(projectID, "[Process] Stopped")
|
|
return nil
|
|
}
|
|
|
|
// RestartProject stops and restarts a project.
|
|
func (s *ProjectService) RestartProject(ctx context.Context, projectID uint) error {
|
|
s.StopProject(projectID)
|
|
// Brief pause to ensure port is freed
|
|
time.Sleep(500 * time.Millisecond)
|
|
return s.StartProject(ctx, projectID)
|
|
}
|
|
|
|
// GetBuildHistory returns the build history for a project.
|
|
func (s *ProjectService) GetBuildHistory(projectID uint, limit int) ([]model.BuildRecord, error) {
|
|
return s.Store.ListBuildRecords(projectID, limit)
|
|
}
|
|
|
|
// GetBuildLog returns the contents of a build log.
|
|
func (s *ProjectService) GetBuildLog(buildID uint) (string, error) {
|
|
record, err := s.Store.GetBuildRecord(buildID)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
return s.Builder.ReadLog(record.LogPath)
|
|
}
|
|
|
|
// --- Deploy Config ---
|
|
|
|
// GetDeployConfig returns the deploy config for a project, or nil.
|
|
func (s *ProjectService) GetDeployConfig(projectID uint) (*model.DeployConfig, error) {
|
|
return s.Store.GetDeployConfig(projectID)
|
|
}
|
|
|
|
// UpdateDeployConfig creates or updates the deploy config for a project.
|
|
func (s *ProjectService) UpdateDeployConfig(cfg *model.DeployConfig) error {
|
|
// Preserve sensitive fields if not sent
|
|
if existing, err := s.Store.GetDeployConfig(cfg.ProjectID); err == nil && existing != nil {
|
|
if cfg.SSHKey == "" {
|
|
cfg.SSHKey = existing.SSHKey
|
|
}
|
|
if cfg.SSHPassword == "" {
|
|
cfg.SSHPassword = existing.SSHPassword
|
|
}
|
|
}
|
|
return s.Store.UpsertDeployConfig(cfg)
|
|
}
|
|
|
|
// DeleteDeployConfig removes the deploy config for a project.
|
|
func (s *ProjectService) DeleteDeployConfig(projectID uint) error {
|
|
return s.Store.DeleteDeployConfig(projectID)
|
|
}
|
|
|
|
// --- Deploy Execution ---
|
|
|
|
// TriggerDeploy starts a deployment for the project.
|
|
func (s *ProjectService) TriggerDeploy(ctx context.Context, projectID uint, buildRecordID *uint) (*model.DeployRecord, error) {
|
|
_, err := s.Store.GetProject(projectID)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("project not found: %w", err)
|
|
}
|
|
|
|
cfg, err := s.Store.GetDeployConfig(projectID)
|
|
if err != nil || cfg == nil {
|
|
return nil, fmt.Errorf("deploy config not found for project %d", projectID)
|
|
}
|
|
|
|
// Create deploy record
|
|
now := time.Now()
|
|
record := &model.DeployRecord{
|
|
ProjectID: projectID,
|
|
BuildRecordID: buildRecordID,
|
|
Status: "running",
|
|
StartedAt: now,
|
|
}
|
|
if err := s.Store.CreateDeployRecord(record); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// Log file path
|
|
logsDir := s.Workspace.LogsDir(projectID)
|
|
logFileName := fmt.Sprintf("deploy-%d.log", time.Now().UnixMilli())
|
|
logPath := filepath.Join(logsDir, logFileName)
|
|
|
|
// Parse file mappings
|
|
var mappings []deployer.FileMapping
|
|
if cfg.FileMappings != "" {
|
|
if err := json.Unmarshal([]byte(cfg.FileMappings), &mappings); err != nil {
|
|
s.finishDeploy(record, "failed", fmt.Sprintf("invalid file mappings: %v", err), logPath)
|
|
return record, err
|
|
}
|
|
}
|
|
|
|
// Resolve local file paths relative to src dir
|
|
srcDir := s.Workspace.SrcDir(projectID)
|
|
for i, m := range mappings {
|
|
if !filepath.IsAbs(m.Local) {
|
|
mappings[i].Local = filepath.Join(srcDir, m.Local)
|
|
}
|
|
}
|
|
|
|
// Build deployer config
|
|
dCfg := &deployer.Config{
|
|
Host: cfg.Host,
|
|
Port: cfg.Port,
|
|
Username: cfg.Username,
|
|
AuthMethod: cfg.AuthMethod,
|
|
SSHKey: cfg.SSHKey,
|
|
Password: cfg.SSHPassword,
|
|
}
|
|
|
|
// Create log file
|
|
logFile, err := os.Create(logPath)
|
|
if err != nil {
|
|
s.finishDeploy(record, "failed", err.Error(), logPath)
|
|
return record, err
|
|
}
|
|
defer logFile.Close()
|
|
|
|
// MultiWriter: write to both log file and SSE broadcast
|
|
writer := io.MultiWriter(logFile, &deployLogWriter{fn: func(line string) {
|
|
s.broadcast(projectID, "[Deploy] "+line)
|
|
}})
|
|
|
|
s.broadcast(projectID, fmt.Sprintf("[Deploy #%d] Starting deployment to %s@%s:%d...",
|
|
record.ID, cfg.Username, cfg.Host, cfg.Port))
|
|
|
|
// Execute deployment
|
|
result, err := s.Deployer.Deploy(ctx, dCfg, cfg.DeployDir, mappings,
|
|
cfg.PreDeployCommand, cfg.PostDeployCommand,
|
|
cfg.HealthCheckURL, cfg.HealthCheckTimeout,
|
|
func(line string) {
|
|
fmt.Fprintln(writer, line)
|
|
})
|
|
|
|
if err != nil || !result.Success {
|
|
errMsg := "deploy failed"
|
|
if err != nil {
|
|
errMsg = err.Error()
|
|
}
|
|
if result != nil && result.Output != "" {
|
|
errMsg = result.Output
|
|
}
|
|
s.finishDeploy(record, "failed", errMsg, logPath)
|
|
if err != nil {
|
|
return record, err
|
|
}
|
|
return record, fmt.Errorf("%s", errMsg)
|
|
}
|
|
|
|
// Success
|
|
finishTime := time.Now()
|
|
record.Status = "success"
|
|
record.LogPath = logPath
|
|
record.FinishedAt = &finishTime
|
|
s.Store.UpdateDeployRecord(record)
|
|
|
|
s.broadcast(projectID, fmt.Sprintf("[Deploy #%d] Deployment completed successfully", record.ID))
|
|
return record, nil
|
|
}
|
|
|
|
// GetDeployHistory returns deploy history for a project.
|
|
func (s *ProjectService) GetDeployHistory(projectID uint, limit int) ([]model.DeployRecord, error) {
|
|
return s.Store.ListDeployRecords(projectID, limit)
|
|
}
|
|
|
|
// GetDeployLog returns the full log for a specific deployment.
|
|
func (s *ProjectService) GetDeployLog(deployID uint) (string, error) {
|
|
record, err := s.Store.GetDeployRecord(deployID)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
data, err := os.ReadFile(record.LogPath)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
return string(data), nil
|
|
}
|
|
|
|
// deployLogWriter implements io.Writer for SSE broadcasting.
|
|
type deployLogWriter struct {
|
|
fn func(string)
|
|
buf []byte
|
|
}
|
|
|
|
func (w *deployLogWriter) Write(p []byte) (n int, err error) {
|
|
if w.fn != nil {
|
|
w.buf = append(w.buf, p...)
|
|
for {
|
|
idx := indexOfByteDeploy(w.buf, '\n')
|
|
if idx < 0 {
|
|
break
|
|
}
|
|
line := string(w.buf[:idx])
|
|
if len(line) > 0 && line[len(line)-1] == '\r' {
|
|
line = line[:len(line)-1]
|
|
}
|
|
w.fn(line)
|
|
w.buf = w.buf[idx+1:]
|
|
}
|
|
}
|
|
return len(p), nil
|
|
}
|
|
|
|
func indexOfByteDeploy(data []byte, b byte) int {
|
|
for i, c := range data {
|
|
if c == b {
|
|
return i
|
|
}
|
|
}
|
|
return -1
|
|
}
|
|
|
|
// SSE
|
|
|
|
// SubscribeLogs registers a channel to receive log lines for a project.
|
|
func (s *ProjectService) SubscribeLogs(projectID uint) chan string {
|
|
ch := make(chan string, 100)
|
|
s.mu.Lock()
|
|
s.subscribers[projectID] = append(s.subscribers[projectID], ch)
|
|
s.mu.Unlock()
|
|
return ch
|
|
}
|
|
|
|
// UnsubscribeLogs removes a subscriber channel for a project.
|
|
func (s *ProjectService) UnsubscribeLogs(projectID uint, ch chan string) {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
subs := s.subscribers[projectID]
|
|
for i, sub := range subs {
|
|
if sub == ch {
|
|
s.subscribers[projectID] = append(subs[:i], subs[i+1:]...)
|
|
close(ch)
|
|
break
|
|
}
|
|
}
|
|
}
|
|
|
|
func (s *ProjectService) broadcast(projectID uint, line string) {
|
|
s.mu.RLock()
|
|
defer s.mu.RUnlock()
|
|
for _, ch := range s.subscribers[projectID] {
|
|
select {
|
|
case ch <- line:
|
|
default:
|
|
// Drop if channel is full
|
|
}
|
|
}
|
|
}
|
|
|
|
// Shutdown gracefully stops all running processes.
|
|
func (s *ProjectService) Shutdown() {
|
|
s.Process.Shutdown()
|
|
}
|
|
|
|
// helper
|
|
|
|
func (s *ProjectService) finishBuild(record *model.BuildRecord, project *model.Project, status, errMsg, logPath string) {
|
|
now := time.Now()
|
|
record.Status = status
|
|
record.FinishedAt = &now
|
|
if logPath != "" {
|
|
record.LogPath = logPath
|
|
}
|
|
s.Store.UpdateBuildRecord(record)
|
|
project.Status = "error"
|
|
s.Store.UpdateProject(project)
|
|
s.broadcast(project.ID, fmt.Sprintf("[Build #%d] %s", record.ID, errMsg))
|
|
}
|
|
|
|
func (s *ProjectService) finishDeploy(record *model.DeployRecord, status, errMsg, logPath string) {
|
|
now := time.Now()
|
|
record.Status = status
|
|
record.FinishedAt = &now
|
|
if logPath != "" {
|
|
record.LogPath = logPath
|
|
}
|
|
s.Store.UpdateDeployRecord(record)
|
|
s.broadcast(record.ProjectID, fmt.Sprintf("[Deploy #%d] %s", record.ID, errMsg))
|
|
}
|