mirror of
https://git.vectorsigma.ru/public/atlantis.git
synced 2026-08-01 23:58:45 +00:00
358 lines
11 KiB
Go
358 lines
11 KiB
Go
package events
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/runatlantis/atlantis/server/core/locking"
|
|
"github.com/runatlantis/atlantis/server/events/models"
|
|
"github.com/runatlantis/atlantis/server/events/vcs"
|
|
"github.com/runatlantis/atlantis/server/logging"
|
|
)
|
|
|
|
// EnhancedLockingSystem provides a robust locking mechanism that addresses race conditions
|
|
type EnhancedLockingSystem struct {
|
|
locker locking.Locker
|
|
backend locking.Backend
|
|
vcsClient vcs.Client
|
|
logger logging.SimpleLogging
|
|
queueManager models.PlanQueueManager
|
|
enableQueue bool
|
|
enableRetry bool
|
|
maxRetryAttempts int
|
|
retryDelay time.Duration
|
|
|
|
// In-memory locks to prevent race conditions
|
|
memoryLocks map[string]*MemoryLock
|
|
memoryLocksMutex sync.RWMutex
|
|
|
|
// Working directory protection
|
|
workingDirLocks map[string]*WorkingDirLock
|
|
workingDirMutex sync.RWMutex
|
|
}
|
|
|
|
// MemoryLock represents an in-memory lock to prevent race conditions
|
|
type MemoryLock struct {
|
|
Project models.Project
|
|
Workspace string
|
|
Pull models.PullRequest
|
|
User models.User
|
|
Time time.Time
|
|
Context context.Context
|
|
Cancel context.CancelFunc
|
|
}
|
|
|
|
// WorkingDirLock protects working directories from premature deletion
|
|
type WorkingDirLock struct {
|
|
RepoFullName string
|
|
PullNum int
|
|
Workspace string
|
|
Time time.Time
|
|
Context context.Context
|
|
Cancel context.CancelFunc
|
|
}
|
|
|
|
// NewEnhancedLockingSystem creates a new enhanced locking system
|
|
func NewEnhancedLockingSystem(
|
|
locker locking.Locker,
|
|
backend locking.Backend,
|
|
vcsClient vcs.Client,
|
|
logger logging.SimpleLogging,
|
|
queueManager models.PlanQueueManager,
|
|
enableQueue bool,
|
|
enableRetry bool,
|
|
maxRetryAttempts int,
|
|
retryDelay int,
|
|
) *EnhancedLockingSystem {
|
|
return &EnhancedLockingSystem{
|
|
locker: locker,
|
|
backend: backend,
|
|
vcsClient: vcsClient,
|
|
logger: logger,
|
|
queueManager: queueManager,
|
|
enableQueue: enableQueue,
|
|
enableRetry: enableRetry,
|
|
maxRetryAttempts: maxRetryAttempts,
|
|
retryDelay: time.Duration(retryDelay) * time.Second,
|
|
memoryLocks: make(map[string]*MemoryLock),
|
|
workingDirLocks: make(map[string]*WorkingDirLock),
|
|
}
|
|
}
|
|
|
|
// TryLockWithRetry attempts to acquire a lock with retry logic
|
|
func (e *EnhancedLockingSystem) TryLockWithRetry(
|
|
project models.Project,
|
|
workspace string,
|
|
pull models.PullRequest,
|
|
user models.User,
|
|
) (*TryLockResponse, error) {
|
|
|
|
// First, try to acquire memory lock to prevent race conditions
|
|
memoryLockKey := e.memoryLockKey(project, workspace)
|
|
if !e.tryAcquireMemoryLock(memoryLockKey, project, workspace, pull, user) {
|
|
return &TryLockResponse{
|
|
LockAcquired: false,
|
|
LockFailureReason: "Another operation is in progress for this project/workspace",
|
|
}, nil
|
|
}
|
|
|
|
// Try to acquire the actual lock
|
|
var lastErr error
|
|
for attempt := 1; attempt <= e.maxRetryAttempts; attempt++ {
|
|
lockAttempt, err := e.locker.TryLock(project, workspace, pull, user)
|
|
if err != nil {
|
|
lastErr = err
|
|
e.logger.Warn("Lock attempt %d failed: %s", attempt, err)
|
|
continue
|
|
}
|
|
|
|
if lockAttempt.LockAcquired {
|
|
e.logger.Info("Successfully acquired lock on attempt %d", attempt)
|
|
return &TryLockResponse{
|
|
LockAcquired: true,
|
|
UnlockFn: func() error {
|
|
// Release memory lock when unlocking
|
|
e.releaseMemoryLock(memoryLockKey)
|
|
return e.unlockWithCleanup(project, workspace, lockAttempt.LockKey)
|
|
},
|
|
LockKey: lockAttempt.LockKey,
|
|
}, nil
|
|
}
|
|
|
|
// If lock not acquired and it's not our own lock
|
|
if lockAttempt.CurrLock.Pull.Num != pull.Num {
|
|
if e.enableQueue {
|
|
return e.handleQueueLogic(project, workspace, pull, user, lockAttempt)
|
|
}
|
|
|
|
// Return failure with retry information
|
|
if e.enableRetry && attempt < e.maxRetryAttempts {
|
|
e.logger.Info("Lock busy, retrying in %v (attempt %d/%d)", e.retryDelay, attempt, e.maxRetryAttempts)
|
|
time.Sleep(e.retryDelay)
|
|
continue
|
|
}
|
|
|
|
link, err := e.vcsClient.MarkdownPullLink(lockAttempt.CurrLock.Pull)
|
|
if err != nil {
|
|
// Release memory lock on error
|
|
e.releaseMemoryLock(memoryLockKey)
|
|
return nil, err
|
|
}
|
|
|
|
failureMsg := fmt.Sprintf(
|
|
"This project is currently locked by an unapplied plan from pull %s. To continue, delete the lock from %s or apply that plan and merge the pull request.\n\n"+
|
|
"Once the lock is released, comment `atlantis plan` here to re-plan.",
|
|
link, link)
|
|
|
|
// Release memory lock on failure
|
|
e.releaseMemoryLock(memoryLockKey)
|
|
return &TryLockResponse{
|
|
LockAcquired: false,
|
|
LockFailureReason: failureMsg,
|
|
}, nil
|
|
}
|
|
|
|
// If it's our own lock, return success
|
|
return &TryLockResponse{
|
|
LockAcquired: true,
|
|
UnlockFn: func() error {
|
|
// Release memory lock when unlocking
|
|
e.releaseMemoryLock(memoryLockKey)
|
|
return e.unlockWithCleanup(project, workspace, lockAttempt.LockKey)
|
|
},
|
|
LockKey: lockAttempt.LockKey,
|
|
}, nil
|
|
}
|
|
|
|
// Release memory lock on final failure
|
|
e.releaseMemoryLock(memoryLockKey)
|
|
return nil, fmt.Errorf("failed to acquire lock after %d attempts: %w", e.maxRetryAttempts, lastErr)
|
|
}
|
|
|
|
// handleQueueLogic handles the queue logic when lock is not available
|
|
func (e *EnhancedLockingSystem) handleQueueLogic(
|
|
project models.Project,
|
|
workspace string,
|
|
pull models.PullRequest,
|
|
user models.User,
|
|
lockAttempt locking.TryLockResponse,
|
|
) (*TryLockResponse, error) {
|
|
|
|
// Check if we're already in the queue
|
|
inQueue, err := e.queueManager.IsInQueue(project, workspace, pull.Num)
|
|
if err != nil {
|
|
e.logger.Warn("Error checking queue status: %s", err)
|
|
}
|
|
|
|
if !inQueue {
|
|
// Add to queue
|
|
queueEntry := models.PlanQueueEntry{
|
|
ID: fmt.Sprintf("%s-%s-%d", project.String(), workspace, pull.Num),
|
|
Project: project,
|
|
Workspace: workspace,
|
|
Pull: pull,
|
|
User: user,
|
|
Time: time.Now(),
|
|
Priority: 0,
|
|
Command: "plan",
|
|
}
|
|
|
|
if err := e.queueManager.AddToQueue(queueEntry); err != nil {
|
|
e.logger.Warn("Failed to add to queue: %s", err)
|
|
} else {
|
|
e.logger.Info("Added PR %d to plan queue for project %s, workspace %s", pull.Num, project.String(), workspace)
|
|
}
|
|
}
|
|
|
|
// Create queue-aware failure message
|
|
link, err := e.vcsClient.MarkdownPullLink(lockAttempt.CurrLock.Pull)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
failureMsg := fmt.Sprintf(
|
|
"This project is currently locked by an unapplied plan from pull %s. To continue, delete the lock from %s or apply that plan and merge the pull request.\n\n"+
|
|
"Your plan request has been added to the queue. You will be notified when it's your turn to plan.\n\n"+
|
|
"Once the lock is released, comment `atlantis plan` here to re-plan.",
|
|
link, link)
|
|
|
|
return &TryLockResponse{
|
|
LockAcquired: false,
|
|
LockFailureReason: failureMsg,
|
|
}, nil
|
|
}
|
|
|
|
// unlockWithCleanup unlocks and handles cleanup
|
|
func (e *EnhancedLockingSystem) unlockWithCleanup(project models.Project, workspace, lockKey string) error {
|
|
// Unlock the actual lock
|
|
_, err := e.locker.Unlock(lockKey)
|
|
if err != nil {
|
|
return fmt.Errorf("unlocking: %w", err)
|
|
}
|
|
|
|
// Try to transfer lock to next person in queue
|
|
if e.enableQueue && e.queueManager != nil {
|
|
if transferErr := e.queueManager.TransferLock(project, workspace); transferErr != nil {
|
|
e.logger.Warn("Failed to transfer lock to next person in queue: %s", transferErr)
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// ProtectWorkingDir protects a working directory from deletion
|
|
func (e *EnhancedLockingSystem) ProtectWorkingDir(repoFullName string, pullNum int, workspace string) context.CancelFunc {
|
|
e.workingDirMutex.Lock()
|
|
defer e.workingDirMutex.Unlock()
|
|
|
|
key := fmt.Sprintf("%s:%d:%s", repoFullName, pullNum, workspace)
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
|
|
e.workingDirLocks[key] = &WorkingDirLock{
|
|
RepoFullName: repoFullName,
|
|
PullNum: pullNum,
|
|
Workspace: workspace,
|
|
Time: time.Now(),
|
|
Context: ctx,
|
|
Cancel: cancel,
|
|
}
|
|
|
|
e.logger.Debug("Protected working directory: %s", key)
|
|
return cancel
|
|
}
|
|
|
|
// IsWorkingDirProtected checks if a working directory is protected
|
|
func (e *EnhancedLockingSystem) IsWorkingDirProtected(repoFullName string, pullNum int, workspace string) bool {
|
|
e.workingDirMutex.RLock()
|
|
defer e.workingDirMutex.RUnlock()
|
|
|
|
key := fmt.Sprintf("%s:%d:%s", repoFullName, pullNum, workspace)
|
|
_, exists := e.workingDirLocks[key]
|
|
return exists
|
|
}
|
|
|
|
// CleanupWorkingDirProtection removes protection for a working directory
|
|
func (e *EnhancedLockingSystem) CleanupWorkingDirProtection(repoFullName string, pullNum int, workspace string) {
|
|
e.workingDirMutex.Lock()
|
|
defer e.workingDirMutex.Unlock()
|
|
|
|
key := fmt.Sprintf("%s:%d:%s", repoFullName, pullNum, workspace)
|
|
if lock, exists := e.workingDirLocks[key]; exists {
|
|
lock.Cancel()
|
|
delete(e.workingDirLocks, key)
|
|
e.logger.Debug("Removed working directory protection: %s", key)
|
|
}
|
|
}
|
|
|
|
// tryAcquireMemoryLock attempts to acquire a memory lock
|
|
func (e *EnhancedLockingSystem) tryAcquireMemoryLock(key string, project models.Project, workspace string, pull models.PullRequest, user models.User) bool {
|
|
e.memoryLocksMutex.Lock()
|
|
defer e.memoryLocksMutex.Unlock()
|
|
|
|
if _, exists := e.memoryLocks[key]; exists {
|
|
return false
|
|
}
|
|
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
e.memoryLocks[key] = &MemoryLock{
|
|
Project: project,
|
|
Workspace: workspace,
|
|
Pull: pull,
|
|
User: user,
|
|
Time: time.Now(),
|
|
Context: ctx,
|
|
Cancel: cancel,
|
|
}
|
|
|
|
return true
|
|
}
|
|
|
|
// releaseMemoryLock releases a memory lock
|
|
func (e *EnhancedLockingSystem) releaseMemoryLock(key string) {
|
|
e.memoryLocksMutex.Lock()
|
|
defer e.memoryLocksMutex.Unlock()
|
|
|
|
if lock, exists := e.memoryLocks[key]; exists {
|
|
lock.Cancel()
|
|
delete(e.memoryLocks, key)
|
|
}
|
|
}
|
|
|
|
// memoryLockKey generates a key for memory locks
|
|
func (e *EnhancedLockingSystem) memoryLockKey(project models.Project, workspace string) string {
|
|
return fmt.Sprintf("memory:%s:%s:%s", project.RepoFullName, project.Path, workspace)
|
|
}
|
|
|
|
// CleanupAllLocks cleans up all locks for a pull request
|
|
func (e *EnhancedLockingSystem) CleanupAllLocks(repoFullName string, pullNum int) error {
|
|
// Clean up queue entries
|
|
if e.enableQueue && e.queueManager != nil {
|
|
if err := e.queueManager.CleanupQueue(repoFullName, pullNum); err != nil {
|
|
e.logger.Warn("Failed to cleanup queue: %s", err)
|
|
}
|
|
}
|
|
|
|
// Clean up working directory protection
|
|
e.workingDirMutex.Lock()
|
|
defer e.workingDirMutex.Unlock()
|
|
|
|
keysToRemove := []string{}
|
|
for key, lock := range e.workingDirLocks {
|
|
if lock.RepoFullName == repoFullName && lock.PullNum == pullNum {
|
|
keysToRemove = append(keysToRemove, key)
|
|
}
|
|
}
|
|
|
|
for _, key := range keysToRemove {
|
|
if lock, exists := e.workingDirLocks[key]; exists {
|
|
lock.Cancel()
|
|
delete(e.workingDirLocks, key)
|
|
e.logger.Debug("Cleaned up working directory protection: %s", key)
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|