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