Files
Tomas Dvorak 355a97bab4 overhaul
2026-04-14 18:04:48 +02:00

719 lines
20 KiB
Go

package deployment
import (
"context"
"encoding/json"
"fmt"
"os"
"path/filepath"
"sort"
"strings"
"sync"
"time"
)
type HistoryManager struct {
storagePath string
mu sync.RWMutex
deployments map[string]*DeploymentRecord
}
type DeploymentRecord struct {
ID string `json:"id"`
ProjectID string `json:"project_id"`
ServiceID string `json:"service_id"`
Environment string `json:"environment"`
Status string `json:"status"`
ImageName string `json:"image_name"`
ImageTag string `json:"image_tag"`
Config ServiceConfig `json:"config"`
CreatedAt time.Time `json:"created_at"`
StartedAt *time.Time `json:"started_at,omitempty"`
CompletedAt *time.Time `json:"completed_at,omitempty"`
Duration time.Duration `json:"duration"`
Containers []ContainerRecord `json:"containers"`
BuildLog string `json:"build_log"`
DeployLog string `json:"deploy_log"`
Error string `json:"error,omitempty"`
Metadata map[string]string `json:"metadata"`
Trigger TriggerRecord `json:"trigger"`
RollbackFrom *string `json:"rollback_from,omitempty"`
Rollbacks []string `json:"rollbacks"`
Tags []string `json:"tags"`
Annotations map[string]interface{} `json:"annotations"`
}
type ContainerRecord struct {
ID string `json:"id"`
Name string `json:"name"`
Status string `json:"status"`
CreatedAt time.Time `json:"created_at"`
StartedAt time.Time `json:"started_at"`
StoppedAt *time.Time `json:"stopped_at,omitempty"`
Ports []PortRecord `json:"ports,omitempty"`
Resources ResourceRecord `json:"resources"`
Health *HealthRecord `json:"health,omitempty"`
ExitCode *int `json:"exit_code,omitempty"`
ErrorMessage string `json:"error_message,omitempty"`
}
type PortRecord struct {
ContainerPort int32 `json:"container_port"`
HostPort int32 `json:"host_port,omitempty"`
HostIP string `json:"host_ip"`
Protocol string `json:"protocol"`
}
type ResourceRecord struct {
CPUPercent float64 `json:"cpu_percent"`
MemoryUsage int64 `json:"memory_usage"`
MemoryLimit int64 `json:"memory_limit"`
NetworkRx int64 `json:"network_rx"`
NetworkTx int64 `json:"network_tx"`
PidsCurrent uint64 `json:"pids_current"`
PidsLimit uint64 `json:"pids_limit"`
}
type HealthRecord struct {
Status string `json:"status"`
FailingStreak int `json:"failing_streak"`
LastCheck time.Time `json:"last_check"`
Output string `json:"output,omitempty"`
}
type TriggerRecord struct {
Type string `json:"type"` // webhook, manual, api, scheduled
Source string `json:"source"` // Source of trigger
User string `json:"user"` // User who triggered
Data map[string]string `json:"data"` // Trigger-specific data
Timestamp time.Time `json:"timestamp"` // When trigger occurred
}
type DeploymentFilter struct {
ProjectID string `json:"project_id,omitempty"`
ServiceID string `json:"service_id,omitempty"`
Environment string `json:"environment,omitempty"`
Status string `json:"status,omitempty"`
TriggerType string `json:"trigger_type,omitempty"`
User string `json:"user,omitempty"`
From time.Time `json:"from,omitempty"`
To time.Time `json:"to,omitempty"`
Tags []string `json:"tags,omitempty"`
Limit int `json:"limit,omitempty"`
Offset int `json:"offset,omitempty"`
SortBy string `json:"sort_by,omitempty"` // created_at, started_at, completed_at, duration
SortOrder string `json:"sort_order,omitempty"` // asc, desc
}
type DeploymentStats struct {
TotalDeployments int `json:"total_deployments"`
SuccessfulDeployments int `json:"successful_deployments"`
FailedDeployments int `json:"failed_deployments"`
AverageDuration time.Duration `json:"average_duration"`
DeploymentsByStatus map[string]int `json:"deployments_by_status"`
DeploymentsByEnv map[string]int `json:"deployments_by_env"`
DeploymentsByDay map[string]int `json:"deployments_by_day"`
RecentActivity []DeploymentRecord `json:"recent_activity"`
TopServices []ServiceDeploymentStats `json:"top_services"`
TopUsers []UserDeploymentStats `json:"top_users"`
}
type ServiceDeploymentStats struct {
ServiceID string `json:"service_id"`
ServiceName string `json:"service_name"`
DeploymentCount int `json:"deployment_count"`
SuccessCount int `json:"success_count"`
FailureCount int `json:"failure_count"`
SuccessRate float64 `json:"success_rate"`
AverageDuration time.Duration `json:"average_duration"`
LastDeployment time.Time `json:"last_deployment"`
}
type UserDeploymentStats struct {
User string `json:"user"`
DeploymentCount int `json:"deployment_count"`
SuccessCount int `json:"success_count"`
FailureCount int `json:"failure_count"`
SuccessRate float64 `json:"success_rate"`
AverageDuration time.Duration `json:"average_duration"`
LastDeployment time.Time `json:"last_deployment"`
}
func NewHistoryManager(storagePath string) *HistoryManager {
return &HistoryManager{
storagePath: storagePath,
deployments: make(map[string]*DeploymentRecord),
}
}
// RecordDeployment records a deployment in history
func (hm *HistoryManager) RecordDeployment(deployment *Deployment) error {
hm.mu.Lock()
defer hm.mu.Unlock()
record := hm.convertToRecord(deployment)
hm.deployments[record.ID] = record
// Save to storage
return hm.saveDeployment(record)
}
// GetDeployment gets a deployment record by ID
func (hm *HistoryManager) GetDeployment(id string) (*DeploymentRecord, error) {
hm.mu.RLock()
defer hm.mu.RUnlock()
record, exists := hm.deployments[id]
if !exists {
return nil, fmt.Errorf("deployment not found: %s", id)
}
return record, nil
}
// ListDeployments lists deployments with filtering
func (hm *HistoryManager) ListDeployments(filter DeploymentFilter) ([]*DeploymentRecord, error) {
hm.mu.RLock()
defer hm.mu.RUnlock()
var deployments []*DeploymentRecord
for _, record := range hm.deployments {
if hm.matchesFilter(record, filter) {
deployments = append(deployments, record)
}
}
// Sort deployments
hm.sortDeployments(deployments, filter.SortBy, filter.SortOrder)
// Apply pagination
if filter.Limit > 0 {
start := filter.Offset
if start >= len(deployments) {
return []*DeploymentRecord{}, nil
}
end := start + filter.Limit
if end > len(deployments) {
end = len(deployments)
}
deployments = deployments[start:end]
}
return deployments, nil
}
// RollbackDeployment creates a rollback deployment
func (hm *HistoryManager) RollbackDeployment(ctx context.Context, deploymentID, reason string, userID string) (*DeploymentRecord, error) {
hm.mu.RLock()
originalDeployment, exists := hm.deployments[deploymentID]
hm.mu.RUnlock()
if !exists {
return nil, fmt.Errorf("deployment not found: %s", deploymentID)
}
// Create rollback deployment record
rollbackRecord := &DeploymentRecord{
ID: generateDeploymentID(),
ProjectID: originalDeployment.ProjectID,
ServiceID: originalDeployment.ServiceID,
Environment: originalDeployment.Environment,
Status: "pending",
ImageName: originalDeployment.ImageName,
ImageTag: originalDeployment.ImageTag,
Config: originalDeployment.Config,
CreatedAt: time.Now(),
Metadata: map[string]string{
"rollback_from": deploymentID,
"rollback_reason": reason,
},
Trigger: TriggerRecord{
Type: "rollback",
Source: "deployment_history",
User: userID,
Data: map[string]string{
"original_deployment": deploymentID,
"reason": reason,
},
Timestamp: time.Now(),
},
RollbackFrom: &deploymentID,
Tags: append(originalDeployment.Tags, "rollback"),
}
// Record the rollback
err := hm.RecordDeployment(&Deployment{
ID: rollbackRecord.ID,
ProjectID: rollbackRecord.ProjectID,
ServiceID: rollbackRecord.ServiceID,
Environment: rollbackRecord.Environment,
Status: rollbackRecord.Status,
ImageName: rollbackRecord.ImageName,
ImageTag: rollbackRecord.ImageTag,
Config: rollbackRecord.Config,
CreatedAt: rollbackRecord.CreatedAt,
Metadata: rollbackRecord.Metadata,
})
if err != nil {
return nil, fmt.Errorf("failed to record rollback: %w", err)
}
// Update original deployment to track rollbacks
hm.mu.Lock()
if original, exists := hm.deployments[deploymentID]; exists {
original.Rollbacks = append(original.Rollbacks, rollbackRecord.ID)
hm.saveDeployment(original)
}
hm.mu.Unlock()
return rollbackRecord, nil
}
// GetDeploymentHistory gets the deployment history for a service
func (hm *HistoryManager) GetDeploymentHistory(serviceID, environment string, limit int) ([]*DeploymentRecord, error) {
filter := DeploymentFilter{
ServiceID: serviceID,
Environment: environment,
Limit: limit,
SortBy: "created_at",
SortOrder: "desc",
}
return hm.ListDeployments(filter)
}
// GetDeploymentStats gets deployment statistics
func (hm *HistoryManager) GetDeploymentStats(projectID string) (*DeploymentStats, error) {
hm.mu.RLock()
defer hm.mu.RUnlock()
stats := &DeploymentStats{
DeploymentsByStatus: make(map[string]int),
DeploymentsByEnv: make(map[string]int),
DeploymentsByDay: make(map[string]int),
}
var totalDuration time.Duration
var successfulDeployments int
for _, record := range hm.deployments {
if projectID != "" && record.ProjectID != projectID {
continue
}
stats.TotalDeployments++
// Count by status
stats.DeploymentsByStatus[record.Status]++
// Count by environment
stats.DeploymentsByEnv[record.Environment]++
// Count by day
day := record.CreatedAt.Format("2006-01-02")
stats.DeploymentsByDay[day]++
// Calculate success metrics
if record.Status == "running" || record.Status == "completed" {
successfulDeployments++
stats.SuccessfulDeployments++
} else if record.Status == "failed" {
stats.FailedDeployments++
}
// Calculate duration
if record.Duration > 0 {
totalDuration += record.Duration
}
}
// Calculate average duration
if stats.TotalDeployments > 0 {
stats.AverageDuration = totalDuration / time.Duration(stats.TotalDeployments)
}
// Get recent activity
stats.RecentActivity = hm.getRecentActivity(projectID, 10)
// Get top services and users
stats.TopServices = hm.getTopServices(projectID, 5)
stats.TopUsers = hm.getTopUsers(projectID, 5)
return stats, nil
}
// DeleteDeployment removes a deployment from history
func (hm *HistoryManager) DeleteDeployment(id string) error {
hm.mu.Lock()
defer hm.mu.Unlock()
if _, exists := hm.deployments[id]; !exists {
return fmt.Errorf("deployment not found: %s", id)
}
delete(hm.deployments, id)
// Remove from storage
return hm.deleteDeploymentFile(id)
}
// convertToRecord converts a Deployment to DeploymentRecord
func (hm *HistoryManager) convertToRecord(deployment *Deployment) *DeploymentRecord {
record := &DeploymentRecord{
ID: deployment.ID,
ProjectID: deployment.ProjectID,
ServiceID: deployment.ServiceID,
Environment: deployment.Environment,
Status: deployment.Status,
ImageName: deployment.ImageName,
ImageTag: deployment.ImageTag,
Config: deployment.Config,
CreatedAt: deployment.CreatedAt,
StartedAt: deployment.StartedAt,
CompletedAt: deployment.CompletedAt,
BuildLog: deployment.BuildLog,
DeployLog: deployment.DeployLog,
Error: deployment.Error,
Metadata: deployment.Metadata,
Tags: []string{},
Annotations: make(map[string]interface{}),
}
// Calculate duration
if deployment.StartedAt != nil && deployment.CompletedAt != nil {
record.Duration = deployment.CompletedAt.Sub(*deployment.StartedAt)
}
// Convert containers
for _, container := range deployment.Containers {
containerRecord := ContainerRecord{
ID: container.ID,
Name: container.Name,
Status: container.Status,
CreatedAt: container.CreatedAt,
StartedAt: container.StartedAt,
Resources: ResourceRecord{
CPUPercent: container.Resources.CPUPercent,
MemoryUsage: container.Resources.MemoryUsage,
MemoryLimit: container.Resources.MemoryLimit,
NetworkRx: container.Resources.NetworkRx,
NetworkTx: container.Resources.NetworkTx,
},
}
if container.Health != nil {
containerRecord.Health = &HealthRecord{
Status: container.Health.Status,
FailingStreak: container.Health.FailingStreak,
LastCheck: container.Health.LastCheck,
}
}
record.Containers = append(record.Containers, containerRecord)
}
return record
}
// matchesFilter checks if a deployment record matches the filter
func (hm *HistoryManager) matchesFilter(record *DeploymentRecord, filter DeploymentFilter) bool {
if filter.ProjectID != "" && record.ProjectID != filter.ProjectID {
return false
}
if filter.ServiceID != "" && record.ServiceID != filter.ServiceID {
return false
}
if filter.Environment != "" && record.Environment != filter.Environment {
return false
}
if filter.Status != "" && record.Status != filter.Status {
return false
}
if filter.TriggerType != "" && record.Trigger.Type != filter.TriggerType {
return false
}
if filter.User != "" && record.Trigger.User != filter.User {
return false
}
if !filter.From.IsZero() && record.CreatedAt.Before(filter.From) {
return false
}
if !filter.To.IsZero() && record.CreatedAt.After(filter.To) {
return false
}
if len(filter.Tags) > 0 {
hasTag := false
for _, tag := range filter.Tags {
for _, recordTag := range record.Tags {
if recordTag == tag {
hasTag = true
break
}
}
if hasTag {
break
}
}
if !hasTag {
return false
}
}
return true
}
// sortDeployments sorts deployments based on the specified criteria
func (hm *HistoryManager) sortDeployments(deployments []*DeploymentRecord, sortBy, sortOrder string) {
if sortBy == "" {
sortBy = "created_at"
}
if sortOrder == "" {
sortOrder = "desc"
}
sort.Slice(deployments, func(i, j int) bool {
var less bool
switch sortBy {
case "created_at":
less = deployments[i].CreatedAt.Before(deployments[j].CreatedAt)
case "started_at":
if deployments[i].StartedAt == nil {
less = true
} else if deployments[j].StartedAt == nil {
less = false
} else {
less = deployments[i].StartedAt.Before(*deployments[j].StartedAt)
}
case "completed_at":
if deployments[i].CompletedAt == nil {
less = true
} else if deployments[j].CompletedAt == nil {
less = false
} else {
less = deployments[i].CompletedAt.Before(*deployments[j].CompletedAt)
}
case "duration":
less = deployments[i].Duration < deployments[j].Duration
default:
less = deployments[i].ID < deployments[j].ID
}
if sortOrder == "desc" {
return !less
}
return less
})
}
// getRecentActivity gets recent deployment activity
func (hm *HistoryManager) getRecentActivity(projectID string, limit int) []DeploymentRecord {
var deployments []DeploymentRecord
for _, record := range hm.deployments {
if projectID != "" && record.ProjectID != projectID {
continue
}
deployments = append(deployments, *record)
}
// Sort by created_at desc
sort.Slice(deployments, func(i, j int) bool {
return deployments[i].CreatedAt.After(deployments[j].CreatedAt)
})
if len(deployments) > limit {
deployments = deployments[:limit]
}
return deployments
}
// getTopServices gets top services by deployment count
func (hm *HistoryManager) getTopServices(projectID string, limit int) []ServiceDeploymentStats {
serviceStats := make(map[string]*ServiceDeploymentStats)
for _, record := range hm.deployments {
if projectID != "" && record.ProjectID != projectID {
continue
}
stats, exists := serviceStats[record.ServiceID]
if !exists {
stats = &ServiceDeploymentStats{
ServiceID: record.ServiceID,
}
serviceStats[record.ServiceID] = stats
}
stats.DeploymentCount++
stats.LastDeployment = record.CreatedAt
if record.Status == "running" || record.Status == "completed" {
stats.SuccessCount++
} else if record.Status == "failed" {
stats.FailureCount++
}
if record.Duration > 0 {
// Simple moving average for duration
if stats.AverageDuration == 0 {
stats.AverageDuration = record.Duration
} else {
stats.AverageDuration = (stats.AverageDuration + record.Duration) / 2
}
}
}
// Calculate success rates
for _, stats := range serviceStats {
if stats.DeploymentCount > 0 {
stats.SuccessRate = float64(stats.SuccessCount) / float64(stats.DeploymentCount) * 100
}
}
// Convert to slice and sort
var topServices []ServiceDeploymentStats
for _, stats := range serviceStats {
topServices = append(topServices, *stats)
}
sort.Slice(topServices, func(i, j int) bool {
return topServices[i].DeploymentCount > topServices[j].DeploymentCount
})
if len(topServices) > limit {
topServices = topServices[:limit]
}
return topServices
}
// getTopUsers gets top users by deployment count
func (hm *HistoryManager) getTopUsers(projectID string, limit int) []UserDeploymentStats {
userStats := make(map[string]*UserDeploymentStats)
for _, record := range hm.deployments {
if projectID != "" && record.ProjectID != projectID {
continue
}
user := record.Trigger.User
if user == "" {
continue
}
stats, exists := userStats[user]
if !exists {
stats = &UserDeploymentStats{
User: user,
}
userStats[user] = stats
}
stats.DeploymentCount++
stats.LastDeployment = record.CreatedAt
if record.Status == "running" || record.Status == "completed" {
stats.SuccessCount++
} else if record.Status == "failed" {
stats.FailureCount++
}
if record.Duration > 0 {
if stats.AverageDuration == 0 {
stats.AverageDuration = record.Duration
} else {
stats.AverageDuration = (stats.AverageDuration + record.Duration) / 2
}
}
}
// Calculate success rates
for _, stats := range userStats {
if stats.DeploymentCount > 0 {
stats.SuccessRate = float64(stats.SuccessCount) / float64(stats.DeploymentCount) * 100
}
}
// Convert to slice and sort
var topUsers []UserDeploymentStats
for _, stats := range userStats {
topUsers = append(topUsers, *stats)
}
sort.Slice(topUsers, func(i, j int) bool {
return topUsers[i].DeploymentCount > topUsers[j].DeploymentCount
})
if len(topUsers) > limit {
topUsers = topUsers[:limit]
}
return topUsers
}
// saveDeployment saves a deployment record to storage
func (hm *HistoryManager) saveDeployment(record *DeploymentRecord) error {
if err := os.MkdirAll(hm.storagePath, 0755); err != nil {
return err
}
filename := filepath.Join(hm.storagePath, record.ID+".json")
data, err := json.MarshalIndent(record, "", " ")
if err != nil {
return err
}
return os.WriteFile(filename, data, 0644)
}
// deleteDeploymentFile removes a deployment file from storage
func (hm *HistoryManager) deleteDeploymentFile(id string) error {
filename := filepath.Join(hm.storagePath, id+".json")
return os.Remove(filename)
}
// loadDeployments loads all deployments from storage
func (hm *HistoryManager) loadDeployments() error {
if _, err := os.Stat(hm.storagePath); os.IsNotExist(err) {
return nil // Storage doesn't exist yet
}
files, err := os.ReadDir(hm.storagePath)
if err != nil {
return err
}
for _, file := range files {
if file.IsDir() || !strings.HasSuffix(file.Name(), ".json") {
continue
}
filename := filepath.Join(hm.storagePath, file.Name())
data, err := os.ReadFile(filename)
if err != nil {
continue // Skip files that can't be read
}
var record DeploymentRecord
if err := json.Unmarshal(data, &record); err != nil {
continue // Skip invalid files
}
hm.deployments[record.ID] = &record
}
return nil
}