Files
Containr/app/backend/internal/api/build.go
T
Tomáš Dvořák 0dc5857b5f refactor(db): port operations, settings, audit, and misc metadata to sqlc (#84)
* 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]>
2026-10-10 09:27:30 +02:00

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
}