mirror of
https://github.com/Dvorinka/Containr.git
synced 2026-06-03 20:12:58 +00:00
small fix, don't worry about it
This commit is contained in:
@@ -0,0 +1,943 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"containr/internal/build"
|
||||
"containr/internal/database"
|
||||
"containr/internal/docker"
|
||||
"containr/internal/types"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// BuildHandler handles build-related API endpoints
|
||||
type BuildHandler struct {
|
||||
buildManager *build.BuildManager
|
||||
dockerClient *docker.Client
|
||||
db *database.DB
|
||||
|
||||
mu sync.RWMutex
|
||||
builds map[string]*BuildStatusResponse
|
||||
buildOrder []string
|
||||
cancels map[string]context.CancelFunc
|
||||
idCounter atomic.Uint64
|
||||
}
|
||||
|
||||
var errBuildsTableMissing = errors.New("builds table missing")
|
||||
|
||||
func (h *BuildHandler) buildUnavailable(c *gin.Context) bool {
|
||||
if h.buildManager != nil {
|
||||
return false
|
||||
}
|
||||
|
||||
c.JSON(http.StatusServiceUnavailable, gin.H{
|
||||
"error": "Build service is unavailable: Docker client not initialized",
|
||||
})
|
||||
return true
|
||||
}
|
||||
|
||||
// NewBuildHandler creates a new build handler
|
||||
func NewBuildHandler(buildManager *build.BuildManager, dockerClient *docker.Client, db *database.DB) *BuildHandler {
|
||||
return &BuildHandler{
|
||||
buildManager: buildManager,
|
||||
dockerClient: dockerClient,
|
||||
db: db,
|
||||
builds: make(map[string]*BuildStatusResponse),
|
||||
cancels: make(map[string]context.CancelFunc),
|
||||
}
|
||||
}
|
||||
|
||||
// BuildRequest represents the request body for starting a build
|
||||
type BuildRequest struct {
|
||||
BuildType string `json:"build_type"`
|
||||
SourcePath string `json:"source_path"`
|
||||
PrebuiltImage string `json:"prebuilt_image"`
|
||||
ImageName string `json:"image_name" binding:"required"`
|
||||
ImageTag string `json:"image_tag" binding:"required"`
|
||||
RegistryURL string `json:"registry_url"`
|
||||
BuildCommand string `json:"build_command"`
|
||||
StartCommand string `json:"start_command"`
|
||||
Environment map[string]string `json:"environment"`
|
||||
BuildArgs map[string]string `json:"build_args"`
|
||||
Labels map[string]string `json:"labels"`
|
||||
ProjectID string `json:"project_id"`
|
||||
ServiceID string `json:"service_id"`
|
||||
Branch string `json:"branch"`
|
||||
Commit string `json:"commit"`
|
||||
}
|
||||
|
||||
// BuildResponse represents the response for a build operation
|
||||
type BuildResponse struct {
|
||||
ID string `json:"id"`
|
||||
Status string `json:"status"`
|
||||
ImageName string `json:"image_name"`
|
||||
ImageTag string `json:"image_tag"`
|
||||
Size int64 `json:"size"`
|
||||
Digest string `json:"digest"`
|
||||
BuildTime time.Time `json:"build_time"`
|
||||
Success bool `json:"success"`
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
// BuildStatusResponse represents the response for build status
|
||||
type BuildStatusResponse struct {
|
||||
ID string `json:"id"`
|
||||
ProjectID string `json:"project_id"`
|
||||
ServiceID string `json:"service_id"`
|
||||
Status string `json:"status"`
|
||||
Progress int `json:"progress"`
|
||||
StartedAt time.Time `json:"started_at"`
|
||||
CompletedAt *time.Time `json:"completed_at,omitempty"`
|
||||
ImageName string `json:"image_name"`
|
||||
ImageTag string `json:"image_tag"`
|
||||
Size int64 `json:"size"`
|
||||
Error string `json:"error,omitempty"`
|
||||
Log string `json:"log"`
|
||||
Metadata map[string]string `json:"metadata"`
|
||||
}
|
||||
|
||||
// BuildListResponse represents the response for listing builds
|
||||
type BuildListResponse struct {
|
||||
Builds []BuildStatusResponse `json:"builds"`
|
||||
Total int `json:"total"`
|
||||
Page int `json:"page"`
|
||||
Limit int `json:"limit"`
|
||||
}
|
||||
|
||||
// StartBuild starts a new build
|
||||
// @Summary Start a build
|
||||
// @Description Starts a new build process for the given configuration
|
||||
// @Tags builds
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param request body BuildRequest true "Build request"
|
||||
// @Success 201 {object} BuildResponse
|
||||
// @Failure 400 {object} map[string]string
|
||||
// @Failure 500 {object} map[string]string
|
||||
// @Router /api/v1/builds [post]
|
||||
func (h *BuildHandler) StartBuild(c *gin.Context) {
|
||||
if h.buildUnavailable(c) {
|
||||
return
|
||||
}
|
||||
|
||||
var req BuildRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
// Convert to internal build request
|
||||
buildReq := &types.BuildRequest{
|
||||
BuildType: req.BuildType,
|
||||
SourcePath: req.SourcePath,
|
||||
PrebuiltImage: req.PrebuiltImage,
|
||||
ImageName: req.ImageName,
|
||||
ImageTag: req.ImageTag,
|
||||
RegistryURL: req.RegistryURL,
|
||||
BuildCommand: req.BuildCommand,
|
||||
StartCommand: req.StartCommand,
|
||||
Environment: req.Environment,
|
||||
BuildArgs: req.BuildArgs,
|
||||
Labels: req.Labels,
|
||||
ProjectID: req.ProjectID,
|
||||
ServiceID: req.ServiceID,
|
||||
TriggeredBy: "api",
|
||||
Branch: req.Branch,
|
||||
Commit: req.Commit,
|
||||
}
|
||||
|
||||
// Validate build request
|
||||
if err := h.buildManager.ValidateBuildRequest(c.Request.Context(), buildReq); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
buildID := h.nextBuildID()
|
||||
now := time.Now().UTC()
|
||||
initial := &BuildStatusResponse{
|
||||
ID: buildID,
|
||||
ProjectID: req.ProjectID,
|
||||
ServiceID: req.ServiceID,
|
||||
Status: "pending",
|
||||
Progress: 0,
|
||||
StartedAt: now,
|
||||
ImageName: req.ImageName,
|
||||
ImageTag: req.ImageTag,
|
||||
Log: h.formatLogLine("Build queued"),
|
||||
Metadata: map[string]string{
|
||||
"build_type": req.BuildType,
|
||||
"branch": req.Branch,
|
||||
"commit": req.Commit,
|
||||
},
|
||||
}
|
||||
h.storeBuild(initial)
|
||||
if err := h.upsertBuild(initial); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to persist build"})
|
||||
return
|
||||
}
|
||||
BroadcastBuildUpdate(buildID, cloneBuildStatus(*initial))
|
||||
h.runBuildAsync(buildID, buildReq)
|
||||
|
||||
c.JSON(http.StatusCreated, BuildResponse{
|
||||
ID: buildID,
|
||||
Status: "pending",
|
||||
ImageName: req.ImageName,
|
||||
ImageTag: req.ImageTag,
|
||||
BuildTime: now,
|
||||
Success: true,
|
||||
})
|
||||
}
|
||||
|
||||
// GetBuildStatus gets the status of a build
|
||||
// @Summary Get build status
|
||||
// @Description Gets the current status of a build
|
||||
// @Tags builds
|
||||
// @Produce json
|
||||
// @Param id path string true "Build ID"
|
||||
// @Success 200 {object} BuildStatusResponse
|
||||
// @Failure 404 {object} map[string]string
|
||||
// @Failure 500 {object} map[string]string
|
||||
// @Router /api/v1/builds/{id} [get]
|
||||
func (h *BuildHandler) GetBuildStatus(c *gin.Context) {
|
||||
buildID := c.Param("id")
|
||||
status, found := h.getBuild(buildID)
|
||||
if !found {
|
||||
dbStatus, dbFound, err := h.getBuildFromDB(buildID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to retrieve build"})
|
||||
return
|
||||
}
|
||||
if dbFound {
|
||||
status = dbStatus
|
||||
found = true
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "build not found"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, status)
|
||||
}
|
||||
|
||||
// ListBuilds lists all builds
|
||||
// @Summary List builds
|
||||
// @Description Lists all builds with optional filtering
|
||||
// @Tags builds
|
||||
// @Produce json
|
||||
// @Param project_id query string false "Filter by project ID"
|
||||
// @Param service_id query string false "Filter by service ID"
|
||||
// @Param status query string false "Filter by status"
|
||||
// @Param page query int false "Page number" default(1)
|
||||
// @Param limit query int false "Items per page" default(20)
|
||||
// @Success 200 {object} BuildListResponse
|
||||
// @Failure 500 {object} map[string]string
|
||||
// @Router /api/v1/builds [get]
|
||||
func (h *BuildHandler) ListBuilds(c *gin.Context) {
|
||||
projectID := c.Query("project_id")
|
||||
serviceID := c.Query("service_id")
|
||||
status := c.Query("status")
|
||||
|
||||
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
||||
limit, _ := strconv.Atoi(c.DefaultQuery("limit", "20"))
|
||||
if page < 1 {
|
||||
page = 1
|
||||
}
|
||||
if limit < 1 {
|
||||
limit = 20
|
||||
}
|
||||
if limit > 100 {
|
||||
limit = 100
|
||||
}
|
||||
builds, total := h.listBuilds(projectID, serviceID, status, page, limit)
|
||||
if h.db != nil {
|
||||
dbBuilds, dbTotal, err := h.listBuildsFromDB(projectID, serviceID, status, page, limit)
|
||||
if err != nil {
|
||||
if !errors.Is(err, errBuildsTableMissing) {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list builds"})
|
||||
return
|
||||
}
|
||||
} else {
|
||||
builds = dbBuilds
|
||||
total = dbTotal
|
||||
}
|
||||
}
|
||||
c.JSON(http.StatusOK, BuildListResponse{
|
||||
Builds: builds,
|
||||
Total: total,
|
||||
Page: page,
|
||||
Limit: limit,
|
||||
})
|
||||
}
|
||||
|
||||
// CancelBuild cancels a running build
|
||||
// @Summary Cancel build
|
||||
// @Description Cancels a running build
|
||||
// @Tags builds
|
||||
// @Produce json
|
||||
// @Param id path string true "Build ID"
|
||||
// @Success 200 {object} map[string]string
|
||||
// @Failure 404 {object} map[string]string
|
||||
// @Failure 500 {object} map[string]string
|
||||
// @Router /api/v1/builds/{id}/cancel [post]
|
||||
func (h *BuildHandler) CancelBuild(c *gin.Context) {
|
||||
buildID := c.Param("id")
|
||||
status, found := h.getBuild(buildID)
|
||||
if !found {
|
||||
dbStatus, dbFound, err := h.getBuildFromDB(buildID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to retrieve build"})
|
||||
return
|
||||
}
|
||||
if dbFound {
|
||||
status = dbStatus
|
||||
found = true
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "build not found"})
|
||||
return
|
||||
}
|
||||
if h.isTerminalState(status.Status) {
|
||||
c.JSON(http.StatusConflict, gin.H{"error": "build is already finished"})
|
||||
return
|
||||
}
|
||||
|
||||
cancelled := h.cancelBuild(buildID)
|
||||
if !cancelled {
|
||||
c.JSON(http.StatusConflict, gin.H{"error": "build is not cancellable"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"message": "Build " + buildID + " cancelled",
|
||||
})
|
||||
}
|
||||
|
||||
// GetBuildLogs gets the logs for a build
|
||||
// @Summary Get build logs
|
||||
// @Description Gets the build logs for a specific build
|
||||
// @Tags builds
|
||||
// @Produce text
|
||||
// @Param id path string true "Build ID"
|
||||
// @Param follow query bool false "Follow logs" default(false)
|
||||
// @Success 200 {string} string "Build logs"
|
||||
// @Failure 404 {object} map[string]string
|
||||
// @Failure 500 {object} map[string]string
|
||||
// @Router /api/v1/builds/{id}/logs [get]
|
||||
func (h *BuildHandler) GetBuildLogs(c *gin.Context) {
|
||||
buildID := c.Param("id")
|
||||
follow := c.DefaultQuery("follow", "false") == "true"
|
||||
status, found := h.getBuild(buildID)
|
||||
if !found {
|
||||
dbStatus, dbFound, err := h.getBuildFromDB(buildID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to retrieve build logs"})
|
||||
return
|
||||
}
|
||||
if dbFound {
|
||||
status = dbStatus
|
||||
found = true
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "build not found"})
|
||||
return
|
||||
}
|
||||
logs := status.Log
|
||||
|
||||
if follow {
|
||||
// In production, this would use Server-Sent Events to stream logs
|
||||
c.Header("Content-Type", "text/plain")
|
||||
c.String(http.StatusOK, logs)
|
||||
} else {
|
||||
c.Header("Content-Type", "text/plain")
|
||||
c.String(http.StatusOK, logs)
|
||||
}
|
||||
}
|
||||
|
||||
// GetBuildPlan gets the build plan for a service
|
||||
// @Summary Get build plan
|
||||
// @Description Gets the build plan for a service without actually building
|
||||
// @Tags builds
|
||||
// @Produce json
|
||||
// @Param request body BuildRequest true "Build request"
|
||||
// @Success 200 {object} build.BuildPlan
|
||||
// @Failure 400 {object} map[string]string
|
||||
// @Failure 500 {object} map[string]string
|
||||
// @Router /api/v1/builds/plan [post]
|
||||
func (h *BuildHandler) GetBuildPlan(c *gin.Context) {
|
||||
if h.buildUnavailable(c) {
|
||||
return
|
||||
}
|
||||
|
||||
var req BuildRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
// Convert to internal build request
|
||||
buildReq := &types.BuildRequest{
|
||||
BuildType: req.BuildType,
|
||||
SourcePath: req.SourcePath,
|
||||
PrebuiltImage: req.PrebuiltImage,
|
||||
ImageName: req.ImageName,
|
||||
ImageTag: req.ImageTag,
|
||||
BuildCommand: req.BuildCommand,
|
||||
StartCommand: req.StartCommand,
|
||||
Environment: req.Environment,
|
||||
BuildArgs: req.BuildArgs,
|
||||
Labels: req.Labels,
|
||||
}
|
||||
|
||||
// Get build plan
|
||||
plan, err := h.buildManager.GetBuildPlan(c.Request.Context(), buildReq)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, plan)
|
||||
}
|
||||
|
||||
// DetectBuildType detects the build type for a given repository
|
||||
// @Summary Detect build type
|
||||
// @Description Detects the build type based on repository contents
|
||||
// @Tags builds
|
||||
// @Produce json
|
||||
// @Param source_path query string true "Source path"
|
||||
// @Success 200 {object} map[string]string
|
||||
// @Failure 400 {object} map[string]string
|
||||
// @Failure 500 {object} map[string]string
|
||||
// @Router /api/v1/builds/detect [get]
|
||||
func (h *BuildHandler) DetectBuildType(c *gin.Context) {
|
||||
if h.buildUnavailable(c) {
|
||||
return
|
||||
}
|
||||
|
||||
sourcePath := c.Query("source_path")
|
||||
if sourcePath == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "source_path is required"})
|
||||
return
|
||||
}
|
||||
|
||||
buildType, err := h.buildManager.DetectBuildType(c.Request.Context(), sourcePath)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"build_type": string(buildType),
|
||||
})
|
||||
}
|
||||
|
||||
func (h *BuildHandler) nextBuildID() string {
|
||||
sequence := h.idCounter.Add(1)
|
||||
return fmt.Sprintf("build-%d-%d", time.Now().UTC().UnixNano(), sequence)
|
||||
}
|
||||
|
||||
func (h *BuildHandler) runBuildAsync(buildID string, req *types.BuildRequest) {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
h.mu.Lock()
|
||||
h.cancels[buildID] = cancel
|
||||
h.mu.Unlock()
|
||||
|
||||
go func() {
|
||||
defer h.removeCancel(buildID)
|
||||
|
||||
h.updateBuild(buildID, func(status *BuildStatusResponse) {
|
||||
if status.Status == "cancelled" {
|
||||
return
|
||||
}
|
||||
status.Status = "running"
|
||||
status.Progress = 10
|
||||
status.Log = h.appendLog(status.Log, h.formatLogLine("Build started"))
|
||||
})
|
||||
|
||||
buildCtx, timeoutCancel := context.WithTimeout(ctx, 30*time.Minute)
|
||||
defer timeoutCancel()
|
||||
|
||||
response, err := h.buildManager.Build(buildCtx, req)
|
||||
completedAt := time.Now().UTC()
|
||||
|
||||
h.updateBuild(buildID, func(status *BuildStatusResponse) {
|
||||
// Cancellation is authoritative even if a build eventually returns.
|
||||
if status.Status == "cancelled" {
|
||||
status.CompletedAt = &completedAt
|
||||
status.Progress = 100
|
||||
if err == nil {
|
||||
status.Log = h.appendLog(status.Log, h.formatLogLine("Build completed after cancellation request; result ignored"))
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
status.CompletedAt = &completedAt
|
||||
status.Progress = 100
|
||||
|
||||
if err != nil {
|
||||
if errors.Is(err, context.Canceled) || errors.Is(buildCtx.Err(), context.Canceled) {
|
||||
status.Status = "cancelled"
|
||||
status.Log = h.appendLog(status.Log, h.formatLogLine("Build cancelled"))
|
||||
return
|
||||
}
|
||||
|
||||
status.Status = "failed"
|
||||
status.Error = err.Error()
|
||||
status.Log = h.appendLog(status.Log, h.formatLogLine("Build failed: "+err.Error()))
|
||||
return
|
||||
}
|
||||
|
||||
status.Status = "success"
|
||||
status.ImageName = response.ImageName
|
||||
status.ImageTag = response.ImageTag
|
||||
status.Size = response.Size
|
||||
if response.Error != "" {
|
||||
status.Error = response.Error
|
||||
}
|
||||
if response.BuildLog != "" {
|
||||
status.Log = h.appendLog(status.Log, strings.TrimSpace(response.BuildLog))
|
||||
}
|
||||
status.Log = h.appendLog(status.Log, h.formatLogLine("Build completed successfully"))
|
||||
})
|
||||
}()
|
||||
}
|
||||
|
||||
func (h *BuildHandler) storeBuild(status *BuildStatusResponse) {
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
|
||||
cloned := cloneBuildStatus(*status)
|
||||
h.builds[status.ID] = &cloned
|
||||
h.buildOrder = append(h.buildOrder, status.ID)
|
||||
}
|
||||
|
||||
func (h *BuildHandler) updateBuild(buildID string, update func(status *BuildStatusResponse)) bool {
|
||||
var snapshot BuildStatusResponse
|
||||
|
||||
h.mu.Lock()
|
||||
status, exists := h.builds[buildID]
|
||||
if !exists {
|
||||
h.mu.Unlock()
|
||||
return false
|
||||
}
|
||||
update(status)
|
||||
snapshot = cloneBuildStatus(*status)
|
||||
h.mu.Unlock()
|
||||
|
||||
if err := h.upsertBuild(&snapshot); err != nil {
|
||||
log.Printf("failed to persist build %s: %v", buildID, err)
|
||||
}
|
||||
BroadcastBuildUpdate(buildID, snapshot)
|
||||
return true
|
||||
}
|
||||
|
||||
func (h *BuildHandler) getBuild(buildID string) (BuildStatusResponse, bool) {
|
||||
h.mu.RLock()
|
||||
defer h.mu.RUnlock()
|
||||
|
||||
status, exists := h.builds[buildID]
|
||||
if !exists {
|
||||
return BuildStatusResponse{}, false
|
||||
}
|
||||
cloned := cloneBuildStatus(*status)
|
||||
return cloned, true
|
||||
}
|
||||
|
||||
func (h *BuildHandler) listBuilds(projectID, serviceID, statusFilter string, page, limit int) ([]BuildStatusResponse, int) {
|
||||
h.mu.RLock()
|
||||
defer h.mu.RUnlock()
|
||||
|
||||
filtered := make([]BuildStatusResponse, 0, len(h.buildOrder))
|
||||
for i := len(h.buildOrder) - 1; i >= 0; i-- {
|
||||
id := h.buildOrder[i]
|
||||
status := h.builds[id]
|
||||
if status == nil {
|
||||
continue
|
||||
}
|
||||
if projectID != "" && status.ProjectID != projectID {
|
||||
continue
|
||||
}
|
||||
if serviceID != "" && status.ServiceID != serviceID {
|
||||
continue
|
||||
}
|
||||
if statusFilter != "" && status.Status != statusFilter {
|
||||
continue
|
||||
}
|
||||
filtered = append(filtered, cloneBuildStatus(*status))
|
||||
}
|
||||
|
||||
total := len(filtered)
|
||||
start := (page - 1) * limit
|
||||
if start >= total {
|
||||
return []BuildStatusResponse{}, total
|
||||
}
|
||||
end := start + limit
|
||||
if end > total {
|
||||
end = total
|
||||
}
|
||||
return filtered[start:end], total
|
||||
}
|
||||
|
||||
func (h *BuildHandler) cancelBuild(buildID string) bool {
|
||||
var cancel context.CancelFunc
|
||||
var snapshot BuildStatusResponse
|
||||
|
||||
h.mu.Lock()
|
||||
status, exists := h.builds[buildID]
|
||||
if !exists {
|
||||
h.mu.Unlock()
|
||||
return h.cancelBuildInDB(buildID)
|
||||
}
|
||||
if h.isTerminalState(status.Status) {
|
||||
h.mu.Unlock()
|
||||
return false
|
||||
}
|
||||
cancel = h.cancels[buildID]
|
||||
status.Status = "cancelled"
|
||||
status.Progress = 100
|
||||
now := time.Now().UTC()
|
||||
status.CompletedAt = &now
|
||||
status.Log = h.appendLog(status.Log, h.formatLogLine("Cancellation requested"))
|
||||
snapshot = cloneBuildStatus(*status)
|
||||
h.mu.Unlock()
|
||||
|
||||
if cancel != nil {
|
||||
cancel()
|
||||
}
|
||||
if err := h.upsertBuild(&snapshot); err != nil {
|
||||
log.Printf("failed to persist cancelled build %s: %v", buildID, err)
|
||||
}
|
||||
BroadcastBuildUpdate(buildID, snapshot)
|
||||
return true
|
||||
}
|
||||
|
||||
func (h *BuildHandler) removeCancel(buildID string) {
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
delete(h.cancels, buildID)
|
||||
}
|
||||
|
||||
func (h *BuildHandler) isTerminalState(state string) bool {
|
||||
switch state {
|
||||
case "success", "failed", "cancelled":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func (h *BuildHandler) appendLog(log, message string) string {
|
||||
msg := strings.TrimSpace(message)
|
||||
if msg == "" {
|
||||
return log
|
||||
}
|
||||
if log == "" {
|
||||
return msg + "\n"
|
||||
}
|
||||
return log + msg + "\n"
|
||||
}
|
||||
|
||||
func (h *BuildHandler) formatLogLine(message string) string {
|
||||
return fmt.Sprintf("[%s] %s", time.Now().UTC().Format(time.RFC3339), message)
|
||||
}
|
||||
|
||||
func (h *BuildHandler) upsertBuild(status *BuildStatusResponse) error {
|
||||
if h.db == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
var metadataRaw []byte
|
||||
if status.Metadata != nil {
|
||||
encoded, err := json.Marshal(status.Metadata)
|
||||
if err != nil {
|
||||
return fmt.Errorf("marshal metadata: %w", err)
|
||||
}
|
||||
metadataRaw = encoded
|
||||
}
|
||||
|
||||
now := time.Now().UTC()
|
||||
_, err := h.db.Exec(
|
||||
`INSERT INTO builds
|
||||
(id, project_id, service_id, status, progress, started_at, completed_at, image_name, image_tag, size, error, log, metadata, created_at, updated_at)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13::jsonb, $14, $14)
|
||||
ON CONFLICT (id) DO UPDATE SET
|
||||
project_id = EXCLUDED.project_id,
|
||||
service_id = EXCLUDED.service_id,
|
||||
status = EXCLUDED.status,
|
||||
progress = EXCLUDED.progress,
|
||||
started_at = EXCLUDED.started_at,
|
||||
completed_at = EXCLUDED.completed_at,
|
||||
image_name = EXCLUDED.image_name,
|
||||
image_tag = EXCLUDED.image_tag,
|
||||
size = EXCLUDED.size,
|
||||
error = EXCLUDED.error,
|
||||
log = EXCLUDED.log,
|
||||
metadata = EXCLUDED.metadata,
|
||||
updated_at = EXCLUDED.updated_at`,
|
||||
status.ID,
|
||||
nullIfEmptyString(status.ProjectID),
|
||||
nullIfEmptyString(status.ServiceID),
|
||||
status.Status,
|
||||
status.Progress,
|
||||
status.StartedAt,
|
||||
status.CompletedAt,
|
||||
status.ImageName,
|
||||
status.ImageTag,
|
||||
status.Size,
|
||||
nullIfEmptyString(status.Error),
|
||||
status.Log,
|
||||
jsonOrEmptyObject(metadataRaw),
|
||||
now,
|
||||
)
|
||||
if err != nil && h.isMissingBuildsTable(err) {
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func (h *BuildHandler) getBuildFromDB(buildID string) (BuildStatusResponse, bool, error) {
|
||||
if h.db == nil {
|
||||
return BuildStatusResponse{}, false, nil
|
||||
}
|
||||
|
||||
row := h.db.QueryRow(
|
||||
`SELECT id, project_id, service_id, status, progress, started_at, completed_at, image_name, image_tag, size, error, log, metadata
|
||||
FROM builds
|
||||
WHERE id = $1`,
|
||||
buildID,
|
||||
)
|
||||
|
||||
build, found, err := scanBuildRow(row.Scan)
|
||||
if err != nil && h.isMissingBuildsTable(err) {
|
||||
return BuildStatusResponse{}, false, nil
|
||||
}
|
||||
return build, found, err
|
||||
}
|
||||
|
||||
func (h *BuildHandler) listBuildsFromDB(projectID, serviceID, status string, page, limit int) ([]BuildStatusResponse, int, error) {
|
||||
if h.db == nil {
|
||||
return nil, 0, nil
|
||||
}
|
||||
|
||||
args := []interface{}{}
|
||||
filters := []string{}
|
||||
nextArg := 1
|
||||
if projectID != "" {
|
||||
filters = append(filters, fmt.Sprintf("project_id = $%d", nextArg))
|
||||
args = append(args, projectID)
|
||||
nextArg++
|
||||
}
|
||||
if serviceID != "" {
|
||||
filters = append(filters, fmt.Sprintf("service_id = $%d", nextArg))
|
||||
args = append(args, serviceID)
|
||||
nextArg++
|
||||
}
|
||||
if status != "" {
|
||||
filters = append(filters, fmt.Sprintf("status = $%d", nextArg))
|
||||
args = append(args, status)
|
||||
nextArg++
|
||||
}
|
||||
|
||||
whereClause := ""
|
||||
if len(filters) > 0 {
|
||||
whereClause = " WHERE " + strings.Join(filters, " AND ")
|
||||
}
|
||||
|
||||
var total int
|
||||
countQuery := "SELECT COUNT(*) FROM builds" + whereClause
|
||||
if err := h.db.QueryRow(countQuery, args...).Scan(&total); err != nil {
|
||||
if h.isMissingBuildsTable(err) {
|
||||
return nil, 0, errBuildsTableMissing
|
||||
}
|
||||
return nil, 0, err
|
||||
}
|
||||
|
||||
offset := (page - 1) * limit
|
||||
args = append(args, limit, offset)
|
||||
query := fmt.Sprintf(
|
||||
`SELECT id, project_id, service_id, status, progress, started_at, completed_at, image_name, image_tag, size, error, log, metadata
|
||||
FROM builds%s
|
||||
ORDER BY created_at DESC
|
||||
LIMIT $%d OFFSET $%d`,
|
||||
whereClause,
|
||||
nextArg,
|
||||
nextArg+1,
|
||||
)
|
||||
|
||||
rows, err := h.db.Query(query, args...)
|
||||
if err != nil {
|
||||
if h.isMissingBuildsTable(err) {
|
||||
return nil, 0, errBuildsTableMissing
|
||||
}
|
||||
return nil, 0, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
builds := make([]BuildStatusResponse, 0, limit)
|
||||
for rows.Next() {
|
||||
build, found, err := scanBuildRow(rows.Scan)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
if found {
|
||||
builds = append(builds, build)
|
||||
}
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
|
||||
return builds, total, nil
|
||||
}
|
||||
|
||||
func (h *BuildHandler) cancelBuildInDB(buildID string) bool {
|
||||
if h.db == nil {
|
||||
return false
|
||||
}
|
||||
now := time.Now().UTC()
|
||||
result, err := h.db.Exec(
|
||||
`UPDATE builds
|
||||
SET status = 'cancelled',
|
||||
progress = 100,
|
||||
completed_at = $1,
|
||||
log = COALESCE(log, '') || $2,
|
||||
updated_at = $1
|
||||
WHERE id = $3
|
||||
AND status NOT IN ('success', 'failed', 'cancelled')`,
|
||||
now,
|
||||
h.formatLogLine("Cancellation requested")+"\n",
|
||||
buildID,
|
||||
)
|
||||
if err != nil {
|
||||
if h.isMissingBuildsTable(err) {
|
||||
return false
|
||||
}
|
||||
log.Printf("failed to cancel build %s in database: %v", buildID, err)
|
||||
return false
|
||||
}
|
||||
|
||||
affected, err := result.RowsAffected()
|
||||
if err != nil || affected == 0 {
|
||||
return false
|
||||
}
|
||||
|
||||
dbBuild, found, err := h.getBuildFromDB(buildID)
|
||||
if err == nil && found {
|
||||
BroadcastBuildUpdate(buildID, dbBuild)
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func scanBuildRow(scan func(dest ...interface{}) error) (BuildStatusResponse, bool, error) {
|
||||
var (
|
||||
id string
|
||||
projectID sql.NullString
|
||||
serviceID sql.NullString
|
||||
status string
|
||||
progress int
|
||||
startedAt sql.NullTime
|
||||
completedAt sql.NullTime
|
||||
imageName string
|
||||
imageTag string
|
||||
size int64
|
||||
errText sql.NullString
|
||||
logText string
|
||||
metadataRaw []byte
|
||||
)
|
||||
|
||||
err := scan(
|
||||
&id,
|
||||
&projectID,
|
||||
&serviceID,
|
||||
&status,
|
||||
&progress,
|
||||
&startedAt,
|
||||
&completedAt,
|
||||
&imageName,
|
||||
&imageTag,
|
||||
&size,
|
||||
&errText,
|
||||
&logText,
|
||||
&metadataRaw,
|
||||
)
|
||||
if err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return BuildStatusResponse{}, false, nil
|
||||
}
|
||||
return BuildStatusResponse{}, false, err
|
||||
}
|
||||
|
||||
parsed := BuildStatusResponse{
|
||||
ID: id,
|
||||
ProjectID: projectID.String,
|
||||
ServiceID: serviceID.String,
|
||||
Status: status,
|
||||
Progress: progress,
|
||||
StartedAt: startedAt.Time.UTC(),
|
||||
ImageName: imageName,
|
||||
ImageTag: imageTag,
|
||||
Size: size,
|
||||
Error: errText.String,
|
||||
Log: logText,
|
||||
}
|
||||
if completedAt.Valid {
|
||||
t := completedAt.Time.UTC()
|
||||
parsed.CompletedAt = &t
|
||||
}
|
||||
if !startedAt.Valid {
|
||||
parsed.StartedAt = time.Time{}
|
||||
}
|
||||
if len(metadataRaw) > 0 {
|
||||
metadata := map[string]string{}
|
||||
if err := json.Unmarshal(metadataRaw, &metadata); err == nil {
|
||||
parsed.Metadata = metadata
|
||||
}
|
||||
}
|
||||
return parsed, true, nil
|
||||
}
|
||||
|
||||
func nullIfEmptyString(value string) interface{} {
|
||||
trimmed := strings.TrimSpace(value)
|
||||
if trimmed == "" {
|
||||
return nil
|
||||
}
|
||||
return trimmed
|
||||
}
|
||||
|
||||
func jsonOrEmptyObject(raw []byte) string {
|
||||
if len(raw) == 0 {
|
||||
return "{}"
|
||||
}
|
||||
return string(raw)
|
||||
}
|
||||
|
||||
func (h *BuildHandler) isMissingBuildsTable(err error) bool {
|
||||
if err == nil {
|
||||
return false
|
||||
}
|
||||
return strings.Contains(strings.ToLower(err.Error()), `relation "builds" does not exist`)
|
||||
}
|
||||
|
||||
func cloneBuildStatus(status BuildStatusResponse) BuildStatusResponse {
|
||||
cloned := status
|
||||
if status.Metadata != nil {
|
||||
cloned.Metadata = make(map[string]string, len(status.Metadata))
|
||||
for k, v := range status.Metadata {
|
||||
cloned.Metadata[k] = v
|
||||
}
|
||||
}
|
||||
return cloned
|
||||
}
|
||||
Reference in New Issue
Block a user