mirror of
https://github.com/Dvorinka/Containr.git
synced 2026-10-28 07:41:27 +00:00
Tranche 4: all 32 raw sites in deployments.go — paginated service list, the accessible-feed joins (owner/member/admin/approved), service+owner load, deployment insert, status transitions (fail/complete/set), the 10-field progress sync, cancel access check, rollback reads/inserts, and the published_port reuse read. The shared serviceForDeployFromRow mapper now feeds both the preview and deployments loaders. Live test covers the full lifecycle: insert, list, access join, feed predicate, queued → deployed sync, failed transition. Generated with [Devin](https://devin.ai) Co-authored-by: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
1052 lines
33 KiB
Go
1052 lines
33 KiB
Go
package api
|
|
|
|
import (
|
|
"containr/internal/database"
|
|
"containr/internal/database/sqlcdb"
|
|
"containr/internal/deployment"
|
|
"containr/internal/deployqueue"
|
|
"containr/internal/docker"
|
|
"containr/internal/source"
|
|
"containr/internal/types"
|
|
"context"
|
|
"database/sql"
|
|
"errors"
|
|
"fmt"
|
|
"log/slog"
|
|
"net/http"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/gin-gonic/gin"
|
|
"github.com/google/uuid"
|
|
)
|
|
|
|
type DeploymentModel struct {
|
|
ID uuid.UUID `json:"id" db:"id"`
|
|
ServiceID uuid.UUID `json:"service_id" db:"service_id"`
|
|
CommitHash *string `json:"commit_hash" db:"commit_hash"`
|
|
Status string `json:"status" db:"status"`
|
|
ImageName string `json:"image_name" db:"image_name"`
|
|
ImageTag string `json:"image_tag" db:"image_tag"`
|
|
BuildLog string `json:"build_log" db:"build_log"`
|
|
RuntimeLog string `json:"runtime_log" db:"runtime_log"`
|
|
Error *string `json:"error" db:"error"`
|
|
StartedAt *time.Time `json:"started_at" db:"started_at"`
|
|
CompletedAt *time.Time `json:"completed_at" db:"completed_at"`
|
|
CreatedAt time.Time `json:"created_at" db:"created_at"`
|
|
UpdatedAt time.Time `json:"updated_at" db:"updated_at"`
|
|
}
|
|
|
|
// deploymentRowShape carries the deployment columns shared by the
|
|
// generated list/detail/rollback row types.
|
|
type deploymentRowShape struct {
|
|
ID uuid.UUID
|
|
ServiceID uuid.UUID
|
|
CommitHash sql.NullString
|
|
Status sql.NullString
|
|
ImageName sql.NullString
|
|
ImageTag sql.NullString
|
|
BuildLog sql.NullString
|
|
RuntimeLog sql.NullString
|
|
Error sql.NullString
|
|
StartedAt sql.NullTime
|
|
CompletedAt sql.NullTime
|
|
CreatedAt sql.NullTime
|
|
UpdatedAt sql.NullTime
|
|
}
|
|
|
|
func strPtrNS(v sql.NullString) *string {
|
|
if !v.Valid {
|
|
return nil
|
|
}
|
|
s := v.String
|
|
return &s
|
|
}
|
|
|
|
func ptrStr(s *string) string {
|
|
if s == nil {
|
|
return ""
|
|
}
|
|
return *s
|
|
}
|
|
|
|
func timePtrNT(v sql.NullTime) *time.Time {
|
|
if !v.Valid {
|
|
return nil
|
|
}
|
|
t := v.Time
|
|
return &t
|
|
}
|
|
|
|
func ntPtr(t *time.Time) sql.NullTime {
|
|
if t == nil {
|
|
return sql.NullTime{}
|
|
}
|
|
return sql.NullTime{Time: *t, Valid: true}
|
|
}
|
|
|
|
func deploymentModelFrom(r deploymentRowShape) DeploymentModel {
|
|
return DeploymentModel{
|
|
ID: r.ID,
|
|
ServiceID: r.ServiceID,
|
|
CommitHash: strPtrNS(r.CommitHash),
|
|
Status: r.Status.String,
|
|
ImageName: r.ImageName.String,
|
|
ImageTag: r.ImageTag.String,
|
|
BuildLog: r.BuildLog.String,
|
|
RuntimeLog: r.RuntimeLog.String,
|
|
Error: strPtrNS(r.Error),
|
|
StartedAt: timePtrNT(r.StartedAt),
|
|
CompletedAt: timePtrNT(r.CompletedAt),
|
|
CreatedAt: r.CreatedAt.Time,
|
|
UpdatedAt: r.UpdatedAt.Time,
|
|
}
|
|
}
|
|
|
|
type CreateDeploymentRequest struct {
|
|
CommitHash string `json:"commit_hash"`
|
|
Branch string `json:"branch"`
|
|
Trigger string `json:"trigger"`
|
|
EnvVars map[string]string `json:"env_vars"`
|
|
NoCache bool `json:"no_cache"`
|
|
}
|
|
|
|
type DeploymentResponse struct {
|
|
ID uuid.UUID `json:"id"`
|
|
ServiceID uuid.UUID `json:"service_id"`
|
|
CommitHash *string `json:"commit_hash"`
|
|
Status string `json:"status"`
|
|
ImageName string `json:"image_name"`
|
|
ImageTag string `json:"image_tag"`
|
|
StartedAt *time.Time `json:"started_at,omitempty"`
|
|
CompletedAt *time.Time `json:"completed_at,omitempty"`
|
|
CreatedAt time.Time `json:"created_at"`
|
|
Error *string `json:"error,omitempty"`
|
|
}
|
|
|
|
func handleGetDeployments(c *gin.Context) {
|
|
db, exists := c.Get("db")
|
|
if !exists {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Database connection not available"})
|
|
return
|
|
}
|
|
|
|
serviceIDStr := c.Param("id")
|
|
serviceID, err := uuid.Parse(serviceIDStr)
|
|
if err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid service ID"})
|
|
return
|
|
}
|
|
|
|
projectID, found := serviceProjectID(db.(*database.DB), serviceID)
|
|
if !found {
|
|
c.JSON(http.StatusNotFound, gin.H{"error": "Service not found"})
|
|
return
|
|
}
|
|
if _, allowed := projectReadAccess(c, db.(*database.DB), projectID); !allowed {
|
|
c.JSON(http.StatusNotFound, gin.H{"error": "Service not found"})
|
|
return
|
|
}
|
|
|
|
offset, limit := pageWindow(c, 50, 100)
|
|
|
|
q := sqlcdb.New(db.(*database.DB).DB)
|
|
total, _ := q.CountDeploymentsForService(c.Request.Context(), serviceID)
|
|
|
|
rows, err := q.ListDeploymentsForService(c.Request.Context(), sqlcdb.ListDeploymentsForServiceParams{
|
|
ServiceID: serviceID, Limit: int32(limit), Offset: int32(offset)})
|
|
if err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to retrieve deployments"})
|
|
return
|
|
}
|
|
|
|
deployments := make([]DeploymentModel, 0, len(rows))
|
|
for _, r := range rows {
|
|
deployments = append(deployments, deploymentModelFrom(deploymentRowShape{
|
|
ID: r.ID, ServiceID: r.ServiceID, CommitHash: r.CommitHash, Status: r.Status,
|
|
ImageName: r.ImageName, ImageTag: r.ImageTag, BuildLog: r.BuildLog,
|
|
RuntimeLog: r.RuntimeLog, Error: r.Error, StartedAt: r.StartedAt,
|
|
CompletedAt: r.CompletedAt, CreatedAt: r.CreatedAt, UpdatedAt: r.UpdatedAt,
|
|
}))
|
|
}
|
|
|
|
c.JSON(http.StatusOK, gin.H{
|
|
"deployments": deployments,
|
|
"pagination": paginationMeta(offset, limit, int(total)),
|
|
})
|
|
}
|
|
|
|
// RecentDeployment is a deployment row joined with its service and project names.
|
|
type RecentDeployment struct {
|
|
ID uuid.UUID `json:"id"`
|
|
ServiceID uuid.UUID `json:"service_id"`
|
|
ServiceName string `json:"service_name"`
|
|
ProjectName string `json:"project_name"`
|
|
Status string `json:"status"`
|
|
ImageName string `json:"image_name"`
|
|
StartedAt *time.Time `json:"started_at"`
|
|
CompletedAt *time.Time `json:"completed_at"`
|
|
CreatedAt time.Time `json:"created_at"`
|
|
}
|
|
|
|
// handleGetRecentDeployments lists the latest deployments across all of the
|
|
// caller's services — feeds the dashboard deploy feed.
|
|
func handleGetRecentDeployments(c *gin.Context) {
|
|
db, exists := c.Get("db")
|
|
if !exists {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Database connection not available"})
|
|
return
|
|
}
|
|
|
|
userID := optionalUserUUID(c)
|
|
isAdmin := contextIsAdmin(c)
|
|
|
|
offset, limit := pageWindow(c, 10, 50)
|
|
|
|
q := sqlcdb.New(db.(*database.DB).DB)
|
|
total, err := q.CountAccessibleDeployments(c.Request.Context(), sqlcdb.CountAccessibleDeploymentsParams{
|
|
OwnerID: userID, Column2: isAdmin})
|
|
if err != nil {
|
|
total = 0
|
|
}
|
|
|
|
rows, err := q.ListRecentAccessibleDeployments(c.Request.Context(), sqlcdb.ListRecentAccessibleDeploymentsParams{
|
|
OwnerID: userID, Column2: isAdmin, Limit: int32(limit), Offset: int32(offset)})
|
|
if err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to retrieve deployments"})
|
|
return
|
|
}
|
|
|
|
deployments := make([]RecentDeployment, 0, len(rows))
|
|
for _, r := range rows {
|
|
deployments = append(deployments, RecentDeployment{
|
|
ID: r.ID, ServiceID: r.ServiceID, ServiceName: r.ServiceName,
|
|
ProjectName: r.ProjectName, Status: r.Status.String, ImageName: r.ImageName,
|
|
StartedAt: timePtrNT(r.StartedAt), CompletedAt: timePtrNT(r.CompletedAt),
|
|
CreatedAt: r.CreatedAt.Time,
|
|
})
|
|
}
|
|
|
|
c.JSON(http.StatusOK, gin.H{
|
|
"deployments": deployments,
|
|
"pagination": paginationMeta(offset, limit, int(total)),
|
|
})
|
|
}
|
|
|
|
func handleCreateDeployment(c *gin.Context) {
|
|
db, exists := c.Get("db")
|
|
if !exists {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Database connection not available"})
|
|
return
|
|
}
|
|
|
|
serviceIDStr := c.Param("id")
|
|
serviceID, err := uuid.Parse(serviceIDStr)
|
|
if err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid service ID"})
|
|
return
|
|
}
|
|
|
|
var req CreateDeploymentRequest
|
|
if err := c.ShouldBindJSON(&req); err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
|
return
|
|
}
|
|
|
|
if req.Trigger == "" {
|
|
req.Trigger = "manual"
|
|
}
|
|
|
|
userID, exists := c.Get("user_id")
|
|
if !exists {
|
|
c.JSON(http.StatusUnauthorized, gin.H{"error": "User not authenticated"})
|
|
return
|
|
}
|
|
|
|
ctx := c.Request.Context()
|
|
q := sqlcdb.New(db.(*database.DB).DB)
|
|
sr, err := q.GetServiceForDeployWithOwner(ctx, serviceID)
|
|
if err != nil {
|
|
c.JSON(http.StatusNotFound, gin.H{"error": "Service not found"})
|
|
return
|
|
}
|
|
service := serviceForDeployFromRow(sqlcdb.GetServiceForDeployRow{
|
|
ID: sr.ID, ProjectID: sr.ProjectID, Name: sr.Name, Type: sr.Type,
|
|
Status: sr.Status, Image: sr.Image, Command: sr.Command,
|
|
Environment: sr.Environment, GitRepo: sr.GitRepo, GitBranch: sr.GitBranch,
|
|
BuildPath: sr.BuildPath, Cpu: sr.Cpu, Memory: sr.Memory,
|
|
Replicas: sr.Replicas, Port: sr.Port, Domain: sr.Domain,
|
|
HealthcheckPath: sr.HealthcheckPath, RestartPolicy: sr.RestartPolicy,
|
|
Builder: sr.Builder, CpuReserve: sr.CpuReserve, MemoryReserve: sr.MemoryReserve,
|
|
StaticBuildCmd: sr.StaticBuildCmd, StaticDir: sr.StaticDir,
|
|
CreatedAt: sr.CreatedAt, UpdatedAt: sr.UpdatedAt,
|
|
})
|
|
projectOwner := sr.OwnerID
|
|
|
|
if projectOwner.String() != userID.(string) {
|
|
c.JSON(http.StatusForbidden, gin.H{"error": "Access denied"})
|
|
return
|
|
}
|
|
|
|
if req.Branch == "" {
|
|
req.Branch = service.GitBranch
|
|
}
|
|
|
|
now := time.Now()
|
|
var commitHash *string
|
|
if trimmed := strings.TrimSpace(req.CommitHash); trimmed != "" {
|
|
commitHash = &trimmed
|
|
}
|
|
|
|
d := DeploymentModel{
|
|
ID: uuid.New(),
|
|
ServiceID: serviceID,
|
|
CommitHash: commitHash,
|
|
Status: "pending",
|
|
ImageName: "",
|
|
ImageTag: "",
|
|
CreatedAt: now,
|
|
UpdatedAt: now,
|
|
}
|
|
|
|
err = q.InsertDeployment(ctx, sqlcdb.InsertDeploymentParams{
|
|
ID: d.ID,
|
|
ServiceID: d.ServiceID,
|
|
Version: fmt.Sprintf("v%d", now.Unix()),
|
|
CommitHash: sql.NullString{String: ptrStr(d.CommitHash), Valid: d.CommitHash != nil},
|
|
Status: sql.NullString{String: d.Status, Valid: true},
|
|
ImageName: sql.NullString{String: d.ImageName, Valid: true},
|
|
ImageTag: sql.NullString{String: d.ImageTag, Valid: true},
|
|
CreatedAt: sql.NullTime{Time: d.CreatedAt, Valid: true},
|
|
UpdatedAt: sql.NullTime{Time: d.UpdatedAt, Valid: true},
|
|
})
|
|
|
|
if err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create deployment"})
|
|
return
|
|
}
|
|
|
|
engine, exists := c.Get("deployment_engine")
|
|
if !exists || engine == nil {
|
|
unavailableErr := "Deployment engine unavailable. Docker may not be configured on this server."
|
|
completedAt := time.Now()
|
|
_ = q.FailDeployment(ctx, sqlcdb.FailDeploymentParams{
|
|
Error: sql.NullString{String: unavailableErr, Valid: true},
|
|
CompletedAt: sql.NullTime{Time: completedAt, Valid: true},
|
|
ID: d.ID,
|
|
})
|
|
d.Status = "failed"
|
|
d.Error = &unavailableErr
|
|
d.CompletedAt = &completedAt
|
|
} else {
|
|
err = q.SetServiceStatus(ctx, sqlcdb.SetServiceStatusParams{
|
|
Status: sql.NullString{String: "building", Valid: true},
|
|
UpdatedAt: sql.NullTime{Time: time.Now(), Valid: true},
|
|
ID: serviceID,
|
|
})
|
|
if err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update service status"})
|
|
return
|
|
}
|
|
|
|
engineInstance := engine.(*deployment.DeploymentEngine)
|
|
pos := getDeployQueue(c).Enqueue(serviceID, deployqueue.Job{
|
|
DeploymentID: d.ID,
|
|
Run: func(jctx context.Context) {
|
|
runDeploymentAndSync(jctx, db.(*database.DB), engineInstance, &d, service, req, userID.(string))
|
|
},
|
|
})
|
|
if pos > 0 {
|
|
d.Status = "queued"
|
|
now := time.Now()
|
|
_ = q.SetDeploymentStatus(ctx, sqlcdb.SetDeploymentStatusParams{
|
|
Status: sql.NullString{String: "queued", Valid: true},
|
|
UpdatedAt: sql.NullTime{Time: now, Valid: true},
|
|
ID: d.ID,
|
|
})
|
|
_ = q.SetServiceStatus(ctx, sqlcdb.SetServiceStatusParams{
|
|
Status: sql.NullString{String: "queued", Valid: true},
|
|
UpdatedAt: sql.NullTime{Time: now, Valid: true},
|
|
ID: serviceID,
|
|
})
|
|
}
|
|
}
|
|
|
|
c.JSON(http.StatusCreated, DeploymentResponse{
|
|
ID: d.ID,
|
|
ServiceID: d.ServiceID,
|
|
CommitHash: d.CommitHash,
|
|
Status: d.Status,
|
|
Error: d.Error,
|
|
CompletedAt: d.CompletedAt,
|
|
CreatedAt: d.CreatedAt,
|
|
})
|
|
}
|
|
|
|
func runDeploymentAndSync(
|
|
parentCtx context.Context,
|
|
db *database.DB,
|
|
engine *deployment.DeploymentEngine,
|
|
dbDeployment *DeploymentModel,
|
|
service Service,
|
|
req CreateDeploymentRequest,
|
|
userID string,
|
|
) {
|
|
runDeploymentAndSyncWithImage(parentCtx, db, engine, dbDeployment, service, req, userID, "")
|
|
}
|
|
|
|
// runDeploymentAndSyncWithImage deploys imageOverride as a prebuilt image
|
|
// (rollback / redeploy of an existing tag) when set; otherwise builds from
|
|
// source or pulls the service image.
|
|
func runDeploymentAndSyncWithImage(
|
|
parentCtx context.Context,
|
|
db *database.DB,
|
|
engine *deployment.DeploymentEngine,
|
|
dbDeployment *DeploymentModel,
|
|
service Service,
|
|
req CreateDeploymentRequest,
|
|
userID string,
|
|
imageOverride string,
|
|
) {
|
|
ctx, cancel := context.WithTimeout(parentCtx, 30*time.Minute)
|
|
defer cancel()
|
|
q := sqlcdb.New(db.DB)
|
|
|
|
// The checkout and any shipped artifacts die with this deployment.
|
|
var artifactIDs []string
|
|
checkoutRoot := filepath.Join(containrWorkDir, "repos", dbDeployment.ID.String())
|
|
defer func() {
|
|
_ = os.RemoveAll(checkoutRoot)
|
|
for _, id := range artifactIDs {
|
|
removeArtifact(id)
|
|
}
|
|
}()
|
|
failDeploy := func(msg string) {
|
|
failedAt := time.Now()
|
|
_ = q.FailDeployment(ctx, sqlcdb.FailDeploymentParams{
|
|
Error: sql.NullString{String: msg, Valid: true},
|
|
CompletedAt: sql.NullTime{Time: failedAt, Valid: true},
|
|
ID: dbDeployment.ID,
|
|
})
|
|
}
|
|
|
|
sourcePath := strings.TrimSpace(service.BuildPath)
|
|
if sourcePath == "" {
|
|
sourcePath = "."
|
|
}
|
|
|
|
// Resolve the runtime env: stored service variables (with ${{svc.KEY}}
|
|
// references expanded) plus any per-deployment overrides.
|
|
env, envErr := resolveServiceEnv(db, service)
|
|
if envErr != nil {
|
|
env = map[string]string{}
|
|
}
|
|
for k, v := range req.EnvVars {
|
|
env[k] = v
|
|
}
|
|
|
|
var command []string
|
|
if cmd := strings.TrimSpace(service.Command); cmd != "" {
|
|
command = strings.Fields(cmd)
|
|
}
|
|
|
|
replicas := service.Replicas
|
|
if replicas < 1 {
|
|
replicas = 1
|
|
}
|
|
|
|
// Best effort: reuse the host port from the last live deployment so public
|
|
// URLs survive redeploys. Column exists post-migration; older DBs get
|
|
// ephemeral ports.
|
|
publishedPort, _ := q.GetServicePublishedPort(ctx, service.ID)
|
|
|
|
publicPort := int32(service.Port)
|
|
if service.Builder == "static" && publicPort == 0 {
|
|
// The static builder's image is nginx — route to its default port
|
|
// unless the user picked another.
|
|
publicPort = 80
|
|
}
|
|
|
|
maintenanceMode, basicAuthUsers := serviceAccess(db, service.ID)
|
|
maintURL := ""
|
|
if maintenanceMode {
|
|
maintURL = maintenanceURL(db)
|
|
}
|
|
|
|
// Node-pinned and spread services dispatch to agent command queues.
|
|
nodeID := serviceNodeID(db, service.ID)
|
|
spread := serviceSpread(db, service.ID)
|
|
remote := nodeID != "" || spread
|
|
|
|
// Materialize the git checkout — local builds consume it directly;
|
|
// remote builds package it into a context artifact the node builds.
|
|
if service.GitRepo != "" && imageOverride == "" {
|
|
var ownerID string
|
|
if o, err := q.GetProjectOwner(ctx, service.ProjectID); err == nil {
|
|
ownerID = o.String()
|
|
}
|
|
branch := req.Branch
|
|
if branch == "" {
|
|
branch = service.GitBranch
|
|
}
|
|
cloneSpec, err := resolveGitCloneSpec(db, ownerID, service.GitRepo, branch, req.CommitHash, service.BuildPath)
|
|
if err != nil {
|
|
failDeploy("resolve git source: " + err.Error())
|
|
return
|
|
}
|
|
src, err := source.Checkout(ctx, checkoutRoot, cloneSpec)
|
|
if err != nil {
|
|
failDeploy("git checkout: " + err.Error())
|
|
return
|
|
}
|
|
sourcePath = src
|
|
}
|
|
|
|
deployReq := &deployment.DeploymentRequest{
|
|
ProjectID: service.ProjectID.String(),
|
|
ServiceID: service.ID.String(),
|
|
Environment: service.Environment,
|
|
Config: deployment.ServiceConfig{
|
|
Name: service.Name,
|
|
Image: service.Image,
|
|
Command: command,
|
|
Environment: env,
|
|
Replicas: replicas,
|
|
PublicPort: publicPort,
|
|
PublishedPort: publishedPort,
|
|
Domain: service.Domain,
|
|
HealthPath: service.HealthCheckPath,
|
|
RestartPolicy: service.RestartPolicy,
|
|
VolumeMounts: loadServiceVolumes(db, service.ID),
|
|
Domains: serviceDomainNames(db, service.ID, service.Domain),
|
|
Resources: deployment.ResourceLimits{
|
|
MemoryBytes: parseMemoryLimit(service.Memory),
|
|
MemoryReservation: parseMemoryLimit(service.MemoryReserve),
|
|
CPUQuota: parseCPULimit(service.CPU),
|
|
CPUShares: parseCPUReserveShares(service.CPUReserve),
|
|
},
|
|
Maintenance: maintenanceMode,
|
|
MaintenanceURL: maintURL,
|
|
BasicAuthUsers: basicAuthUsers,
|
|
NodeID: nodeID,
|
|
Spread: spread,
|
|
},
|
|
Trigger: deployment.TriggerConfig{
|
|
Type: req.Trigger,
|
|
Source: "api",
|
|
User: userID,
|
|
Timestamp: time.Now(),
|
|
},
|
|
}
|
|
|
|
if imageOverride != "" || service.GitRepo == "" {
|
|
// Prebuilt image deploy (rollback, redeploy, or image-sourced
|
|
// service): resolve private-registry credentials by image host.
|
|
image := imageOverride
|
|
if image == "" {
|
|
image = service.Image
|
|
}
|
|
if remote && strings.HasPrefix(image, "containr-") {
|
|
// Locally-built tag — no registry serves it. Ship the image
|
|
// itself as a docker-save artifact; the node loads it.
|
|
rc, err := engine.DockerClient().SaveImage(ctx, image)
|
|
if err != nil {
|
|
failDeploy("export rollback image: " + err.Error())
|
|
return
|
|
}
|
|
artifactID, err := putArtifact(rc)
|
|
_ = rc.Close()
|
|
if err != nil {
|
|
failDeploy("store rollback image artifact: " + err.Error())
|
|
return
|
|
}
|
|
artifactIDs = append(artifactIDs, artifactID)
|
|
deployReq.Config.RemoteLoad = &deployment.RemoteLoadSpec{ArtifactID: artifactID}
|
|
deployReq.BuildConfig = &deployment.BuildConfig{
|
|
BuildType: "remote",
|
|
PrebuiltImage: image,
|
|
}
|
|
} else {
|
|
var ownerID string
|
|
if o, err := q.GetProjectOwner(ctx, service.ProjectID); err == nil {
|
|
ownerID = o.String()
|
|
}
|
|
auth := registryAuthFor(db, ownerID, image)
|
|
deployReq.BuildConfig = &deployment.BuildConfig{
|
|
BuildType: "prebuilt",
|
|
PrebuiltImage: image,
|
|
PullUsername: auth.Username,
|
|
PullPassword: auth.Password,
|
|
}
|
|
}
|
|
} else {
|
|
// "auto" leaves BuildType empty so the manager detects it; the static
|
|
// builder additionally feeds its generated Dockerfile the service's
|
|
// build command and output dir.
|
|
buildType := ""
|
|
if service.Builder != "" && service.Builder != "auto" {
|
|
buildType = service.Builder
|
|
}
|
|
buildArgs := map[string]string{}
|
|
buildCommand := ""
|
|
if buildType == "static" {
|
|
buildCommand = firstNonEmpty(service.StaticBuildCmd, "npm ci && npm run build")
|
|
buildArgs["STATIC_DIR"] = firstNonEmpty(service.StaticDir, "dist")
|
|
}
|
|
if remote {
|
|
// Package the context — the node docker-builds it itself.
|
|
packReq := &types.BuildRequest{
|
|
BuildType: buildType,
|
|
SourcePath: sourcePath,
|
|
BuildCommand: buildCommand,
|
|
BuildArgs: buildArgs,
|
|
Environment: env,
|
|
}
|
|
rc, err := engine.BuildManager().PackageContext(ctx, packReq)
|
|
if err != nil {
|
|
failDeploy("package build context: " + err.Error())
|
|
return
|
|
}
|
|
artifactID, err := putArtifact(rc)
|
|
_ = rc.Close()
|
|
if err != nil {
|
|
failDeploy("store build context artifact: " + err.Error())
|
|
return
|
|
}
|
|
artifactIDs = append(artifactIDs, artifactID)
|
|
deployReq.Config.RemoteBuild = &deployment.RemoteBuildSpec{
|
|
ArtifactID: artifactID,
|
|
BuildArgs: buildArgs,
|
|
NoCache: req.NoCache,
|
|
}
|
|
deployReq.BuildConfig = &deployment.BuildConfig{
|
|
BuildType: "remote",
|
|
Branch: req.Branch,
|
|
Commit: req.CommitHash,
|
|
NoCache: req.NoCache,
|
|
}
|
|
} else {
|
|
deployReq.BuildConfig = &deployment.BuildConfig{
|
|
BuildType: buildType,
|
|
SourcePath: sourcePath,
|
|
BuildCommand: buildCommand,
|
|
BuildArgs: buildArgs,
|
|
Branch: req.Branch,
|
|
Commit: req.CommitHash,
|
|
NoCache: req.NoCache,
|
|
}
|
|
}
|
|
}
|
|
|
|
if msg := capacityCheck(parentCtx, db, engine.DockerClient(), service, replicas); msg != "" {
|
|
failedAt := time.Now()
|
|
_ = q.FailDeployment(ctx, sqlcdb.FailDeploymentParams{
|
|
Error: sql.NullString{String: msg, Valid: true},
|
|
CompletedAt: sql.NullTime{Time: failedAt, Valid: true},
|
|
ID: dbDeployment.ID,
|
|
})
|
|
return
|
|
}
|
|
|
|
engineDeployment, err := engine.Deploy(ctx, deployReq)
|
|
if err != nil {
|
|
failedAt := time.Now()
|
|
failure := "Failed to start deployment engine: " + err.Error()
|
|
_ = q.FailDeployment(ctx, sqlcdb.FailDeploymentParams{
|
|
Error: sql.NullString{String: failure, Valid: true},
|
|
CompletedAt: sql.NullTime{Time: failedAt, Valid: true},
|
|
ID: dbDeployment.ID,
|
|
})
|
|
_ = q.SetServiceStatus(ctx, sqlcdb.SetServiceStatusParams{
|
|
Status: sql.NullString{String: "failed", Valid: true},
|
|
UpdatedAt: sql.NullTime{Time: failedAt, Valid: true},
|
|
ID: service.ID,
|
|
})
|
|
return
|
|
}
|
|
|
|
syncTicker := time.NewTicker(1 * time.Second)
|
|
defer syncTicker.Stop()
|
|
|
|
for {
|
|
select {
|
|
case <-ctx.Done():
|
|
failedAt := time.Now()
|
|
finalStatus, finalErr := "failed", "Deployment timed out before completion"
|
|
if errors.Is(ctx.Err(), context.Canceled) {
|
|
finalStatus, finalErr = "cancelled", "Deployment cancelled"
|
|
}
|
|
_ = q.CompleteDeployment(ctx, sqlcdb.CompleteDeploymentParams{
|
|
Status: sql.NullString{String: finalStatus, Valid: true},
|
|
Error: sql.NullString{String: finalErr, Valid: true},
|
|
CompletedAt: sql.NullTime{Time: failedAt, Valid: true},
|
|
ID: dbDeployment.ID,
|
|
})
|
|
_ = q.SetServiceStatus(ctx, sqlcdb.SetServiceStatusParams{
|
|
Status: sql.NullString{String: finalStatus, Valid: true},
|
|
UpdatedAt: sql.NullTime{Time: failedAt, Valid: true},
|
|
ID: service.ID,
|
|
})
|
|
return
|
|
case <-syncTicker.C:
|
|
current, getErr := engine.GetDeployment(engineDeployment.ID)
|
|
if getErr != nil {
|
|
continue
|
|
}
|
|
|
|
dbStatus := mapEngineStatusToDBStatus(current.Status)
|
|
imageName, imageTag := splitImageReference(current.ImageName, dbDeployment.ImageTag)
|
|
|
|
now := time.Now()
|
|
_ = q.SyncDeploymentProgress(ctx, sqlcdb.SyncDeploymentProgressParams{
|
|
Status: sql.NullString{String: dbStatus, Valid: true},
|
|
ImageName: sql.NullString{String: imageName, Valid: true},
|
|
ImageTag: sql.NullString{String: imageTag, Valid: true},
|
|
BuildLog: sql.NullString{String: current.BuildLog, Valid: true},
|
|
RuntimeLog: sql.NullString{String: current.DeployLog, Valid: true},
|
|
Error: sql.NullString{String: current.Error, Valid: current.Error != ""},
|
|
StartedAt: ntPtr(current.StartedAt),
|
|
CompletedAt: ntPtr(current.CompletedAt),
|
|
UpdatedAt: sql.NullTime{Time: now, Valid: true},
|
|
ID: dbDeployment.ID,
|
|
})
|
|
|
|
switch dbStatus {
|
|
case "deployed":
|
|
_ = q.SetServiceStatus(ctx, sqlcdb.SetServiceStatusParams{
|
|
Status: sql.NullString{String: "running", Valid: true},
|
|
UpdatedAt: sql.NullTime{Time: now, Valid: true},
|
|
ID: service.ID,
|
|
})
|
|
insertUserNotification(db, userID, "deployment", "Deployment succeeded",
|
|
fmt.Sprintf("Service %s is now running.", service.Name), "service", service.ID.String())
|
|
return
|
|
case "failed":
|
|
_ = q.SetServiceStatus(ctx, sqlcdb.SetServiceStatusParams{
|
|
Status: sql.NullString{String: "failed", Valid: true},
|
|
UpdatedAt: sql.NullTime{Time: now, Valid: true},
|
|
ID: service.ID,
|
|
})
|
|
body := fmt.Sprintf("Service %s failed to deploy.", service.Name)
|
|
if current.Error != "" {
|
|
body = fmt.Sprintf("Service %s failed to deploy: %s", service.Name, current.Error)
|
|
}
|
|
insertUserNotification(db, userID, "deployment", "Deployment failed", body, "service", service.ID.String())
|
|
return
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
func mapEngineStatusToDBStatus(status string) string {
|
|
if status == "running" {
|
|
return "deployed"
|
|
}
|
|
return status
|
|
}
|
|
|
|
// getDeployQueue returns the per-service deploy queue. SetupRoutes always
|
|
// sets it; the fallback keeps handler tests that skip SetupRoutes working.
|
|
func getDeployQueue(c *gin.Context) *deployqueue.Queue {
|
|
if v, exists := c.Get("deploy_queue"); exists {
|
|
if q, ok := v.(*deployqueue.Queue); ok && q != nil {
|
|
return q
|
|
}
|
|
}
|
|
return deployqueue.New()
|
|
}
|
|
|
|
// handleCancelDeployment cancels a queued or in-flight deployment. Queued
|
|
// jobs are dropped and marked cancelled immediately; active jobs get their
|
|
// context cancelled so the build/reconcile aborts and the sync loop writes
|
|
// the terminal 'cancelled' status.
|
|
func handleCancelDeployment(c *gin.Context) {
|
|
db, exists := c.Get("db")
|
|
if !exists {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Database connection not available", "code": "DEPENDENCY_UNAVAILABLE"})
|
|
return
|
|
}
|
|
|
|
deploymentID, err := uuid.Parse(c.Param("id"))
|
|
if err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid deployment ID", "code": "VALIDATION"})
|
|
return
|
|
}
|
|
userID, exists := c.Get("user_id")
|
|
if !exists {
|
|
c.JSON(http.StatusUnauthorized, gin.H{"error": "User not authenticated", "code": "UNAUTHENTICATED"})
|
|
return
|
|
}
|
|
|
|
ctx := c.Request.Context()
|
|
q := sqlcdb.New(db.(*database.DB).DB)
|
|
access, err := q.GetDeploymentAccess(ctx, deploymentID)
|
|
if err != nil {
|
|
c.JSON(http.StatusNotFound, gin.H{"error": "Deployment not found", "code": "NOT_FOUND"})
|
|
return
|
|
}
|
|
serviceID := access.ServiceID
|
|
status := access.Status.String
|
|
if access.OwnerID.String() != userID.(string) && !contextIsAdmin(c) {
|
|
c.JSON(http.StatusForbidden, gin.H{"error": "Access denied", "code": "FORBIDDEN"})
|
|
return
|
|
}
|
|
|
|
switch status {
|
|
case "deployed", "failed", "cancelled":
|
|
c.JSON(http.StatusConflict, gin.H{"error": "Deployment already finished", "code": "NOT_CANCELLABLE"})
|
|
return
|
|
}
|
|
|
|
switch getDeployQueue(c).Cancel(serviceID, deploymentID) {
|
|
case deployqueue.WasQueued:
|
|
now := time.Now()
|
|
_ = q.CompleteDeployment(ctx, sqlcdb.CompleteDeploymentParams{
|
|
Status: sql.NullString{String: "cancelled", Valid: true},
|
|
Error: sql.NullString{String: "Deployment cancelled", Valid: true},
|
|
CompletedAt: sql.NullTime{Time: now, Valid: true},
|
|
ID: deploymentID,
|
|
})
|
|
_ = q.SetServiceStatus(ctx, sqlcdb.SetServiceStatusParams{
|
|
Status: sql.NullString{String: "cancelled", Valid: true},
|
|
UpdatedAt: sql.NullTime{Time: now, Valid: true},
|
|
ID: serviceID,
|
|
})
|
|
c.JSON(http.StatusOK, gin.H{"status": "cancelled"})
|
|
case deployqueue.WasActive:
|
|
c.JSON(http.StatusAccepted, gin.H{"status": "cancelling"})
|
|
default:
|
|
c.JSON(http.StatusConflict, gin.H{"error": "Deployment is not running on this node", "code": "NOT_CANCELLABLE"})
|
|
}
|
|
}
|
|
|
|
func splitImageReference(image, fallbackTag string) (string, string) {
|
|
if image == "" {
|
|
return "", fallbackTag
|
|
}
|
|
|
|
// Digest-pinned refs (name[:tag]@sha256:…) carry no usable tag — the
|
|
// colon lives inside the digest and must not be split.
|
|
if strings.Contains(image, "@") {
|
|
return image, fallbackTag
|
|
}
|
|
|
|
lastSlash := strings.LastIndex(image, "/")
|
|
lastColon := strings.LastIndex(image, ":")
|
|
if lastColon > lastSlash {
|
|
return image[:lastColon], image[lastColon+1:]
|
|
}
|
|
|
|
return image, fallbackTag
|
|
}
|
|
|
|
func handleGetDeployment(c *gin.Context) {
|
|
db, exists := c.Get("db")
|
|
if !exists {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Database connection not available"})
|
|
return
|
|
}
|
|
|
|
deploymentIDStr := c.Param("id")
|
|
deploymentID, err := uuid.Parse(deploymentIDStr)
|
|
if err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid deployment ID"})
|
|
return
|
|
}
|
|
|
|
r, err := sqlcdb.New(db.(*database.DB).DB).GetDeploymentWithProject(c.Request.Context(), deploymentID)
|
|
if err != nil {
|
|
c.JSON(http.StatusNotFound, gin.H{"error": "Deployment not found"})
|
|
return
|
|
}
|
|
projectID := r.ProjectID
|
|
d := deploymentModelFrom(deploymentRowShape{
|
|
ID: r.ID, ServiceID: r.ServiceID, CommitHash: r.CommitHash, Status: r.Status,
|
|
ImageName: r.ImageName, ImageTag: r.ImageTag, BuildLog: r.BuildLog,
|
|
RuntimeLog: r.RuntimeLog, Error: r.Error, StartedAt: r.StartedAt,
|
|
CompletedAt: r.CompletedAt, CreatedAt: r.CreatedAt, UpdatedAt: r.UpdatedAt,
|
|
})
|
|
|
|
if _, allowed := projectReadAccess(c, db.(*database.DB), projectID); !allowed {
|
|
c.JSON(http.StatusNotFound, gin.H{"error": "Deployment not found"})
|
|
return
|
|
}
|
|
|
|
c.JSON(http.StatusOK, gin.H{"deployment": d})
|
|
}
|
|
|
|
func handleRollbackDeployment(c *gin.Context) {
|
|
db, exists := c.Get("db")
|
|
if !exists {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Database connection not available"})
|
|
return
|
|
}
|
|
|
|
deploymentIDStr := c.Param("id")
|
|
deploymentID, err := uuid.Parse(deploymentIDStr)
|
|
if err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid deployment ID"})
|
|
return
|
|
}
|
|
|
|
userID, exists := c.Get("user_id")
|
|
if !exists {
|
|
c.JSON(http.StatusUnauthorized, gin.H{"error": "User not authenticated"})
|
|
return
|
|
}
|
|
|
|
ctx := c.Request.Context()
|
|
q := sqlcdb.New(db.(*database.DB).DB)
|
|
r, err := q.GetDeploymentForRollback(ctx, deploymentID)
|
|
if err != nil {
|
|
c.JSON(http.StatusNotFound, gin.H{"error": "Deployment not found"})
|
|
return
|
|
}
|
|
serviceID := r.ServiceID
|
|
targetDeployment := deploymentModelFrom(deploymentRowShape{
|
|
ID: r.ID, ServiceID: r.ServiceID, CommitHash: r.CommitHash, Status: r.Status,
|
|
ImageName: r.ImageName, ImageTag: r.ImageTag, BuildLog: r.BuildLog,
|
|
RuntimeLog: r.RuntimeLog, Error: r.Error, StartedAt: r.StartedAt,
|
|
CompletedAt: r.CompletedAt, CreatedAt: r.CreatedAt, UpdatedAt: r.UpdatedAt,
|
|
})
|
|
ownerCheck := r.OwnerID
|
|
|
|
if ownerCheck.String() != userID.(string) && !contextIsAdmin(c) {
|
|
c.JSON(http.StatusForbidden, gin.H{"error": "Access denied"})
|
|
return
|
|
}
|
|
|
|
if targetDeployment.Status != "deployed" && targetDeployment.Status != "failed" {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Can only rollback completed or failed deployments"})
|
|
return
|
|
}
|
|
|
|
now := time.Now()
|
|
rollbackID := uuid.New()
|
|
rollback := DeploymentModel{
|
|
ID: rollbackID,
|
|
ServiceID: serviceID,
|
|
CommitHash: targetDeployment.CommitHash,
|
|
Status: "rolling_back",
|
|
ImageName: targetDeployment.ImageName,
|
|
ImageTag: targetDeployment.ImageTag,
|
|
CreatedAt: now,
|
|
UpdatedAt: now,
|
|
}
|
|
|
|
err = q.InsertDeployment(ctx, sqlcdb.InsertDeploymentParams{
|
|
ID: rollback.ID,
|
|
ServiceID: rollback.ServiceID,
|
|
Version: fmt.Sprintf("rollback-%d", now.Unix()),
|
|
CommitHash: sql.NullString{String: ptrStr(rollback.CommitHash), Valid: rollback.CommitHash != nil},
|
|
Status: sql.NullString{String: rollback.Status, Valid: true},
|
|
ImageName: sql.NullString{String: rollback.ImageName, Valid: true},
|
|
ImageTag: sql.NullString{String: rollback.ImageTag, Valid: true},
|
|
CreatedAt: sql.NullTime{Time: rollback.CreatedAt, Valid: true},
|
|
UpdatedAt: sql.NullTime{Time: rollback.UpdatedAt, Valid: true},
|
|
})
|
|
|
|
if err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create rollback deployment"})
|
|
return
|
|
}
|
|
|
|
err = q.SetServiceStatus(ctx, sqlcdb.SetServiceStatusParams{
|
|
Status: sql.NullString{String: "building", Valid: true},
|
|
UpdatedAt: sql.NullTime{Time: time.Now(), Valid: true},
|
|
ID: serviceID,
|
|
})
|
|
|
|
// Real rollback: redeploy the target deployment's image.
|
|
engineValue, _ := c.Get("deployment_engine")
|
|
engine, _ := engineValue.(*deployment.DeploymentEngine)
|
|
targetImage := targetDeployment.ImageName
|
|
if targetDeployment.ImageTag != "" {
|
|
targetImage += ":" + targetDeployment.ImageTag
|
|
}
|
|
|
|
if engine == nil || targetImage == "" {
|
|
completedAt := time.Now()
|
|
reason := "Rollback image unavailable"
|
|
if engine == nil {
|
|
reason = "Deployment engine unavailable. Docker may not be configured on this server."
|
|
}
|
|
_ = q.FailDeployment(ctx, sqlcdb.FailDeploymentParams{
|
|
Error: sql.NullString{String: reason, Valid: true},
|
|
CompletedAt: sql.NullTime{Time: completedAt, Valid: true},
|
|
ID: rollbackID,
|
|
})
|
|
_ = q.SetServiceStatus(ctx, sqlcdb.SetServiceStatusParams{
|
|
Status: sql.NullString{String: "failed", Valid: true},
|
|
UpdatedAt: sql.NullTime{Time: completedAt, Valid: true},
|
|
ID: serviceID,
|
|
})
|
|
} else {
|
|
service, _ := loadServiceForDeploy(db.(*database.DB), serviceID)
|
|
|
|
rollbackReq := CreateDeploymentRequest{
|
|
CommitHash: func() string {
|
|
if targetDeployment.CommitHash != nil {
|
|
return *targetDeployment.CommitHash
|
|
}
|
|
return ""
|
|
}(),
|
|
Trigger: "rollback",
|
|
}
|
|
// Reuse the stored image directly — no rebuild.
|
|
getDeployQueue(c).Enqueue(serviceID, deployqueue.Job{
|
|
DeploymentID: rollback.ID,
|
|
Run: func(jctx context.Context) {
|
|
runDeploymentAndSyncWithImage(jctx, db.(*database.DB), engine, &rollback, service, rollbackReq, userID.(string), targetImage)
|
|
},
|
|
})
|
|
}
|
|
|
|
c.JSON(http.StatusCreated, gin.H{
|
|
"deployment": DeploymentResponse{
|
|
ID: rollback.ID,
|
|
ServiceID: rollback.ServiceID,
|
|
CommitHash: rollback.CommitHash,
|
|
Status: rollback.Status,
|
|
ImageName: rollback.ImageName,
|
|
ImageTag: rollback.ImageTag,
|
|
CreatedAt: rollback.CreatedAt,
|
|
},
|
|
"message": "Rollback initiated",
|
|
})
|
|
}
|
|
|
|
// capacityCheck compares the requested per-replica memory (limit, falling
|
|
// back to reservation) against node capacity. app_settings.capacity_policy
|
|
// = "block" fails the deployment; the default "warn" only logs. CPU is not
|
|
// gated — shares are a weight, not a reservation.
|
|
func capacityCheck(ctx context.Context, db *database.DB, client *docker.Client, service Service, replicas int) string {
|
|
if client == nil {
|
|
return ""
|
|
}
|
|
requested := parseMemoryLimit(service.Memory)
|
|
if requested == 0 {
|
|
requested = parseMemoryLimit(service.MemoryReserve)
|
|
}
|
|
if requested == 0 {
|
|
return ""
|
|
}
|
|
info, err := client.GetSystemInfo(ctx)
|
|
if err != nil || info.MemTotal <= 0 {
|
|
return ""
|
|
}
|
|
need := requested * int64(replicas)
|
|
if need <= info.MemTotal {
|
|
return ""
|
|
}
|
|
msg := fmt.Sprintf("requested %d bytes x %d replicas exceeds node memory (%d bytes)",
|
|
requested, replicas, info.MemTotal)
|
|
if strings.ToLower(settingValue(db, "capacity_policy", "", "warn")) == "block" {
|
|
return msg
|
|
}
|
|
slog.Warn("capacity check exceeded", "service_id", service.ID, "detail", msg)
|
|
return ""
|
|
}
|