mirror of
https://github.com/Dvorinka/Containr.git
synced 2026-10-28 07:41:27 +00:00
* refactor(db): port operations, settings, audit, activity, and misc service metadata to sqlc Covers the cross-cutting surface: operations board reads, app settings, notification producers, activity/audit inserts, builds upsert/cancel, git clone credential resolution, invites, environments, registry auth, project/service variables, HA resolver, boot sweeps, and admin user ops. Dynamic filtered lists and Better Auth table probes stay raw by design. Authored By: TDvorak <[email protected]> * refactor(db): port apwhy gateway handlers and proxy data plane to sqlc Final tranche — every static call site now uses generated queries. Raw SQL remains only where the statement is assembled at runtime (filtered lists, sparse updates, admin count closure) or probes Better Auth's auth_users table, which lives outside the local schema. Authored By: TDvorak <[email protected]>
934 lines
24 KiB
Go
934 lines
24 KiB
Go
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/database/sqlcdb"
|
|
"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 := sqlcdb.New(h.db.DB).UpsertBuild(context.Background(), sqlcdb.UpsertBuildParams{
|
|
ID: status.ID,
|
|
ProjectID: nsNullIfEmpty(status.ProjectID),
|
|
ServiceID: nsNullIfEmpty(status.ServiceID),
|
|
Status: status.Status,
|
|
Progress: int32(status.Progress),
|
|
StartedAt: status.StartedAt,
|
|
CompletedAt: ntPtr(status.CompletedAt),
|
|
ImageName: status.ImageName,
|
|
ImageTag: status.ImageTag,
|
|
Size: status.Size,
|
|
Error: nsNullIfEmpty(status.Error),
|
|
Log: status.Log,
|
|
Column13: json.RawMessage(jsonOrEmptyObject(metadataRaw)),
|
|
CreatedAt: 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, err := sqlcdb.New(h.db.DB).GetBuild(context.Background(), buildID)
|
|
if err != nil {
|
|
if errors.Is(err, sql.ErrNoRows) || h.isMissingBuildsTable(err) {
|
|
return BuildStatusResponse{}, false, nil
|
|
}
|
|
return BuildStatusResponse{}, false, err
|
|
}
|
|
build := BuildStatusResponse{
|
|
ID: row.ID,
|
|
ProjectID: row.ProjectID.String,
|
|
ServiceID: row.ServiceID.String,
|
|
Status: row.Status,
|
|
Progress: int(row.Progress),
|
|
StartedAt: row.StartedAt.UTC(),
|
|
ImageName: row.ImageName,
|
|
ImageTag: row.ImageTag,
|
|
Size: row.Size,
|
|
Error: row.Error.String,
|
|
Log: row.Log,
|
|
}
|
|
if t := timePtrNT(row.CompletedAt); t != nil {
|
|
u := t.UTC()
|
|
build.CompletedAt = &u
|
|
}
|
|
if len(row.Metadata) > 0 {
|
|
metadata := map[string]string{}
|
|
if err := json.Unmarshal(row.Metadata, &metadata); err == nil {
|
|
build.Metadata = metadata
|
|
}
|
|
}
|
|
return build, true, nil
|
|
}
|
|
|
|
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()
|
|
affected, err := sqlcdb.New(h.db.DB).CancelBuild(context.Background(), sqlcdb.CancelBuildParams{
|
|
CompletedAt: sql.NullTime{Time: now, Valid: true},
|
|
Log: h.formatLogLine("Cancellation requested") + "\n",
|
|
ID: buildID,
|
|
})
|
|
if err != nil {
|
|
if h.isMissingBuildsTable(err) {
|
|
return false
|
|
}
|
|
log.Printf("failed to cancel build %s in database: %v", buildID, err)
|
|
return false
|
|
}
|
|
if 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 nsNullIfEmpty(value string) sql.NullString {
|
|
trimmed := strings.TrimSpace(value)
|
|
return sql.NullString{String: trimmed, Valid: 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
|
|
}
|