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
134 lines
2.8 KiB
Go
134 lines
2.8 KiB
Go
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")
|
|
}
|