Initial commit

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2026-06-18 21:19:47 +08:00
commit 3f7bdf0222
49 changed files with 7818 additions and 0 deletions

178
internal/builder/builder.go Normal file
View File

@@ -0,0 +1,178 @@
package builder
import (
"bufio"
"context"
"fmt"
"io"
"os"
"os/exec"
"path/filepath"
"strings"
"time"
"ci/internal/model"
)
// LineCallback is called for each line of build output.
type LineCallback func(line string)
// BuildResult holds the outcome of a build.
type BuildResult struct {
Success bool
LogPath string
CommitHash string
Output string
}
// Service handles build operations.
type Service struct{}
// New creates a new builder Service.
func New() *Service {
return &Service{}
}
// Execute runs a build for the given project, streaming output via callback.
func (s *Service) Execute(ctx context.Context, project *model.Project, workDir, logsDir, commitHash string, onLine LineCallback) (*BuildResult, error) {
// Create log file
logFileName := fmt.Sprintf("build-%d.log", time.Now().UnixMilli())
logPath := filepath.Join(logsDir, logFileName)
logFile, err := os.Create(logPath)
if err != nil {
return nil, fmt.Errorf("failed to create log file: %w", err)
}
defer logFile.Close()
writer := io.MultiWriter(logFile, &callbackWriter{fn: onLine})
// Resolve the build script
script := strings.TrimSpace(project.BuildScript)
if script == "" {
// Default: try go build
script = "go build -o app ."
}
// Determine shell for the build script
var cmd *exec.Cmd
if isShellScript(script) {
// Multi-line script or complex — write to temp file and execute
scriptPath := filepath.Join(workDir, ".build-script.sh")
if err := os.WriteFile(scriptPath, []byte("#!/bin/sh\nset -e\n"+script), 0755); err != nil {
return nil, fmt.Errorf("failed to write build script: %w", err)
}
cmd = exec.CommandContext(ctx, "sh", scriptPath)
} else {
cmd = exec.CommandContext(ctx, "sh", "-c", script)
}
cmd.Dir = workDir
cmd.Env = os.Environ()
cmd.Stdout = writer
cmd.Stderr = writer
fmt.Fprintf(writer, "=== Build started at %s ===\n", time.Now().Format(time.RFC3339))
fmt.Fprintf(writer, "Working directory: %s\n", workDir)
fmt.Fprintf(writer, "Build script: %s\n", script)
fmt.Fprintf(writer, "Commit: %s\n\n", commitHash)
startTime := time.Now()
err = cmd.Run()
duration := time.Since(startTime)
result := &BuildResult{
LogPath: logPath,
CommitHash: commitHash,
}
if err != nil {
fmt.Fprintf(writer, "\n=== Build FAILED after %s ===\n", duration.Round(time.Second))
fmt.Fprintf(writer, "Error: %v\n", err)
result.Success = false
result.Output = fmt.Sprintf("Build failed: %v", err)
} else {
fmt.Fprintf(writer, "\n=== Build SUCCESS after %s ===\n", duration.Round(time.Second))
result.Success = true
result.Output = fmt.Sprintf("Build succeeded in %s", duration.Round(time.Second))
}
return result, nil
}
// ReadLog reads a build log file and returns its contents.
func (s *Service) ReadLog(logPath string) (string, error) {
data, err := os.ReadFile(logPath)
if err != nil {
return "", err
}
return string(data), nil
}
// ReadLogTail reads the last n lines from a log file.
func (s *Service) ReadLogTail(logPath string, lines int) ([]string, error) {
file, err := os.Open(logPath)
if err != nil {
return nil, err
}
defer file.Close()
var result []string
scanner := bufio.NewScanner(file)
for scanner.Scan() {
result = append(result, scanner.Text())
}
if err := scanner.Err(); err != nil {
return nil, err
}
if len(result) > lines {
result = result[len(result)-lines:]
}
return result, nil
}
// isShellScript checks if the script looks multi-line or complex enough to need a temp file.
func isShellScript(script string) bool {
return strings.Contains(script, "\n") || len(script) > 200
}
// callbackWriter implements io.Writer, calling a function for each line written.
type callbackWriter struct {
fn LineCallback
buf []byte
}
func (w *callbackWriter) Write(p []byte) (n int, err error) {
if w.fn != nil {
w.buf = append(w.buf, p...)
for {
idx := indexOfByte(w.buf, '\n')
if idx < 0 {
break
}
line := string(w.buf[:idx])
if strings.HasSuffix(line, "\r") {
line = line[:len(line)-1]
}
w.fn(line)
w.buf = w.buf[idx+1:]
}
}
return len(p), nil
}
func (w *callbackWriter) flush() {
if w.fn != nil && len(w.buf) > 0 {
w.fn(string(w.buf))
w.buf = nil
}
}
func indexOfByte(data []byte, b byte) int {
for i, c := range data {
if c == b {
return i
}
}
return -1
}

View File

@@ -0,0 +1,305 @@
package deployer
import (
"context"
"fmt"
"io"
"net"
"net/http"
"os"
"strconv"
"time"
"golang.org/x/crypto/ssh"
)
// LineCallback is called for each line of deploy output.
type LineCallback func(line string)
// FileMapping maps a local file path to a remote server path.
type FileMapping struct {
Local string `json:"local"`
Remote string `json:"remote"`
}
// Config holds SSH connection parameters.
type Config struct {
Host string
Port int
Username string
AuthMethod string // "key" or "password"
SSHKey string // private key content (PEM)
Password string
}
// DeployResult holds the outcome of a deployment.
type DeployResult struct {
Success bool
Output string
}
// Service handles SSH deployment operations.
type Service struct{}
// New creates a new deployer Service.
func New() *Service {
return &Service{}
}
// Deploy performs a full deployment: connect, pre-commands, upload files, post-commands, health check.
// Output is streamed via the onLine callback.
func (s *Service) Deploy(ctx context.Context, cfg *Config, deployDir string,
mappings []FileMapping, preCmd, postCmd, healthCheckURL string,
healthCheckTimeout int, onLine LineCallback) (*DeployResult, error) {
log := func(format string, args ...any) {
onLine(fmt.Sprintf(format, args...))
}
log("=== Deploy started at %s ===", time.Now().Format(time.RFC3339))
log("Target: %s@%s:%d", cfg.Username, cfg.Host, cfg.Port)
log("Deploy directory: %s", deployDir)
log("Auth method: %s", cfg.AuthMethod)
// 1. Connect to SSH server
client, err := s.dial(cfg)
if err != nil {
log("ERROR: SSH connection failed: %v", err)
return &DeployResult{Success: false, Output: fmt.Sprintf("SSH connection failed: %v", err)}, err
}
defer client.Close()
log("SSH connection established")
// 2. Ensure remote deploy directory exists
if err := s.runCommand(client, fmt.Sprintf("mkdir -p %s", deployDir), onLine); err != nil {
log("ERROR: Failed to create deploy directory: %v", err)
return &DeployResult{Success: false, Output: fmt.Sprintf("mkdir failed: %v", err)}, err
}
log("Remote directory ensured: %s", deployDir)
// 3. Execute pre-deploy commands
if preCmd != "" {
log("=== Executing pre-deploy command ===")
log("$ %s", preCmd)
if err := s.runCommand(client, preCmd, onLine); err != nil {
log("ERROR: Pre-deploy command failed: %v", err)
return &DeployResult{Success: false, Output: fmt.Sprintf("pre-deploy failed: %v", err)}, err
}
log("Pre-deploy command completed")
}
// 4. Upload files
if len(mappings) > 0 {
log("=== Uploading %d file(s) ===", len(mappings))
}
for _, m := range mappings {
if err := s.uploadFile(client, m.Local, m.Remote, onLine); err != nil {
log("ERROR: Failed to upload %s: %v", m.Local, err)
return &DeployResult{Success: false, Output: fmt.Sprintf("upload %s failed: %v", m.Local, err)}, err
}
}
// 5. Execute post-deploy commands
if postCmd != "" {
log("=== Executing post-deploy command ===")
log("$ %s", postCmd)
if err := s.runCommand(client, postCmd, onLine); err != nil {
log("ERROR: Post-deploy command failed: %v", err)
return &DeployResult{Success: false, Output: fmt.Sprintf("post-deploy failed: %v", err)}, err
}
log("Post-deploy command completed")
}
// 6. Health check
if healthCheckURL != "" {
log("=== Health check ===")
log("Checking %s (timeout: %ds)", healthCheckURL, healthCheckTimeout)
timeout := time.Duration(healthCheckTimeout) * time.Second
if err := s.checkHealth(healthCheckURL, timeout); err != nil {
log("WARNING: Health check failed: %v", err)
// Health check failure is a warning, not a deploy failure
} else {
log("Health check passed")
}
}
log("=== Deploy completed successfully at %s ===", time.Now().Format(time.RFC3339))
return &DeployResult{Success: true, Output: "Deployment succeeded"}, nil
}
// dial establishes an SSH connection.
func (s *Service) dial(cfg *Config) (*ssh.Client, error) {
auth, err := s.makeAuth(cfg)
if err != nil {
return nil, fmt.Errorf("make auth: %w", err)
}
sshCfg := &ssh.ClientConfig{
User: cfg.Username,
Auth: []ssh.AuthMethod{auth},
HostKeyCallback: ssh.InsecureIgnoreHostKey(),
Timeout: 10 * time.Second,
}
addr := net.JoinHostPort(cfg.Host, strconv.Itoa(cfg.Port))
return ssh.Dial("tcp", addr, sshCfg)
}
// makeAuth builds the ssh.AuthMethod from config.
func (s *Service) makeAuth(cfg *Config) (ssh.AuthMethod, error) {
switch cfg.AuthMethod {
case "password":
return ssh.Password(cfg.Password), nil
case "key":
// Try to parse the private key
key, err := ssh.ParsePrivateKey([]byte(cfg.SSHKey))
if err != nil {
// Try parsing with passphrase (empty passphrase)
key, err2 := ssh.ParsePrivateKeyWithPassphrase([]byte(cfg.SSHKey), []byte{})
if err2 != nil {
return nil, fmt.Errorf("parse private key: %w (original: %v)", err2, err)
}
return ssh.PublicKeys(key), nil
}
return ssh.PublicKeys(key), nil
default:
return nil, fmt.Errorf("unsupported auth method: %s", cfg.AuthMethod)
}
}
// runCommand executes a command on the remote host and streams output line by line.
func (s *Service) runCommand(client *ssh.Client, cmd string, onLine LineCallback) error {
session, err := client.NewSession()
if err != nil {
return fmt.Errorf("new session: %w", err)
}
defer session.Close()
// Combine stdout and stderr
stdout, err := session.StdoutPipe()
if err != nil {
return fmt.Errorf("stdout pipe: %w", err)
}
stderr, err := session.StderrPipe()
if err != nil {
return fmt.Errorf("stderr pipe: %w", err)
}
if err := session.Start(cmd); err != nil {
return fmt.Errorf("start command: %w", err)
}
// Stream stdout lines
go streamLines(stdout, onLine)
// Stream stderr lines
go streamLines(stderr, onLine)
err = session.Wait()
if err != nil {
if exitErr, ok := err.(*ssh.ExitError); ok {
return fmt.Errorf("command exited with %d", exitErr.ExitStatus())
}
return fmt.Errorf("command error: %w", err)
}
return nil
}
// uploadFile copies a local file to a remote path via SSH session.
// Uses "cat > remotePath" which is universally available on Linux.
func (s *Service) uploadFile(client *ssh.Client, localPath, remotePath string, onLine LineCallback) error {
// Get file info
info, err := os.Stat(localPath)
if err != nil {
return fmt.Errorf("stat local file: %w", err)
}
fileSize := info.Size()
onLine(fmt.Sprintf("Uploading %s (%d bytes) -> %s", localPath, fileSize, remotePath))
// Open local file
f, err := os.Open(localPath)
if err != nil {
return fmt.Errorf("open local file: %w", err)
}
defer f.Close()
session, err := client.NewSession()
if err != nil {
return fmt.Errorf("new session: %w", err)
}
defer session.Close()
// Pipe file content to cat > remotePath on the server
session.Stdin = f
// Capture stderr for error reporting
stderr, err := session.StderrPipe()
if err != nil {
return fmt.Errorf("stderr pipe: %w", err)
}
// Also create parent directories if needed
cmd := fmt.Sprintf("mkdir -p $(dirname %s) && cat > %s", remotePath, remotePath)
if err := session.Start(cmd); err != nil {
return fmt.Errorf("start upload: %w", err)
}
// Read stderr in background
errBuf := make([]byte, 4096)
n, _ := stderr.Read(errBuf)
err = session.Wait()
if err != nil {
if n > 0 {
return fmt.Errorf("upload failed: %s: %w", string(errBuf[:n]), err)
}
return fmt.Errorf("upload failed: %w", err)
}
onLine(fmt.Sprintf("Uploaded %s successfully", localPath))
return nil
}
// checkHealth performs an HTTP GET to the health check URL.
func (s *Service) checkHealth(url string, timeout time.Duration) error {
client := &http.Client{
Timeout: timeout,
}
resp, err := client.Get(url)
if err != nil {
return fmt.Errorf("health check request failed: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode >= 200 && resp.StatusCode < 400 {
return nil
}
return fmt.Errorf("health check returned status %d", resp.StatusCode)
}
// streamLines reads lines from a reader and calls onLine for each.
func streamLines(r io.Reader, onLine LineCallback) {
buf := make([]byte, 4096)
line := make([]byte, 0)
for {
n, err := r.Read(buf)
if n > 0 {
for _, b := range buf[:n] {
if b == '\n' {
onLine(string(line))
line = line[:0]
} else if b != '\r' {
line = append(line, b)
}
}
}
if err != nil {
if len(line) > 0 {
onLine(string(line))
}
return
}
}
}

100
internal/git/git.go Normal file
View File

@@ -0,0 +1,100 @@
package git
import (
"fmt"
"os"
"path/filepath"
gogit "github.com/go-git/go-git/v5"
"github.com/go-git/go-git/v5/plumbing"
"github.com/go-git/go-git/v5/plumbing/transport"
"github.com/go-git/go-git/v5/plumbing/transport/http"
)
// Service handles Git operations for projects.
type Service struct{}
// New creates a new Git Service.
func New() *Service {
return &Service{}
}
// CloneOrPull clones a repo if it doesn't exist locally, or pulls latest changes.
// Returns the commit hash of HEAD after the operation.
func (s *Service) CloneOrPull(url, branch, username, password, dstDir string) (string, error) {
// Check if repo already exists
repo, err := gogit.PlainOpen(dstDir)
if err == nil {
// Repo exists, pull
return s.pull(repo, branch, username, password)
}
// Clone fresh
return s.clone(url, branch, username, password, dstDir)
}
func (s *Service) clone(url, branch, username, password, dstDir string) (string, error) {
// Ensure parent directory exists
os.MkdirAll(filepath.Dir(dstDir), 0755)
// Remove target if it exists (shouldn't, but be safe)
os.RemoveAll(dstDir)
var auth transport.AuthMethod
if username != "" || password != "" {
auth = &http.BasicAuth{
Username: username,
Password: password,
}
}
refName := plumbing.NewBranchReferenceName(branch)
repo, err := gogit.PlainClone(dstDir, false, &gogit.CloneOptions{
URL: url,
Auth: auth,
ReferenceName: refName,
SingleBranch: true,
Depth: 1,
Progress: nil,
})
if err != nil {
return "", fmt.Errorf("clone failed: %w", err)
}
return getHeadHash(repo)
}
func (s *Service) pull(repo *gogit.Repository, branch, username, password string) (string, error) {
w, err := repo.Worktree()
if err != nil {
return "", fmt.Errorf("worktree: %w", err)
}
var auth transport.AuthMethod
if username != "" || password != "" {
auth = &http.BasicAuth{
Username: username,
Password: password,
}
}
refName := plumbing.NewBranchReferenceName(branch)
err = w.Pull(&gogit.PullOptions{
RemoteName: "origin",
Auth: auth,
ReferenceName: refName,
SingleBranch: true,
})
if err != nil && err != gogit.NoErrAlreadyUpToDate {
return "", fmt.Errorf("pull failed: %w", err)
}
return getHeadHash(repo)
}
func getHeadHash(repo *gogit.Repository) (string, error) {
ref, err := repo.Head()
if err != nil {
return "", err
}
return ref.Hash().String(), nil
}

187
internal/handler/build.go Normal file
View File

@@ -0,0 +1,187 @@
package handler
import (
"fmt"
"io"
"net/http"
"time"
"context"
"ci/internal/model"
"ci/internal/service"
"github.com/gin-gonic/gin"
)
// BuildHandler handles build/run/log endpoints.
type BuildHandler struct {
Svc *service.ProjectService
}
func NewBuildHandler(svc *service.ProjectService) *BuildHandler {
return &BuildHandler{Svc: svc}
}
// TriggerBuild starts a build for the project.
func (h *BuildHandler) TriggerBuild(c *gin.Context) {
id, err := parseID(c)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid project id"})
return
}
// Build in background
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Minute)
defer cancel()
go func() {
h.Svc.BuildProject(ctx, id)
}()
c.JSON(http.StatusAccepted, gin.H{"message": "build started"})
}
// StartProject starts the project's binary.
func (h *BuildHandler) StartProject(c *gin.Context) {
id, err := parseID(c)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid project id"})
return
}
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
if err := h.Svc.StartProject(ctx, id); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"message": "started"})
}
// StopProject stops a running project.
func (h *BuildHandler) StopProject(c *gin.Context) {
id, err := parseID(c)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid project id"})
return
}
if err := h.Svc.StopProject(id); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"message": "stopped"})
}
// RestartProject restarts a project.
func (h *BuildHandler) RestartProject(c *gin.Context) {
id, err := parseID(c)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid project id"})
return
}
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
if err := h.Svc.RestartProject(ctx, id); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"message": "restarted"})
}
// GetBuilds lists build history for a project.
func (h *BuildHandler) GetBuilds(c *gin.Context) {
id, err := parseID(c)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid project id"})
return
}
records, err := h.Svc.GetBuildHistory(id, 50)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
if records == nil {
records = []model.BuildRecord{}
}
c.JSON(http.StatusOK, records)
}
// GetBuildLog returns the full log for a specific build.
func (h *BuildHandler) GetBuildLog(c *gin.Context) {
id, err := parseID(c)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid build id"})
return
}
logContent, err := h.Svc.GetBuildLog(id)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.String(http.StatusOK, logContent)
}
// StreamLogs streams real-time logs via SSE for a project.
func (h *BuildHandler) StreamLogs(c *gin.Context) {
id, err := parseID(c)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid project id"})
return
}
c.Header("Content-Type", "text/event-stream")
c.Header("Cache-Control", "no-cache")
c.Header("Connection", "keep-alive")
c.Header("Access-Control-Allow-Origin", "*")
ch := h.Svc.SubscribeLogs(id)
defer h.Svc.UnsubscribeLogs(id, ch)
clientGone := c.Request.Context().Done()
c.Stream(func(w io.Writer) bool {
select {
case <-clientGone:
return false
case line, ok := <-ch:
if !ok {
return false
}
fmt.Fprintf(w, "data: %s\n\n", line)
return true
}
})
}
// Webhook handles Git webhook triggers (Gitee/GitHub).
func (h *BuildHandler) Webhook(c *gin.Context) {
id, err := parseID(c)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid project id"})
return
}
// Verify project exists
_, err = h.Svc.GetProject(id)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "project not found"})
return
}
// Trigger build
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Minute)
defer cancel()
go func() {
h.Svc.BuildProject(ctx, id)
}()
c.JSON(http.StatusOK, gin.H{"message": "webhook received, build triggered"})
}

151
internal/handler/deploy.go Normal file
View File

@@ -0,0 +1,151 @@
package handler
import (
"context"
"net/http"
"strconv"
"time"
"ci/internal/model"
"ci/internal/service"
"github.com/gin-gonic/gin"
)
// DeployHandler handles deployment endpoints.
type DeployHandler struct {
Svc *service.ProjectService
}
// NewDeployHandler creates a new DeployHandler.
func NewDeployHandler(svc *service.ProjectService) *DeployHandler {
return &DeployHandler{Svc: svc}
}
// GetDeployConfig returns the deploy config for a project.
func (h *DeployHandler) GetDeployConfig(c *gin.Context) {
id, err := parseID(c)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid project id"})
return
}
cfg, err := h.Svc.GetDeployConfig(id)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
if cfg == nil {
c.JSON(http.StatusNotFound, gin.H{"error": "no deploy config"})
return
}
c.JSON(http.StatusOK, cfg)
}
// UpdateDeployConfig creates or updates the deploy config for a project.
func (h *DeployHandler) UpdateDeployConfig(c *gin.Context) {
id, err := parseID(c)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid project id"})
return
}
var cfg model.DeployConfig
if err := c.ShouldBindJSON(&cfg); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
cfg.ProjectID = id
if err := h.Svc.UpdateDeployConfig(&cfg); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
// Re-fetch to return clean state (sensitive fields hidden by json:"-")
result, _ := h.Svc.GetDeployConfig(id)
if result == nil {
c.JSON(http.StatusOK, gin.H{"message": "created"})
return
}
c.JSON(http.StatusOK, result)
}
// DeleteDeployConfig removes the deploy config for a project.
func (h *DeployHandler) DeleteDeployConfig(c *gin.Context) {
id, err := parseID(c)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid project id"})
return
}
if err := h.Svc.DeleteDeployConfig(id); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"message": "deploy config deleted"})
}
// TriggerDeploy starts a deployment for the project.
func (h *DeployHandler) TriggerDeploy(c *gin.Context) {
id, err := parseID(c)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid project id"})
return
}
// Deploy in background
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Minute)
defer cancel()
go func() {
h.Svc.TriggerDeploy(ctx, id, nil)
}()
c.JSON(http.StatusAccepted, gin.H{"message": "deployment started"})
}
// GetDeployRecords lists deploy history for a project.
func (h *DeployHandler) GetDeployRecords(c *gin.Context) {
id, err := parseID(c)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid project id"})
return
}
records, err := h.Svc.GetDeployHistory(id, 50)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
if records == nil {
records = []model.DeployRecord{}
}
c.JSON(http.StatusOK, records)
}
// GetDeployLog returns the full log for a specific deployment.
func (h *DeployHandler) GetDeployLog(c *gin.Context) {
didStr := c.Param("did")
did, err := parseIDStr(didStr)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid deploy id"})
return
}
logContent, err := h.Svc.GetDeployLog(did)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.String(http.StatusOK, logContent)
}
// parseIDStr parses an ID string for non-param-based IDs (e.g., deploy record ID from URL path).
func parseIDStr(s string) (uint, error) {
id, err := strconv.ParseUint(s, 10, 64)
if err != nil {
return 0, err
}
return uint(id), nil
}

129
internal/handler/project.go Normal file
View File

@@ -0,0 +1,129 @@
package handler
import (
"net/http"
"strconv"
"ci/internal/model"
"ci/internal/service"
"github.com/gin-gonic/gin"
)
// ProjectHandler handles project CRUD endpoints.
type ProjectHandler struct {
Svc *service.ProjectService
}
func NewProjectHandler(svc *service.ProjectService) *ProjectHandler {
return &ProjectHandler{Svc: svc}
}
// ListProjects returns all projects.
func (h *ProjectHandler) ListProjects(c *gin.Context) {
projects, err := h.Svc.ListProjects()
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
if projects == nil {
projects = []model.Project{}
}
c.JSON(http.StatusOK, projects)
}
// GetProject returns a single project by ID.
func (h *ProjectHandler) GetProject(c *gin.Context) {
id, err := parseID(c)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid project id"})
return
}
project, err := h.Svc.GetProject(id)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "project not found"})
return
}
c.JSON(http.StatusOK, project)
}
// CreateProject creates a new project.
func (h *ProjectHandler) CreateProject(c *gin.Context) {
var p model.Project
if err := c.ShouldBindJSON(&p); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if p.Name == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "name is required"})
return
}
if p.Mode != "git" && p.Mode != "upload" {
p.Mode = "git" // default
}
if err := h.Svc.CreateProject(&p); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusCreated, p)
}
// UpdateProject updates an existing project.
func (h *ProjectHandler) UpdateProject(c *gin.Context) {
id, err := parseID(c)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid project id"})
return
}
existing, err := h.Svc.GetProject(id)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "project not found"})
return
}
var updates model.Project
if err := c.ShouldBindJSON(&updates); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
// Preserve fields that should not be updated from the request
updates.ID = existing.ID
updates.CreatedAt = existing.CreatedAt
updates.Status = existing.Status
updates.PID = existing.PID
// Preserve password if not sent
if updates.GitPassword == "" {
updates.GitPassword = existing.GitPassword
}
if err := h.Svc.UpdateProject(&updates); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, updates)
}
// DeleteProject deletes a project.
func (h *ProjectHandler) DeleteProject(c *gin.Context) {
id, err := parseID(c)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid project id"})
return
}
if err := h.Svc.DeleteProject(id); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"message": "deleted"})
}
func parseID(c *gin.Context) (uint, error) {
idStr := c.Param("id")
id, err := strconv.ParseUint(idStr, 10, 64)
if err != nil {
return 0, err
}
return uint(id), nil
}

View File

@@ -0,0 +1,76 @@
package handler
import (
"fmt"
"net/http"
"path/filepath"
"strings"
"ci/internal/service"
"github.com/gin-gonic/gin"
)
// UploadHandler handles file upload endpoints.
type UploadHandler struct {
Svc *service.ProjectService
}
func NewUploadHandler(svc *service.ProjectService) *UploadHandler {
return &UploadHandler{Svc: svc}
}
// UploadFiles handles source file upload for a project (zip/tar.gz).
func (h *UploadHandler) UploadFiles(c *gin.Context) {
id, err := parseID(c)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid project id"})
return
}
// Verify project exists and is upload mode
project, err := h.Svc.GetProject(id)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "project not found"})
return
}
file, err := c.FormFile("file")
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "no file provided"})
return
}
// Validate file extension
ext := strings.ToLower(filepath.Ext(file.Filename))
valid := false
if ext == ".zip" {
valid = true
}
if strings.HasSuffix(strings.ToLower(file.Filename), ".tar.gz") || strings.HasSuffix(strings.ToLower(file.Filename), ".tgz") {
valid = true
}
if !valid {
c.JSON(http.StatusBadRequest, gin.H{"error": "only .zip and .tar.gz files are supported"})
return
}
// Save uploaded file to temp location
tmpPath := filepath.Join(h.Svc.Workspace.ProjectDir(project.ID), file.Filename)
if err := c.SaveUploadedFile(file, tmpPath); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("failed to save file: %v", err)})
return
}
// Extract to src directory
if err := h.Svc.Workspace.ExtractArchive(project.ID, tmpPath); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("failed to extract: %v", err)})
return
}
c.JSON(http.StatusOK, gin.H{
"message": "file uploaded and extracted successfully",
"filename": file.Filename,
})
}

88
internal/model/model.go Normal file
View File

@@ -0,0 +1,88 @@
package model
import "time"
// Project represents a managed CI/CD project
type Project struct {
ID uint `gorm:"primaryKey" json:"id"`
Name string `gorm:"uniqueIndex;not null" json:"name"`
Description string `json:"description"`
Mode string `gorm:"default:'git'" json:"mode"` // "git" or "upload"
// Git mode fields
GitURL string `json:"git_url"`
GitBranch string `gorm:"default:'master'" json:"git_branch"`
GitUsername string `json:"git_username"`
GitPassword string `json:"-"` // hidden from JSON responses
// Build config
BuildScript string `json:"build_script"`
OutputBinary string `json:"output_binary"` // relative path to built binary
// Runtime config
RunCommand string `json:"run_command"` // command to run the project
EnvVars string `json:"env_vars"` // JSON key-value env vars
Port int `json:"port"` // target port
// Status
Status string `json:"status"` // "stopped", "running", "building", "error"
PID int `json:"pid"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
// BuildRecord represents a single build execution
type BuildRecord struct {
ID uint `gorm:"primaryKey" json:"id"`
ProjectID uint `gorm:"index;not null" json:"project_id"`
Project *Project `gorm:"foreignKey:ProjectID" json:"project,omitempty"`
Status string `gorm:"default:'running'" json:"status"` // "running", "success", "failed"
LogPath string `json:"log_path"` // path to log file
CommitHash string `json:"commit_hash"` // git commit hash (if git mode)
StartedAt time.Time `json:"started_at"`
FinishedAt *time.Time `json:"finished_at"`
}
// DeployConfig holds SSH deployment configuration for a project.
type DeployConfig struct {
ID uint `gorm:"primaryKey" json:"id"`
ProjectID uint `gorm:"uniqueIndex;not null" json:"project_id"`
// SSH connection
Host string `json:"host"`
Port int `gorm:"default:22" json:"port"`
Username string `json:"username"`
AuthMethod string `gorm:"default:'key'" json:"auth_method"` // "key" or "password"
SSHKey string `json:"-"` // hidden from JSON responses
SSHPassword string `json:"-"` // hidden from JSON responses
// Deployment settings
DeployDir string `json:"deploy_dir"` // remote target directory
FileMappings string `json:"file_mappings"` // JSON: [{"local":"app","remote":"/opt/myapp/app"}]
PreDeployCommand string `json:"pre_deploy_command"`
PostDeployCommand string `json:"post_deploy_command"`
// Health check
HealthCheckURL string `json:"health_check_url"`
HealthCheckTimeout int `gorm:"default:30" json:"health_check_timeout"`
// Auto-deploy after successful build
AutoDeploy bool `gorm:"default:false" json:"auto_deploy"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
// DeployRecord tracks a single deployment execution.
type DeployRecord struct {
ID uint `gorm:"primaryKey" json:"id"`
ProjectID uint `gorm:"index;not null" json:"project_id"`
Project *Project `gorm:"foreignKey:ProjectID" json:"project,omitempty"`
BuildRecordID *uint `json:"build_record_id"` // optional link to the build that triggered this deploy
Status string `gorm:"default:'running'" json:"status"` // "running", "success", "failed"
LogPath string `json:"log_path"`
StartedAt time.Time `json:"started_at"`
FinishedAt *time.Time `json:"finished_at"`
}

279
internal/process/manager.go Normal file
View File

@@ -0,0 +1,279 @@
package process
import (
"bufio"
"fmt"
"io"
"os"
"os/exec"
"sync"
"syscall"
"time"
)
// LineSubscriber receives individual log lines as they arrive.
type LineSubscriber func(line string)
// ManagedProcess wraps a running child process with log streaming.
type ManagedProcess struct {
mu sync.RWMutex
cmd *exec.Cmd
PID int
ProjectID uint
Status string // "running", "stopped"
subscribers []LineSubscriber
logFile *os.File
cancel chan struct{}
}
// Manager manages multiple running processes keyed by project ID.
type Manager struct {
mu sync.RWMutex
procs map[uint]*ManagedProcess // projectID -> process
stopChan chan struct{}
}
// NewManager creates a new process Manager.
func NewManager() *Manager {
return &Manager{
procs: make(map[uint]*ManagedProcess),
stopChan: make(chan struct{}),
}
}
// Start launches a command for the given project and streams stdout/stderr.
func (m *Manager) Start(projectID uint, workDir string, command string, envVars map[string]string, logWriter io.Writer) (*ManagedProcess, error) {
m.mu.Lock()
defer m.mu.Unlock()
// Stop existing process for this project if any
if existing, ok := m.procs[projectID]; ok {
existing.Stop()
}
// Parse command: support simple "cmd arg1 arg2" or shell execution
var cmd *exec.Cmd
if isShellCommand(command) {
cmd = exec.Command("sh", "-c", command)
} else {
parts := splitCommand(command)
cmd = exec.Command(parts[0], parts[1:]...)
}
cmd.Dir = workDir
cmd.Env = os.Environ()
for k, v := range envVars {
cmd.Env = append(cmd.Env, fmt.Sprintf("%s=%s", k, v))
}
// Pipe stdout and stderr
stdout, err := cmd.StdoutPipe()
if err != nil {
return nil, err
}
stderr, err := cmd.StderrPipe()
if err != nil {
return nil, err
}
if err := cmd.Start(); err != nil {
return nil, fmt.Errorf("failed to start process: %w", err)
}
mp := &ManagedProcess{
cmd: cmd,
PID: cmd.Process.Pid,
ProjectID: projectID,
Status: "running",
cancel: make(chan struct{}),
}
// Stream stdout/stderr to subscribers and log writer
go mp.streamOutput(stdout, logWriter)
go mp.streamOutput(stderr, logWriter)
// Monitor process exit
go func() {
err := cmd.Wait()
m.mu.Lock()
defer m.mu.Unlock()
mp.mu.Lock()
mp.Status = "stopped"
if err != nil {
mp.broadcast(fmt.Sprintf("[Process exited with error: %v]", err))
} else {
mp.broadcast("[Process exited successfully]")
}
mp.mu.Unlock()
delete(m.procs, projectID)
}()
m.procs[projectID] = mp
return mp, nil
}
// Stop terminates a running process by project ID.
func (m *Manager) Stop(projectID uint) error {
m.mu.Lock()
defer m.mu.Unlock()
mp, ok := m.procs[projectID]
if !ok {
return fmt.Errorf("no running process for project %d", projectID)
}
return mp.Stop()
}
// Stop terminates the managed process.
func (mp *ManagedProcess) Stop() error {
mp.mu.Lock()
defer mp.mu.Unlock()
if mp.Status == "stopped" {
return nil
}
close(mp.cancel)
// Try graceful shutdown first
if err := mp.cmd.Process.Signal(syscall.SIGTERM); err != nil {
// On Windows, SIGTERM is not supported; use Kill
mp.cmd.Process.Kill()
return nil
}
// Wait with timeout
done := make(chan error, 1)
go func() {
_, err := mp.cmd.Process.Wait()
done <- err
}()
select {
case <-done:
mp.Status = "stopped"
return nil
case <-time.After(10 * time.Second):
mp.cmd.Process.Kill()
mp.Status = "stopped"
return fmt.Errorf("process %d killed after timeout", mp.PID)
}
}
// GetStatus returns the status of a project's process.
func (m *Manager) GetStatus(projectID uint) string {
m.mu.RLock()
defer m.mu.RUnlock()
mp, ok := m.procs[projectID]
if !ok {
return "stopped"
}
mp.mu.RLock()
defer mp.mu.RUnlock()
return mp.Status
}
// GetProcess returns the managed process for a project, or nil.
func (m *Manager) GetProcess(projectID uint) *ManagedProcess {
m.mu.RLock()
defer m.mu.RUnlock()
return m.procs[projectID]
}
// Subscribe adds a log line subscriber to a running process.
func (mp *ManagedProcess) Subscribe(fn LineSubscriber) {
mp.mu.Lock()
defer mp.mu.Unlock()
mp.subscribers = append(mp.subscribers, fn)
}
// UnsubscribeAll removes all subscribers.
func (mp *ManagedProcess) UnsubscribeAll() {
mp.mu.Lock()
defer mp.mu.Unlock()
mp.subscribers = nil
}
// IsRunning returns whether the process is currently running.
func (mp *ManagedProcess) IsRunning() bool {
mp.mu.RLock()
defer mp.mu.RUnlock()
return mp.Status == "running"
}
func (mp *ManagedProcess) broadcast(line string) {
for _, sub := range mp.subscribers {
sub(line)
}
}
func (mp *ManagedProcess) streamOutput(r io.Reader, logWriter io.Writer) {
scanner := bufio.NewScanner(r)
scanner.Buffer(make([]byte, 1024*1024), 1024*1024) // 1MB buffer
for scanner.Scan() {
line := scanner.Text()
mp.mu.RLock()
mp.broadcast(line)
mp.mu.RUnlock()
if logWriter != nil {
fmt.Fprintln(logWriter, line)
}
}
}
// Shutdown stops all managed processes gracefully.
func (m *Manager) Shutdown() {
m.mu.Lock()
defer m.mu.Unlock()
for id, mp := range m.procs {
mp.Stop()
delete(m.procs, id)
}
}
// isShellCommand returns true if the command contains shell metacharacters.
func isShellCommand(cmd string) bool {
for _, c := range cmd {
switch c {
case '|', '&', ';', '$', '>', '<', '`', '*', '?', '[', ']', '(', ')', '{', '}':
return true
}
}
return false
}
// splitCommand splits a simple command string into parts, respecting quotes.
func splitCommand(cmd string) []string {
var parts []string
var current []rune
inQuote := false
quoteChar := rune(0)
for _, r := range cmd {
switch {
case r == '"' || r == '\'':
if inQuote && r == quoteChar {
inQuote = false
} else if !inQuote {
inQuote = true
quoteChar = r
} else {
current = append(current, r)
}
case r == ' ' && !inQuote:
if len(current) > 0 {
parts = append(parts, string(current))
current = nil
}
default:
current = append(current, r)
}
}
if len(current) > 0 {
parts = append(parts, string(current))
}
return parts
}

184
internal/server/server.go Normal file
View 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()
}

549
internal/service/project.go Normal file
View File

@@ -0,0 +1,549 @@
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))
}

164
internal/store/store.go Normal file
View File

@@ -0,0 +1,164 @@
package store
import (
"ci/internal/model"
"os"
"path/filepath"
"github.com/glebarez/sqlite"
"gorm.io/gorm"
)
// Store wraps the database connection and provides data access methods.
type Store struct {
DB *gorm.DB
}
// New creates a new Store, initializes the database, and runs migrations.
func New(dataDir string) (*Store, error) {
if err := os.MkdirAll(dataDir, 0755); err != nil {
return nil, err
}
dbPath := filepath.Join(dataDir, "ci.db")
db, err := gorm.Open(sqlite.Open(dbPath), &gorm.Config{})
if err != nil {
return nil, err
}
if err := db.AutoMigrate(&model.Project{}, &model.BuildRecord{}, &model.DeployConfig{}, &model.DeployRecord{}); err != nil {
return nil, err
}
return &Store{DB: db}, nil
}
// --- Project CRUD ---
func (s *Store) CreateProject(p *model.Project) error {
return s.DB.Create(p).Error
}
func (s *Store) ListProjects() ([]model.Project, error) {
var projects []model.Project
err := s.DB.Order("created_at desc").Find(&projects).Error
return projects, err
}
func (s *Store) GetProject(id uint) (*model.Project, error) {
var p model.Project
err := s.DB.First(&p, id).Error
if err != nil {
return nil, err
}
return &p, nil
}
func (s *Store) UpdateProject(p *model.Project) error {
return s.DB.Save(p).Error
}
func (s *Store) DeleteProject(id uint) error {
return s.DB.Delete(&model.Project{}, id).Error
}
// --- BuildRecord CRUD ---
func (s *Store) CreateBuildRecord(r *model.BuildRecord) error {
return s.DB.Create(r).Error
}
func (s *Store) UpdateBuildRecord(r *model.BuildRecord) error {
return s.DB.Save(r).Error
}
func (s *Store) GetBuildRecord(id uint) (*model.BuildRecord, error) {
var r model.BuildRecord
err := s.DB.Preload("Project").First(&r, id).Error
if err != nil {
return nil, err
}
return &r, nil
}
func (s *Store) ListBuildRecords(projectID uint, limit int) ([]model.BuildRecord, error) {
var records []model.BuildRecord
if limit <= 0 {
limit = 20
}
err := s.DB.Where("project_id = ?", projectID).
Order("started_at desc").
Limit(limit).
Find(&records).Error
return records, err
}
// --- DeployConfig CRUD ---
// GetDeployConfig returns the deploy config for a project, or nil if not configured.
func (s *Store) GetDeployConfig(projectID uint) (*model.DeployConfig, error) {
var cfg model.DeployConfig
err := s.DB.Where("project_id = ?", projectID).First(&cfg).Error
if err != nil {
if err == gorm.ErrRecordNotFound {
return nil, nil // not configured is not an error
}
return nil, err
}
return &cfg, nil
}
// UpsertDeployConfig creates or updates the deploy config for a project.
func (s *Store) UpsertDeployConfig(cfg *model.DeployConfig) error {
// Use FirstOrCreate + Assign to handle upsert
var existing model.DeployConfig
err := s.DB.Where("project_id = ?", cfg.ProjectID).First(&existing).Error
if err == nil {
// Update existing
cfg.ID = existing.ID
cfg.CreatedAt = existing.CreatedAt
return s.DB.Save(cfg).Error
}
// Create new
return s.DB.Create(cfg).Error
}
// DeleteDeployConfig removes the deploy config for a project.
func (s *Store) DeleteDeployConfig(projectID uint) error {
return s.DB.Where("project_id = ?", projectID).Delete(&model.DeployConfig{}).Error
}
// --- DeployRecord CRUD ---
// CreateDeployRecord creates a new deploy record.
func (s *Store) CreateDeployRecord(r *model.DeployRecord) error {
return s.DB.Create(r).Error
}
// UpdateDeployRecord updates an existing deploy record.
func (s *Store) UpdateDeployRecord(r *model.DeployRecord) error {
return s.DB.Save(r).Error
}
// GetDeployRecord returns a single deploy record by ID.
func (s *Store) GetDeployRecord(id uint) (*model.DeployRecord, error) {
var r model.DeployRecord
err := s.DB.Preload("Project").First(&r, id).Error
if err != nil {
return nil, err
}
return &r, nil
}
// ListDeployRecords returns deploy history for a project.
func (s *Store) ListDeployRecords(projectID uint, limit int) ([]model.DeployRecord, error) {
var records []model.DeployRecord
if limit <= 0 {
limit = 20
}
err := s.DB.Where("project_id = ?", projectID).
Order("started_at desc").
Limit(limit).
Find(&records).Error
return records, err
}

View File

@@ -0,0 +1,186 @@
package workspace
import (
"archive/tar"
"archive/zip"
"compress/gzip"
"fmt"
"io"
"os"
"path/filepath"
"strings"
)
// Manager handles workspace directories for projects.
type Manager struct {
BaseDir string
}
// New creates a new workspace Manager.
func New(baseDir string) (*Manager, error) {
if err := os.MkdirAll(baseDir, 0755); err != nil {
return nil, err
}
return &Manager{BaseDir: baseDir}, nil
}
// ProjectDir returns the root workspace directory for a project.
func (m *Manager) ProjectDir(projectID uint) string {
return filepath.Join(m.BaseDir, fmt.Sprintf("project-%d", projectID))
}
// SrcDir returns the source directory for a project.
func (m *Manager) SrcDir(projectID uint) string {
return filepath.Join(m.ProjectDir(projectID), "src")
}
// BuildsDir returns the builds directory for a project.
func (m *Manager) BuildsDir(projectID uint) string {
return filepath.Join(m.ProjectDir(projectID), "builds")
}
// LogsDir returns the logs directory for a project.
func (m *Manager) LogsDir(projectID uint) string {
return filepath.Join(m.ProjectDir(projectID), "logs")
}
// InitProject creates all required directories for a project.
func (m *Manager) InitProject(projectID uint) error {
dirs := []string{
m.SrcDir(projectID),
m.BuildsDir(projectID),
m.LogsDir(projectID),
}
for _, d := range dirs {
if err := os.MkdirAll(d, 0755); err != nil {
return err
}
}
return nil
}
// CleanProject removes the entire project workspace.
func (m *Manager) CleanProject(projectID uint) error {
return os.RemoveAll(m.ProjectDir(projectID))
}
// ExtractArchive detects archive type and extracts to the project src directory.
// Supports .zip, .tar.gz, .tgz.
func (m *Manager) ExtractArchive(projectID uint, filePath string) error {
dest := m.SrcDir(projectID)
// Clean destination first
os.RemoveAll(dest)
os.MkdirAll(dest, 0755)
lower := strings.ToLower(filePath)
switch {
case strings.HasSuffix(lower, ".zip"):
return extractZip(filePath, dest)
case strings.HasSuffix(lower, ".tar.gz"), strings.HasSuffix(lower, ".tgz"):
return extractTarGz(filePath, dest)
default:
return fmt.Errorf("unsupported archive format: %s (use .zip or .tar.gz)", filePath)
}
}
func extractZip(src, dest string) error {
r, err := zip.OpenReader(src)
if err != nil {
return err
}
defer r.Close()
for _, f := range r.File {
// Prevent zip slip
path := filepath.Join(dest, f.Name)
if !strings.HasPrefix(filepath.Clean(path), filepath.Clean(dest)+string(os.PathSeparator)) {
return fmt.Errorf("illegal file path in zip: %s", f.Name)
}
if f.FileInfo().IsDir() {
os.MkdirAll(path, 0755)
continue
}
os.MkdirAll(filepath.Dir(path), 0755)
out, err := os.Create(path)
if err != nil {
return err
}
rc, err := f.Open()
if err != nil {
out.Close()
return err
}
_, err = io.Copy(out, rc)
rc.Close()
out.Close()
if err != nil {
return err
}
}
return nil
}
func extractTarGz(src, dest string) error {
f, err := os.Open(src)
if err != nil {
return err
}
defer f.Close()
gzReader, err := gzip.NewReader(f)
if err != nil {
return err
}
defer gzReader.Close()
tarReader := tar.NewReader(gzReader)
for {
header, err := tarReader.Next()
if err == io.EOF {
break
}
if err != nil {
return err
}
path := filepath.Join(dest, header.Name)
if !strings.HasPrefix(filepath.Clean(path), filepath.Clean(dest)+string(os.PathSeparator)) {
return fmt.Errorf("illegal file path in tar: %s", header.Name)
}
switch header.Typeflag {
case tar.TypeDir:
os.MkdirAll(path, 0755)
case tar.TypeReg:
os.MkdirAll(filepath.Dir(path), 0755)
out, err := os.Create(path)
if err != nil {
return err
}
_, err = io.Copy(out, tarReader)
out.Close()
if err != nil {
return err
}
}
}
return nil
}
// SaveUploadedFile saves an uploaded file to a temp location.
func (m *Manager) SaveUploadedFile(projectID uint, reader io.Reader, filename string) (string, error) {
dir := m.ProjectDir(projectID)
os.MkdirAll(dir, 0755)
path := filepath.Join(dir, filename)
f, err := os.Create(path)
if err != nil {
return "", err
}
defer f.Close()
_, err = io.Copy(f, reader)
return path, err
}