fix: cancel in-progress deploy when webhook triggers new deploy (closes #38)
When a webhook-triggered deploy starts for an app that already has a deploy in progress, the existing deploy is now cancelled via context cancellation before the new deploy begins. This prevents silently lost webhook deploys. Changes: - Add per-app active deploy tracking with cancel func and done channel - Deploy() accepts cancelExisting param: true for webhook, false for manual - Cancelled deployments are marked with new 'cancelled' status - Add ErrDeployCancelled sentinel error - Add DeploymentStatusCancelled model constant - Add comprehensive tests for cancellation mechanics
This commit is contained in:
133
internal/service/deploy/deploy_cancel_test.go
Normal file
133
internal/service/deploy/deploy_cancel_test.go
Normal file
@@ -0,0 +1,133 @@
|
||||
package deploy_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log/slog"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
|
||||
"git.eeqj.de/sneak/upaas/internal/service/deploy"
|
||||
)
|
||||
|
||||
func TestCancelActiveDeploy_NoExisting(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
svc := deploy.NewTestService(slog.Default())
|
||||
|
||||
// Should not panic or block when no active deploy exists
|
||||
svc.CancelActiveDeploy("nonexistent-app")
|
||||
}
|
||||
|
||||
func TestCancelActiveDeploy_CancelsAndWaits(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
svc := deploy.NewTestService(slog.Default())
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
done := make(chan struct{})
|
||||
|
||||
svc.RegisterActiveDeploy("app-1", cancel, done)
|
||||
|
||||
// Simulate a running deploy that respects cancellation
|
||||
var deployFinished bool
|
||||
|
||||
go func() {
|
||||
<-ctx.Done()
|
||||
|
||||
deployFinished = true
|
||||
|
||||
close(done)
|
||||
}()
|
||||
|
||||
svc.CancelActiveDeploy("app-1")
|
||||
assert.True(t, deployFinished, "deploy should have finished after cancellation")
|
||||
}
|
||||
|
||||
func TestCancelActiveDeploy_BlocksUntilDone(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
svc := deploy.NewTestService(slog.Default())
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
done := make(chan struct{})
|
||||
|
||||
svc.RegisterActiveDeploy("app-2", cancel, done)
|
||||
|
||||
// Simulate slow cleanup after cancellation
|
||||
go func() {
|
||||
<-ctx.Done()
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
close(done)
|
||||
}()
|
||||
|
||||
start := time.Now()
|
||||
|
||||
svc.CancelActiveDeploy("app-2")
|
||||
|
||||
elapsed := time.Since(start)
|
||||
|
||||
assert.GreaterOrEqual(t, elapsed, 50*time.Millisecond,
|
||||
"cancelActiveDeploy should block until the deploy finishes")
|
||||
}
|
||||
|
||||
func TestTryLockApp_PreventsConcurrent(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
svc := deploy.NewTestService(slog.Default())
|
||||
|
||||
assert.True(t, svc.TryLockApp("app-1"), "first lock should succeed")
|
||||
assert.False(t, svc.TryLockApp("app-1"), "second lock should fail")
|
||||
|
||||
svc.UnlockApp("app-1")
|
||||
|
||||
assert.True(t, svc.TryLockApp("app-1"), "lock after unlock should succeed")
|
||||
|
||||
svc.UnlockApp("app-1")
|
||||
}
|
||||
|
||||
func TestCancelActiveDeploy_AllowsNewDeploy(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
svc := deploy.NewTestService(slog.Default())
|
||||
|
||||
// Simulate an active deploy holding the lock
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
done := make(chan struct{})
|
||||
|
||||
svc.RegisterActiveDeploy("app-3", cancel, done)
|
||||
|
||||
// Lock the app as if a deploy is in progress
|
||||
assert.True(t, svc.TryLockApp("app-3"))
|
||||
|
||||
// Simulate deploy goroutine: release lock on cancellation
|
||||
var mu sync.Mutex
|
||||
|
||||
released := false
|
||||
|
||||
go func() {
|
||||
<-ctx.Done()
|
||||
|
||||
svc.UnlockApp("app-3")
|
||||
|
||||
mu.Lock()
|
||||
released = true
|
||||
mu.Unlock()
|
||||
|
||||
close(done)
|
||||
}()
|
||||
|
||||
// Cancel should cause the old deploy to release its lock
|
||||
svc.CancelActiveDeploy("app-3")
|
||||
|
||||
mu.Lock()
|
||||
assert.True(t, released)
|
||||
mu.Unlock()
|
||||
|
||||
// Now a new deploy should be able to acquire the lock
|
||||
assert.True(t, svc.TryLockApp("app-3"), "should be able to lock after cancellation")
|
||||
|
||||
svc.UnlockApp("app-3")
|
||||
}
|
||||
Reference in New Issue
Block a user