From 14ca3d00e78d39de2b1fa91e781e9f5eb432fb70 Mon Sep 17 00:00:00 2001 From: nutcas3 Date: Mon, 20 Apr 2026 20:06:09 +0300 Subject: [PATCH] feat: Add deployment service with CRUD operations, rollback support, and status tracking - Add DeploymentService struct with database dependency and NewDeploymentService constructor - Add DeploymentFilter struct with Service/Environment/Status/Limit/Offset fields for query filtering - Add List method with context support, filter application (service/environment/status), pagination (default limit: 20), and DESC ordering by created_at - Add Get method with gorm.ErrRecordNotFound handling and formatted --- .../internal/services/deployment.go | 157 ++++++++++++++++++ 1 file changed, 157 insertions(+) create mode 100644 apps/api-server/internal/services/deployment.go diff --git a/apps/api-server/internal/services/deployment.go b/apps/api-server/internal/services/deployment.go new file mode 100644 index 0000000..082f160 --- /dev/null +++ b/apps/api-server/internal/services/deployment.go @@ -0,0 +1,157 @@ +package services + +import ( + "context" + "errors" + "fmt" + "time" + + "gorm.io/gorm" + + "github.com/nutcas3/telecom-platform/apps/api-server/internal/database" + "github.com/nutcas3/telecom-platform/apps/api-server/internal/models" +) + +// DeploymentService persists deployment records. Actual orchestration +// (Helm/ArgoCD/kubectl) is handled by an external controller or operator; +// this service is the system of record for deployment history and status. +type DeploymentService struct { + db *database.Database +} + +func NewDeploymentService(db *database.Database) *DeploymentService { + return &DeploymentService{db: db} +} + +type DeploymentFilter struct { + Service string + Environment string + Status string + Limit int + Offset int +} + +func (s *DeploymentService) List(ctx context.Context, f DeploymentFilter) ([]models.DeploymentRecord, int64, error) { + q := s.db.DB.WithContext(ctx).Model(&models.DeploymentRecord{}) + if f.Service != "" { + q = q.Where("service = ?", f.Service) + } + if f.Environment != "" { + q = q.Where("environment = ?", f.Environment) + } + if f.Status != "" { + q = q.Where("status = ?", f.Status) + } + var total int64 + if err := q.Count(&total).Error; err != nil { + return nil, 0, err + } + if f.Limit <= 0 { + f.Limit = 20 + } + var out []models.DeploymentRecord + if err := q.Order("created_at DESC").Limit(f.Limit).Offset(f.Offset).Find(&out).Error; err != nil { + return nil, 0, err + } + return out, total, nil +} + +func (s *DeploymentService) Get(ctx context.Context, id uint) (*models.DeploymentRecord, error) { + var d models.DeploymentRecord + if err := s.db.DB.WithContext(ctx).First(&d, id).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, fmt.Errorf("deployment %d not found", id) + } + return nil, err + } + return &d, nil +} + +type StartDeploymentInput struct { + Service string `json:"service" binding:"required"` + Version string `json:"version" binding:"required"` + Environment string `json:"environment" binding:"required"` + Strategy string `json:"strategy"` + Replicas int `json:"replicas"` + TriggeredBy string `json:"triggered_by"` + Reason string `json:"reason"` +} + +func (s *DeploymentService) Start(ctx context.Context, in StartDeploymentInput) (*models.DeploymentRecord, error) { + now := time.Now() + d := models.DeploymentRecord{ + Service: in.Service, + Version: in.Version, + Environment: in.Environment, + Strategy: defaultStr(in.Strategy, "rolling"), + Replicas: in.Replicas, + TriggeredBy: defaultStr(in.TriggeredBy, "api"), + Reason: in.Reason, + Status: "in_progress", + StartedAt: &now, + } + if err := s.db.DB.WithContext(ctx).Create(&d).Error; err != nil { + return nil, err + } + return &d, nil +} + +type RollbackInput struct { + Service string `json:"service" binding:"required"` + ToVersion string `json:"to_version" binding:"required"` + Reason string `json:"reason"` + TriggeredBy string `json:"triggered_by"` +} + +func (s *DeploymentService) Rollback(ctx context.Context, in RollbackInput) (*models.DeploymentRecord, error) { + // Ensure the target version exists in history for that service. + var prior models.DeploymentRecord + err := s.db.DB.WithContext(ctx). + Where("service = ? AND version = ?", in.Service, in.ToVersion). + Order("created_at DESC"). + First(&prior).Error + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, fmt.Errorf("no prior deployment of %s at version %s", in.Service, in.ToVersion) + } + return nil, err + } + + now := time.Now() + d := models.DeploymentRecord{ + Service: in.Service, + Version: in.ToVersion, + Environment: prior.Environment, + Strategy: "rolling", + Replicas: prior.Replicas, + TriggeredBy: defaultStr(in.TriggeredBy, "api"), + Reason: in.Reason, + Status: "rolled_back", + StartedAt: &now, + RollbackTo: in.ToVersion, + } + if err := s.db.DB.WithContext(ctx).Create(&d).Error; err != nil { + return nil, err + } + return &d, nil +} + +// MarkCompleted updates deployment status; intended to be called by the +// orchestration layer (controller) once the real rollout finishes. +func (s *DeploymentService) MarkCompleted(ctx context.Context, id uint, success bool) (*models.DeploymentRecord, error) { + d, err := s.Get(ctx, id) + if err != nil { + return nil, err + } + now := time.Now() + d.CompletedAt = &now + if success { + d.Status = "completed" + } else { + d.Status = "failed" + } + if err := s.db.DB.WithContext(ctx).Save(d).Error; err != nil { + return nil, err + } + return d, nil +}