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>
848 lines
29 KiB
Go
848 lines
29 KiB
Go
package api
|
|
|
|
import (
|
|
"containr/internal/database"
|
|
"containr/internal/database/sqlcdb"
|
|
"containr/internal/deployment"
|
|
"containr/internal/deployqueue"
|
|
"context"
|
|
"database/sql"
|
|
"encoding/json"
|
|
"fmt"
|
|
"net/http"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/gin-gonic/gin"
|
|
"github.com/google/uuid"
|
|
)
|
|
|
|
// PreviewEnvironment represents a preview environment
|
|
type PreviewEnvironment struct {
|
|
ID uuid.UUID `json:"id" db:"id"`
|
|
ProjectID uuid.UUID `json:"project_id" db:"project_id"`
|
|
ServiceID uuid.UUID `json:"service_id" db:"service_id"`
|
|
BranchName string `json:"branch_name" db:"branch_name"`
|
|
PRNumber *int `json:"pr_number" db:"pr_number"`
|
|
Environment string `json:"environment" db:"environment"` // preview-{branch}-{timestamp}
|
|
Status string `json:"status" db:"status"` // building, running, failed, stopped, expired
|
|
URL string `json:"url" db:"url"`
|
|
ExpiresAt *time.Time `json:"expires_at" db:"expires_at"`
|
|
CreatedAt time.Time `json:"created_at" db:"created_at"`
|
|
UpdatedAt time.Time `json:"updated_at" db:"updated_at"`
|
|
|
|
// PreviewServiceID is the cloned service this preview actually deploys;
|
|
// its status is the source of truth while the preview is active.
|
|
PreviewServiceID *uuid.UUID `json:"preview_service_id,omitempty" db:"preview_service_id"`
|
|
|
|
// Related data
|
|
Service *Service `json:"service,omitempty"`
|
|
DeploymentID *uuid.UUID `json:"deployment_id,omitempty"`
|
|
}
|
|
|
|
// previewRowShape is the common column set the generated preview rows
|
|
// carry — ListPreviewEnvironmentsForProjectRow and GetPreviewEnvironmentRow
|
|
// share it.
|
|
type previewRowShape struct {
|
|
ID uuid.UUID
|
|
ProjectID uuid.UUID
|
|
ServiceID uuid.UUID
|
|
PreviewServiceID uuid.NullUUID
|
|
BranchName string
|
|
PrNumber sql.NullInt32
|
|
Environment string
|
|
Status string
|
|
Url sql.NullString
|
|
ExpiresAt time.Time
|
|
CreatedAt sql.NullTime
|
|
UpdatedAt sql.NullTime
|
|
SvcID uuid.NullUUID
|
|
ServiceName sql.NullString
|
|
ServiceType sql.NullString
|
|
}
|
|
|
|
func previewFromRow(r previewRowShape) PreviewEnvironment {
|
|
env := PreviewEnvironment{
|
|
ID: r.ID,
|
|
ProjectID: r.ProjectID,
|
|
ServiceID: r.ServiceID,
|
|
BranchName: r.BranchName,
|
|
Environment: r.Environment,
|
|
Status: r.Status,
|
|
URL: r.Url.String,
|
|
CreatedAt: r.CreatedAt.Time,
|
|
UpdatedAt: r.UpdatedAt.Time,
|
|
}
|
|
if r.PreviewServiceID.Valid {
|
|
id := r.PreviewServiceID.UUID
|
|
env.PreviewServiceID = &id
|
|
}
|
|
if r.PrNumber.Valid {
|
|
n := int(r.PrNumber.Int32)
|
|
env.PRNumber = &n
|
|
}
|
|
if !r.ExpiresAt.IsZero() {
|
|
t := r.ExpiresAt
|
|
env.ExpiresAt = &t
|
|
}
|
|
if r.SvcID.Valid {
|
|
env.Service = &Service{
|
|
ID: r.SvcID.UUID,
|
|
Name: r.ServiceName.String,
|
|
Type: r.ServiceType.String,
|
|
}
|
|
}
|
|
return env
|
|
}
|
|
|
|
func prNumberSQL(n *int) sql.NullInt32 {
|
|
if n == nil {
|
|
return sql.NullInt32{}
|
|
}
|
|
return sql.NullInt32{Int32: int32(*n), Valid: true}
|
|
}
|
|
|
|
// CreatePreviewEnvironmentRequest represents a request to create a preview environment
|
|
type CreatePreviewEnvironmentRequest struct {
|
|
ProjectID uuid.UUID `json:"project_id"`
|
|
ServiceID uuid.UUID `json:"service_id" binding:"required"`
|
|
BranchName string `json:"branch_name" binding:"required"`
|
|
PRNumber *int `json:"pr_number"`
|
|
TTLHours int `json:"ttl_hours" binding:"min=1,max=168"` // 1 hour to 7 days
|
|
}
|
|
|
|
// UpdatePreviewEnvironmentRequest represents a request to update a preview environment
|
|
type UpdatePreviewEnvironmentRequest struct {
|
|
Status string `json:"status" binding:"omitempty,oneof=building running failed stopped expired"`
|
|
URL string `json:"url"`
|
|
ExpiresAt *time.Time `json:"expires_at"`
|
|
TTLHours int `json:"ttl_hours" binding:"omitempty,min=1,max=168"`
|
|
}
|
|
|
|
// PromotePreviewEnvironmentRequest represents a request to promote a preview environment
|
|
type PromotePreviewEnvironmentRequest struct {
|
|
TargetEnvironment string `json:"target_environment" binding:"required,oneof=production development"`
|
|
CreateBackup bool `json:"create_backup"`
|
|
}
|
|
|
|
// handleGetPreviewEnvironments retrieves all preview environments for a project
|
|
func handleGetPreviewEnvironments(c *gin.Context) {
|
|
db, exists := c.Get("db")
|
|
if !exists {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Database connection not available"})
|
|
return
|
|
}
|
|
|
|
projectIDStr := firstPathParam(c, "id", "project_id", "projectId")
|
|
projectID, err := uuid.Parse(projectIDStr)
|
|
if err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid project ID"})
|
|
return
|
|
}
|
|
|
|
// Approved projects are public; unapproved ones need owner/member/admin.
|
|
projectExists, allowed := projectReadAccess(c, db.(*database.DB), projectID)
|
|
if !projectExists || !allowed {
|
|
c.JSON(http.StatusNotFound, gin.H{"error": "Project not found"})
|
|
return
|
|
}
|
|
|
|
// Get preview environments for the project with service info
|
|
rows, err := sqlcdb.New(db.(*database.DB).DB).ListPreviewEnvironmentsForProject(c.Request.Context(), projectID)
|
|
if err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to retrieve preview environments"})
|
|
return
|
|
}
|
|
|
|
environments := make([]PreviewEnvironment, 0, len(rows))
|
|
for _, r := range rows {
|
|
environments = append(environments, previewFromRow(previewRowShape{
|
|
ID: r.ID, ProjectID: r.ProjectID, ServiceID: r.ServiceID,
|
|
PreviewServiceID: r.PreviewServiceID, BranchName: r.BranchName,
|
|
PrNumber: r.PrNumber, Environment: r.Environment, Status: r.Status,
|
|
Url: r.Url, ExpiresAt: r.ExpiresAt, CreatedAt: r.CreatedAt, UpdatedAt: r.UpdatedAt,
|
|
SvcID: r.SvcID, ServiceName: r.ServiceName, ServiceType: r.ServiceType,
|
|
}))
|
|
}
|
|
|
|
c.JSON(http.StatusOK, gin.H{"preview_environments": environments})
|
|
}
|
|
|
|
// handleCreatePreviewEnvironment creates a new preview environment
|
|
func handleCreatePreviewEnvironment(c *gin.Context) {
|
|
db, exists := c.Get("db")
|
|
if !exists {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Database connection not available"})
|
|
return
|
|
}
|
|
|
|
projectIDStr := firstPathParam(c, "id", "project_id", "projectId")
|
|
projectID, err := uuid.Parse(projectIDStr)
|
|
if err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid project ID"})
|
|
return
|
|
}
|
|
|
|
var req CreatePreviewEnvironmentRequest
|
|
if err := c.ShouldBindJSON(&req); err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
|
return
|
|
}
|
|
|
|
if req.ProjectID == uuid.Nil {
|
|
req.ProjectID = projectID
|
|
} else if req.ProjectID != projectID {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Project ID in URL and request body must match"})
|
|
return
|
|
}
|
|
|
|
// Get user ID from JWT token
|
|
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)
|
|
|
|
// Check if project exists and user has access
|
|
project, err := q.GetProjectBrief(ctx, req.ProjectID)
|
|
if err != nil {
|
|
c.JSON(http.StatusNotFound, gin.H{"error": "Project not found"})
|
|
return
|
|
}
|
|
|
|
// Check if user owns the project
|
|
if project.OwnerID.String() != userID.(string) && !contextIsAdmin(c) {
|
|
c.JSON(http.StatusForbidden, gin.H{"error": "Access denied"})
|
|
return
|
|
}
|
|
|
|
// Check if service exists and belongs to the project
|
|
service, err := q.GetServiceTypeBrief(ctx, sqlcdb.GetServiceTypeBriefParams{
|
|
ID: req.ServiceID, ProjectID: req.ProjectID})
|
|
if err != nil {
|
|
c.JSON(http.StatusNotFound, gin.H{"error": "Service not found or doesn't belong to this project"})
|
|
return
|
|
}
|
|
|
|
// Check if preview environment already exists for this branch and service
|
|
count, err := q.CountActivePreviewsForBranch(ctx, sqlcdb.CountActivePreviewsForBranchParams{
|
|
ServiceID: req.ServiceID, BranchName: req.BranchName})
|
|
if err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to check existing preview environment"})
|
|
return
|
|
}
|
|
if count > 0 {
|
|
c.JSON(http.StatusConflict, gin.H{"error": "Preview environment already exists for this branch and service"})
|
|
return
|
|
}
|
|
|
|
// Set default TTL if not provided
|
|
ttlHours := req.TTLHours
|
|
if ttlHours == 0 {
|
|
ttlHours = 24 // Default 24 hours
|
|
}
|
|
|
|
// A preview is a real service: clone the source, pin it to the
|
|
// requested branch, swap its domains for a preview hostname, and run a
|
|
// normal deployment through the queue. Expiry sweeps the clone away.
|
|
envName := generatePreviewEnvironmentName(req.BranchName)
|
|
cloneName := previewServiceName(service.Name, req.BranchName)
|
|
cloneID, err := cloneServiceRow(db.(*database.DB), req.ServiceID, req.ProjectID, cloneName, "")
|
|
if err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to clone service for preview"})
|
|
return
|
|
}
|
|
|
|
// The clone's branch override is what makes it a preview — everything
|
|
// else (env vars, volumes, builder, resources) stays identical.
|
|
if err := q.UpdateServicePreviewBranch(ctx, sqlcdb.UpdateServicePreviewBranchParams{
|
|
GitBranch: sql.NullString{String: req.BranchName, Valid: true}, ID: cloneID}); err != nil {
|
|
_ = q.DeleteServiceByID(ctx, cloneID)
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to configure preview service"})
|
|
return
|
|
}
|
|
// Cloned service_domains would collide with the parent's Traefik
|
|
// router — previews get their own hostname or none.
|
|
if err := q.DeleteAllServiceDomains(ctx, cloneID); err != nil {
|
|
_ = q.DeleteServiceByID(ctx, cloneID)
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to configure preview domains"})
|
|
return
|
|
}
|
|
previewHost := resolvePreviewHost(db.(*database.DB), cloneID, envName)
|
|
if previewHost != "" {
|
|
_ = q.CreatePreviewDomain(ctx, sqlcdb.CreatePreviewDomainParams{
|
|
ServiceID: cloneID, Domain: previewHost})
|
|
}
|
|
|
|
expiresAt := time.Now().Add(time.Duration(ttlHours) * time.Hour)
|
|
env := PreviewEnvironment{
|
|
ID: uuid.New(),
|
|
ProjectID: req.ProjectID,
|
|
ServiceID: req.ServiceID,
|
|
BranchName: req.BranchName,
|
|
PRNumber: req.PRNumber,
|
|
Environment: envName,
|
|
Status: "building",
|
|
ExpiresAt: &expiresAt,
|
|
CreatedAt: time.Now(),
|
|
UpdatedAt: time.Now(),
|
|
}
|
|
if previewHost != "" {
|
|
env.URL = "https://" + previewHost
|
|
}
|
|
|
|
err = q.CreatePreviewEnvironment(ctx, sqlcdb.CreatePreviewEnvironmentParams{
|
|
ID: env.ID,
|
|
ProjectID: env.ProjectID,
|
|
ServiceID: env.ServiceID,
|
|
PreviewServiceID: uuid.NullUUID{UUID: cloneID, Valid: true},
|
|
BranchName: env.BranchName,
|
|
PrNumber: prNumberSQL(env.PRNumber),
|
|
Environment: env.Environment,
|
|
Status: env.Status,
|
|
Url: sql.NullString{String: env.URL, Valid: env.URL != ""},
|
|
ExpiresAt: *env.ExpiresAt,
|
|
CreatedAt: sql.NullTime{Time: env.CreatedAt, Valid: true},
|
|
UpdatedAt: sql.NullTime{Time: env.UpdatedAt, Valid: true},
|
|
})
|
|
if err != nil {
|
|
_ = q.DeleteServiceByID(ctx, cloneID)
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create preview environment"})
|
|
return
|
|
}
|
|
|
|
deploymentID := uuid.New()
|
|
err = q.InsertPendingDeployment(ctx, sqlcdb.InsertPendingDeploymentParams{
|
|
ID: deploymentID,
|
|
ServiceID: cloneID,
|
|
Version: "preview-" + env.Environment,
|
|
CommitHash: sql.NullString{String: req.BranchName, Valid: true},
|
|
})
|
|
if err != nil {
|
|
_ = q.DeletePreviewEnvironment(ctx, env.ID)
|
|
_ = q.DeleteServiceByID(ctx, cloneID)
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to provision preview deployment"})
|
|
return
|
|
}
|
|
env.DeploymentID = &deploymentID
|
|
|
|
if engineValue, exists := c.Get("deployment_engine"); exists && engineValue != nil {
|
|
if engine, ok := engineValue.(*deployment.DeploymentEngine); ok {
|
|
cloneService, loadErr := loadServiceForDeploy(db.(*database.DB), cloneID)
|
|
if loadErr == nil {
|
|
engineInstance := engine
|
|
d := DeploymentModel{ID: deploymentID, ServiceID: cloneID, Status: "pending", CreatedAt: time.Now(), UpdatedAt: time.Now()}
|
|
getDeployQueue(c).Enqueue(cloneID, deployqueue.Job{
|
|
DeploymentID: deploymentID,
|
|
Run: func(jctx context.Context) {
|
|
runDeploymentAndSync(jctx, db.(*database.DB), engineInstance, &d, cloneService, CreateDeploymentRequest{Branch: req.BranchName, Trigger: "preview"}, userID.(string))
|
|
},
|
|
})
|
|
}
|
|
}
|
|
}
|
|
|
|
LogAuditWithRequest(c, "service", req.ServiceID.String(), "preview.create", map[string]interface{}{
|
|
"preview_environment_id": env.ID.String(),
|
|
"preview_service_id": cloneID.String(),
|
|
"branch": req.BranchName,
|
|
})
|
|
c.JSON(http.StatusCreated, gin.H{"preview_environment": env})
|
|
}
|
|
|
|
// handleGetPreviewEnvironment retrieves a specific preview environment
|
|
func handleGetPreviewEnvironment(c *gin.Context) {
|
|
db, exists := c.Get("db")
|
|
if !exists {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Database connection not available"})
|
|
return
|
|
}
|
|
|
|
envIDStr := c.Param("id")
|
|
envID, err := uuid.Parse(envIDStr)
|
|
if err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid preview environment ID"})
|
|
return
|
|
}
|
|
|
|
// Public when the parent project is approved; otherwise owner/member/admin.
|
|
userID := optionalUserUUID(c)
|
|
r, err := sqlcdb.New(db.(*database.DB).DB).GetPreviewEnvironment(
|
|
c.Request.Context(), sqlcdb.GetPreviewEnvironmentParams{
|
|
ID: envID, OwnerID: userID, Column3: contextIsAdmin(c)})
|
|
if err != nil {
|
|
c.JSON(http.StatusNotFound, gin.H{"error": "Preview environment not found"})
|
|
return
|
|
}
|
|
env := previewFromRow(previewRowShape{
|
|
ID: r.ID, ProjectID: r.ProjectID, ServiceID: r.ServiceID,
|
|
PreviewServiceID: r.PreviewServiceID, BranchName: r.BranchName,
|
|
PrNumber: r.PrNumber, Environment: r.Environment, Status: r.Status,
|
|
Url: r.Url, ExpiresAt: r.ExpiresAt, CreatedAt: r.CreatedAt, UpdatedAt: r.UpdatedAt,
|
|
SvcID: r.SvcID, ServiceName: r.ServiceName, ServiceType: r.ServiceType,
|
|
})
|
|
c.JSON(http.StatusOK, gin.H{"preview_environment": env})
|
|
}
|
|
|
|
// handleUpdatePreviewEnvironment updates a preview environment
|
|
func handleUpdatePreviewEnvironment(c *gin.Context) {
|
|
db, exists := c.Get("db")
|
|
if !exists {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Database connection not available"})
|
|
return
|
|
}
|
|
|
|
envIDStr := c.Param("id")
|
|
envID, err := uuid.Parse(envIDStr)
|
|
if err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid preview environment ID"})
|
|
return
|
|
}
|
|
|
|
var req UpdatePreviewEnvironmentRequest
|
|
if err := c.ShouldBindJSON(&req); err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
|
return
|
|
}
|
|
|
|
// Get user ID from JWT token
|
|
userID, exists := c.Get("user_id")
|
|
if !exists {
|
|
c.JSON(http.StatusUnauthorized, gin.H{"error": "User not authenticated"})
|
|
return
|
|
}
|
|
|
|
// Check if preview environment exists and user has access
|
|
uid, _ := uuid.Parse(fmt.Sprint(userID))
|
|
q := sqlcdb.New(db.(*database.DB).DB)
|
|
r, err := q.GetPreviewEnvironmentForWrite(
|
|
c.Request.Context(), sqlcdb.GetPreviewEnvironmentForWriteParams{
|
|
ID: envID, OwnerID: uid, Column3: contextIsAdmin(c)})
|
|
if err != nil {
|
|
c.JSON(http.StatusNotFound, gin.H{"error": "Preview environment not found"})
|
|
return
|
|
}
|
|
existingEnv := previewFromRow(previewRowShape{
|
|
ID: r.ID, ProjectID: r.ProjectID, ServiceID: r.ServiceID,
|
|
BranchName: r.BranchName, PrNumber: r.PrNumber, Environment: r.Environment,
|
|
Status: r.Status, Url: r.Url, ExpiresAt: r.ExpiresAt,
|
|
CreatedAt: r.CreatedAt, UpdatedAt: r.UpdatedAt,
|
|
})
|
|
|
|
// Update fields if provided
|
|
if req.Status != "" {
|
|
existingEnv.Status = req.Status
|
|
}
|
|
if req.URL != "" {
|
|
existingEnv.URL = req.URL
|
|
}
|
|
if req.ExpiresAt != nil {
|
|
existingEnv.ExpiresAt = req.ExpiresAt
|
|
}
|
|
if req.TTLHours > 0 {
|
|
newExpiresAt := time.Now().Add(time.Duration(req.TTLHours) * time.Hour)
|
|
existingEnv.ExpiresAt = &newExpiresAt
|
|
}
|
|
|
|
existingEnv.UpdatedAt = time.Now()
|
|
|
|
// Update preview environment in database
|
|
expiresAt := time.Time{}
|
|
if existingEnv.ExpiresAt != nil {
|
|
expiresAt = *existingEnv.ExpiresAt
|
|
}
|
|
err = q.UpdatePreviewEnvironment(c.Request.Context(), sqlcdb.UpdatePreviewEnvironmentParams{
|
|
Status: existingEnv.Status,
|
|
Url: sql.NullString{String: existingEnv.URL, Valid: existingEnv.URL != ""},
|
|
ExpiresAt: expiresAt,
|
|
UpdatedAt: sql.NullTime{Time: existingEnv.UpdatedAt, Valid: true},
|
|
ID: existingEnv.ID,
|
|
})
|
|
|
|
if err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update preview environment"})
|
|
return
|
|
}
|
|
|
|
c.JSON(http.StatusOK, gin.H{"preview_environment": existingEnv})
|
|
}
|
|
|
|
// handleDeletePreviewEnvironment deletes a preview environment
|
|
func handleDeletePreviewEnvironment(c *gin.Context) {
|
|
db, exists := c.Get("db")
|
|
if !exists {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Database connection not available"})
|
|
return
|
|
}
|
|
|
|
envIDStr := c.Param("id")
|
|
envID, err := uuid.Parse(envIDStr)
|
|
if err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid preview environment ID"})
|
|
return
|
|
}
|
|
|
|
// Get user ID from JWT token
|
|
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)
|
|
|
|
// Check if preview environment exists and user has access
|
|
projectOwnerID, err := q.GetPreviewEnvironmentOwner(ctx, envID)
|
|
if err != nil {
|
|
c.JSON(http.StatusNotFound, gin.H{"error": "Preview environment not found"})
|
|
return
|
|
}
|
|
|
|
// Check if user owns the project
|
|
if projectOwnerID.String() != userID.(string) && !contextIsAdmin(c) {
|
|
c.JSON(http.StatusForbidden, gin.H{"error": "Access denied"})
|
|
return
|
|
}
|
|
|
|
// Tear down the clone's runtime and row — the preview is the clone.
|
|
if cloneID, err := q.GetPreviewServiceID(ctx, envID); err == nil && cloneID.Valid {
|
|
if engineValue, exists := c.Get("deployment_engine"); exists && engineValue != nil {
|
|
if engine, ok := engineValue.(*deployment.DeploymentEngine); ok {
|
|
removeServiceRuntime(ctx, db.(*database.DB), engine, cloneID.UUID)
|
|
}
|
|
}
|
|
_ = q.DeleteServiceByID(ctx, cloneID.UUID)
|
|
}
|
|
|
|
// Delete preview environment
|
|
err = q.DeletePreviewEnvironment(ctx, envID)
|
|
|
|
if err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to delete preview environment"})
|
|
return
|
|
}
|
|
|
|
c.JSON(http.StatusOK, gin.H{"message": "Preview environment deleted successfully"})
|
|
}
|
|
|
|
// handlePromotePreviewEnvironment promotes a preview environment to production/development
|
|
func handlePromotePreviewEnvironment(c *gin.Context) {
|
|
db, exists := c.Get("db")
|
|
if !exists {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Database connection not available"})
|
|
return
|
|
}
|
|
|
|
envIDStr := c.Param("id")
|
|
envID, err := uuid.Parse(envIDStr)
|
|
if err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid preview environment ID"})
|
|
return
|
|
}
|
|
|
|
var req PromotePreviewEnvironmentRequest
|
|
if err := c.ShouldBindJSON(&req); err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
|
return
|
|
}
|
|
|
|
// Get user ID from JWT token
|
|
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)
|
|
|
|
// Get preview environment details
|
|
uid, _ := uuid.Parse(fmt.Sprint(userID))
|
|
r, err := q.GetPreviewEnvironmentForPromote(
|
|
ctx, sqlcdb.GetPreviewEnvironmentForPromoteParams{
|
|
ID: envID, OwnerID: uid, Column3: contextIsAdmin(c)})
|
|
if err != nil {
|
|
c.JSON(http.StatusNotFound, gin.H{"error": "Preview environment not found"})
|
|
return
|
|
}
|
|
env := PreviewEnvironment{
|
|
ID: r.ID,
|
|
ProjectID: r.ProjectID,
|
|
ServiceID: r.ServiceID,
|
|
BranchName: r.BranchName,
|
|
Environment: r.Environment,
|
|
Status: r.Status,
|
|
}
|
|
if r.PreviewServiceID.Valid {
|
|
id := r.PreviewServiceID.UUID
|
|
env.PreviewServiceID = &id
|
|
}
|
|
|
|
// Check if preview environment is in a state that can be promoted
|
|
if env.Status != "running" {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Preview environment must be running to promote"})
|
|
return
|
|
}
|
|
|
|
// Promote is a real redeploy: the source service builds the preview's
|
|
// branch onto its own deployment track.
|
|
deploymentID := uuid.New()
|
|
promotionVersion := fmt.Sprintf("promote-%s-%d", strings.ReplaceAll(env.BranchName, "/", "-"), time.Now().Unix())
|
|
err = q.InsertPendingDeployment(ctx, sqlcdb.InsertPendingDeploymentParams{
|
|
ID: deploymentID,
|
|
ServiceID: env.ServiceID,
|
|
Version: promotionVersion,
|
|
CommitHash: sql.NullString{String: env.BranchName, Valid: true},
|
|
})
|
|
if err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create promotion deployment"})
|
|
return
|
|
}
|
|
|
|
sourceService, loadErr := loadServiceForDeploy(db.(*database.DB), env.ServiceID)
|
|
if loadErr != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to load promotion target service"})
|
|
return
|
|
}
|
|
if engineValue, exists := c.Get("deployment_engine"); exists && engineValue != nil {
|
|
if engine, ok := engineValue.(*deployment.DeploymentEngine); ok {
|
|
engineInstance := engine
|
|
d := DeploymentModel{ID: deploymentID, ServiceID: env.ServiceID, Status: "pending", CreatedAt: time.Now(), UpdatedAt: time.Now()}
|
|
getDeployQueue(c).Enqueue(env.ServiceID, deployqueue.Job{
|
|
DeploymentID: deploymentID,
|
|
Run: func(jctx context.Context) {
|
|
runDeploymentAndSync(jctx, db.(*database.DB), engineInstance, &d, sourceService, CreateDeploymentRequest{Branch: env.BranchName, Trigger: "promote"}, userID.(string))
|
|
},
|
|
})
|
|
}
|
|
}
|
|
|
|
// Promotion retires the preview — the clone's containers and row go.
|
|
if env.PreviewServiceID != nil {
|
|
if engineValue, exists := c.Get("deployment_engine"); exists && engineValue != nil {
|
|
if engine, ok := engineValue.(*deployment.DeploymentEngine); ok {
|
|
removeServiceRuntime(ctx, db.(*database.DB), engine, *env.PreviewServiceID)
|
|
}
|
|
}
|
|
_ = q.DeleteServiceByID(ctx, *env.PreviewServiceID)
|
|
}
|
|
|
|
if err := q.MarkPreviewEnvironmentStatus(ctx, sqlcdb.MarkPreviewEnvironmentStatusParams{
|
|
Status: "stopped", ID: env.ID}); err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update preview environment status"})
|
|
return
|
|
}
|
|
|
|
c.JSON(http.StatusOK, gin.H{
|
|
"message": "Preview environment promoted successfully",
|
|
"promotion": map[string]interface{}{
|
|
"preview_environment_id": env.ID,
|
|
"target_environment": req.TargetEnvironment,
|
|
"branch_name": env.BranchName,
|
|
"create_backup": req.CreateBackup,
|
|
"deployment_id": deploymentID,
|
|
"status": "queued",
|
|
"preview_status": "stopped",
|
|
},
|
|
})
|
|
}
|
|
|
|
// generatePreviewEnvironmentName generates a unique environment name for preview
|
|
func generatePreviewEnvironmentName(branchName string) string {
|
|
timestamp := time.Now().Format("20060102-150405")
|
|
// Sanitize branch name
|
|
sanitizedBranch := strings.ReplaceAll(branchName, "/", "-")
|
|
sanitizedBranch = strings.ReplaceAll(sanitizedBranch, "_", "-")
|
|
return fmt.Sprintf("preview-%s-%s", sanitizedBranch, timestamp)
|
|
}
|
|
|
|
// handleCleanupExpiredPreviewEnvironments cleans up expired preview environments
|
|
func handleCleanupExpiredPreviewEnvironments(c *gin.Context) {
|
|
db, exists := c.Get("db")
|
|
if !exists {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Database connection not available"})
|
|
return
|
|
}
|
|
|
|
// Get user ID from JWT token
|
|
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)
|
|
uid, _ := uuid.Parse(fmt.Sprint(userID))
|
|
|
|
// Find expired preview environments for user's projects
|
|
rows, err := q.ListExpiredPreviewsForUser(ctx, sqlcdb.ListExpiredPreviewsForUserParams{
|
|
OwnerID: uid, Column2: contextIsAdmin(c)})
|
|
if err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to find expired preview environments"})
|
|
return
|
|
}
|
|
|
|
expiredEnvs := make([]PreviewEnvironment, 0, len(rows))
|
|
for _, r := range rows {
|
|
env := PreviewEnvironment{
|
|
ID: r.ID, ProjectID: r.ProjectID, ServiceID: r.ServiceID,
|
|
BranchName: r.BranchName, Environment: r.Environment,
|
|
}
|
|
if r.PreviewServiceID.Valid {
|
|
id := r.PreviewServiceID.UUID
|
|
env.PreviewServiceID = &id
|
|
}
|
|
expiredEnvs = append(expiredEnvs, env)
|
|
}
|
|
|
|
// Mark expired environments as expired and tear down their clones
|
|
cleanupCount := 0
|
|
for _, env := range expiredEnvs {
|
|
if err := q.MarkPreviewEnvironmentStatus(ctx, sqlcdb.MarkPreviewEnvironmentStatusParams{
|
|
Status: "expired", ID: env.ID}); err != nil {
|
|
continue
|
|
}
|
|
if env.PreviewServiceID != nil {
|
|
if engineValue, exists := c.Get("deployment_engine"); exists && engineValue != nil {
|
|
if engine, ok := engineValue.(*deployment.DeploymentEngine); ok {
|
|
removeServiceRuntime(ctx, db.(*database.DB), engine, *env.PreviewServiceID)
|
|
}
|
|
}
|
|
_ = q.DeleteServiceByID(ctx, *env.PreviewServiceID)
|
|
}
|
|
cleanupCount++
|
|
}
|
|
|
|
c.JSON(http.StatusOK, gin.H{
|
|
"message": "Cleanup completed",
|
|
"cleaned_count": cleanupCount,
|
|
"expired_environments": expiredEnvs,
|
|
})
|
|
}
|
|
|
|
// previewServiceName builds the clone's service name — deterministic enough
|
|
// to spot in lists, short enough for dnsLabel-derived hostnames.
|
|
func previewServiceName(serviceName, branch string) string {
|
|
label := dnsLabel(serviceName + "-" + branch)
|
|
if label == "" {
|
|
return "preview-" + uuid.NewString()[:8]
|
|
}
|
|
return label + "-preview"
|
|
}
|
|
|
|
// resolvePreviewHost picks a preview hostname under the clone's node default
|
|
// domain — same source as serviceAutoDomains. Returns "" when no node
|
|
// carries a default domain; the preview still runs, reachable internally.
|
|
func resolvePreviewHost(db *database.DB, cloneID uuid.UUID, envName string) string {
|
|
label := dnsLabel(envName)
|
|
if label == "" {
|
|
return ""
|
|
}
|
|
q := sqlcdb.New(db.DB)
|
|
ctx := context.Background()
|
|
brief, err := q.GetServicePlacementBrief(ctx, cloneID)
|
|
if err != nil {
|
|
return ""
|
|
}
|
|
nodeID, spread := brief.NodeID, brief.Spread
|
|
var base string
|
|
switch {
|
|
case nodeID.Valid && nodeID.String != "":
|
|
if a, err := q.GetAgent(ctx, nodeID.String); err == nil {
|
|
base = a.DefaultDomain
|
|
}
|
|
case spread:
|
|
if bases, err := q.ListSchedulableAgentDomains(ctx, json.RawMessage(tagsJSON(servicePlacementTags(db, cloneID)))); err == nil && len(bases) > 0 {
|
|
base = bases[0]
|
|
}
|
|
default:
|
|
// Local preview: borrow the first node default domain if any —
|
|
// still better than no hostname for a Traefik-fronted local host.
|
|
if bases, err := q.ListSchedulableAgentDomains(ctx, json.RawMessage("[]")); err == nil && len(bases) > 0 {
|
|
base = bases[0]
|
|
}
|
|
}
|
|
if base == "" {
|
|
return ""
|
|
}
|
|
return label + "." + base
|
|
}
|
|
|
|
// loadServiceForDeploy hydrates the Service fields runDeploymentAndSync
|
|
// needs — mirrors the loader in handleDeployService.
|
|
func serviceForDeployFromRow(r sqlcdb.GetServiceForDeployRow) Service {
|
|
return Service{
|
|
ID: r.ID, ProjectID: r.ProjectID, Name: r.Name, Type: r.Type.String,
|
|
Status: r.Status.String, Image: r.Image.String, Command: r.Command.String,
|
|
Environment: r.Environment.String, GitRepo: r.GitRepo.String,
|
|
GitBranch: r.GitBranch.String, BuildPath: r.BuildPath.String,
|
|
CPU: r.Cpu.String, Memory: r.Memory.String,
|
|
Replicas: int(r.Replicas), Port: int(r.Port), Domain: r.Domain,
|
|
HealthCheckPath: r.HealthcheckPath, RestartPolicy: r.RestartPolicy,
|
|
Builder: r.Builder, CPUReserve: r.CpuReserve, MemoryReserve: r.MemoryReserve,
|
|
StaticBuildCmd: r.StaticBuildCmd, StaticDir: r.StaticDir,
|
|
CreatedAt: r.CreatedAt.Time, UpdatedAt: r.UpdatedAt.Time,
|
|
}
|
|
}
|
|
|
|
func loadServiceForDeploy(db *database.DB, serviceID uuid.UUID) (Service, error) {
|
|
r, err := sqlcdb.New(db.DB).GetServiceForDeploy(context.Background(), serviceID)
|
|
if err != nil {
|
|
return Service{}, err
|
|
}
|
|
return serviceForDeployFromRow(r), nil
|
|
}
|
|
|
|
// StartPreviewSweeper expires preview environments past their TTL — tears
|
|
// down each clone's runtime and row, marks the preview expired.
|
|
func StartPreviewSweeper(ctx context.Context, db *database.DB, engine *deployment.DeploymentEngine) {
|
|
if db == nil || engine == nil {
|
|
return
|
|
}
|
|
go func() {
|
|
ticker := time.NewTicker(time.Minute)
|
|
defer ticker.Stop()
|
|
for {
|
|
select {
|
|
case <-ctx.Done():
|
|
return
|
|
case <-ticker.C:
|
|
sweepExpiredPreviews(ctx, db, engine)
|
|
syncPreviewStatuses(ctx, db)
|
|
}
|
|
}
|
|
}()
|
|
}
|
|
|
|
// sweepExpiredPreviews retires previews whose TTL elapsed.
|
|
func sweepExpiredPreviews(ctx context.Context, db *database.DB, engine *deployment.DeploymentEngine) {
|
|
q := sqlcdb.New(db.DB)
|
|
rows, err := q.ListSweepablePreviews(ctx)
|
|
if err != nil {
|
|
return
|
|
}
|
|
var ids []uuid.UUID
|
|
for _, r := range rows {
|
|
ids = append(ids, r.ID)
|
|
if r.PreviewServiceID.Valid {
|
|
removeServiceRuntime(ctx, db, engine, r.PreviewServiceID.UUID)
|
|
_ = q.DeleteServiceByID(ctx, r.PreviewServiceID.UUID)
|
|
}
|
|
}
|
|
for _, id := range ids {
|
|
_ = q.MarkPreviewEnvironmentStatus(ctx, sqlcdb.MarkPreviewEnvironmentStatusParams{
|
|
Status: "expired", ID: id})
|
|
}
|
|
}
|
|
|
|
// syncPreviewStatuses folds the clone's live service status into the
|
|
// preview row so list/detail endpoints reflect the real deploy state.
|
|
func syncPreviewStatuses(ctx context.Context, db *database.DB) {
|
|
_ = sqlcdb.New(db.DB).SyncPreviewStatuses(ctx)
|
|
}
|