178
internal/builder/builder.go
Normal file
178
internal/builder/builder.go
Normal 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
|
||||
}
|
||||
305
internal/deployer/deployer.go
Normal file
305
internal/deployer/deployer.go
Normal 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
100
internal/git/git.go
Normal 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
187
internal/handler/build.go
Normal 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
151
internal/handler/deploy.go
Normal 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
129
internal/handler/project.go
Normal 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
|
||||
}
|
||||
76
internal/handler/upload.go
Normal file
76
internal/handler/upload.go
Normal 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
88
internal/model/model.go
Normal 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
279
internal/process/manager.go
Normal 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
184
internal/server/server.go
Normal 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
549
internal/service/project.go
Normal 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
164
internal/store/store.go
Normal 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
|
||||
}
|
||||
186
internal/workspace/workspace.go
Normal file
186
internal/workspace/workspace.go
Normal 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
|
||||
}
|
||||
Reference in New Issue
Block a user