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

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