upaas/internal/service/deploy/cancel_test.go
user 16640ef88e fix: cancel in-progress deploy on new webhook trigger
When a webhook-triggered deploy starts for an app that already has a deploy
in progress, the new deploy now cancels the existing one via context
cancellation, waits for the lock to be released, and then starts the new
deploy.

Changes:
- Add per-app context cancellation (appCancels sync.Map) to deploy.Service
- Deploy() creates a cancellable context and registers it for the app
- Add CancelAppDeploy() method to cancel an in-progress deploy
- Add ErrDeployCancelled sentinel error for cancelled deploys
- Handle context cancellation in build and deploy phases, marking
  deployments as failed with a clear cancellation message
- Webhook triggerDeployment() now cancels in-progress deploys and retries
  until the lock is released (up to 30 attempts with 2s delay)

fixes #38
2026-02-15 22:13:20 -08:00

173 lines
4.4 KiB
Go

package deploy_test
import (
"context"
"errors"
"sync"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"go.uber.org/fx"
"git.eeqj.de/sneak/upaas/internal/config"
"git.eeqj.de/sneak/upaas/internal/database"
"git.eeqj.de/sneak/upaas/internal/docker"
"git.eeqj.de/sneak/upaas/internal/globals"
"git.eeqj.de/sneak/upaas/internal/logger"
"git.eeqj.de/sneak/upaas/internal/models"
"git.eeqj.de/sneak/upaas/internal/service/deploy"
"git.eeqj.de/sneak/upaas/internal/service/notify"
)
func setupDeployService(t *testing.T) (*deploy.Service, *database.Database) {
t.Helper()
tmpDir := t.TempDir()
globals.SetAppname("upaas-test")
globals.SetVersion("test")
globalsInst, err := globals.New(fx.Lifecycle(nil))
require.NoError(t, err)
loggerInst, err := logger.New(fx.Lifecycle(nil), logger.Params{Globals: globalsInst})
require.NoError(t, err)
cfg := &config.Config{Port: 8080, DataDir: tmpDir, SessionSecret: "test-secret-key-at-least-32-chars"}
dbInst, err := database.New(fx.Lifecycle(nil), database.Params{Logger: loggerInst, Config: cfg})
require.NoError(t, err)
dockerClient, err := docker.New(fx.Lifecycle(nil), docker.Params{Logger: loggerInst, Config: cfg})
require.NoError(t, err)
notifySvc, err := notify.New(fx.Lifecycle(nil), notify.ServiceParams{Logger: loggerInst})
require.NoError(t, err)
svc, err := deploy.New(fx.Lifecycle(nil), deploy.ServiceParams{
Logger: loggerInst, Config: cfg, Database: dbInst, Docker: dockerClient, Notify: notifySvc,
})
require.NoError(t, err)
return svc, dbInst
}
func createDeployTestApp(t *testing.T, dbInst *database.Database) *models.App {
t.Helper()
app := models.NewApp(dbInst)
app.ID = "test-cancel-app"
app.Name = "test-cancel-app"
app.RepoURL = "git@example.com:user/repo.git"
app.Branch = "main"
app.DockerfilePath = "Dockerfile"
app.SSHPrivateKey = "private-key"
app.SSHPublicKey = "public-key"
app.Status = models.AppStatusPending
err := app.Save(context.Background())
require.NoError(t, err)
return app
}
func TestConcurrentDeploysOnSameApp(t *testing.T) {
t.Parallel()
svc, dbInst := setupDeployService(t)
app := createDeployTestApp(t, dbInst)
// Run two deploys concurrently - both will fail (no docker) but we verify
// the lock mechanism works (no panics, no data races)
var wg sync.WaitGroup
results := make([]error, 2)
for i := range 2 {
wg.Add(1)
go func(idx int) {
defer wg.Done()
results[idx] = svc.Deploy(context.Background(), app, nil)
}(i)
}
wg.Wait()
// At least one should have run (and failed due to docker not connected)
// The other either ran too or got ErrDeploymentInProgress
gotInProgress := false
gotOther := false
for _, err := range results {
require.Error(t, err)
if errors.Is(err, deploy.ErrDeploymentInProgress) {
gotInProgress = true
} else {
gotOther = true
}
}
// At least one must have actually attempted the deploy
assert.True(t, gotOther, "at least one deploy should have attempted execution")
// If timing worked out, one should have been rejected
// (but this is racy - both might complete sequentially)
_ = gotInProgress
}
func TestCancelAppDeployReturnsFalseWhenNoDeploy(t *testing.T) {
t.Parallel()
svc, _ := setupDeployService(t)
// No deploy in progress, should return false
cancelled := svc.CancelAppDeploy("nonexistent-app")
assert.False(t, cancelled)
}
func TestCancelAppDeployCancelsInProgressDeploy(t *testing.T) {
t.Parallel()
svc, dbInst := setupDeployService(t)
app := createDeployTestApp(t, dbInst)
deployStarted := make(chan struct{})
deployDone := make(chan error, 1)
// Start a deploy that will hold the lock
go func() {
// Signal when deploy starts (will acquire lock quickly)
close(deployStarted)
deployDone <- svc.Deploy(context.Background(), app, nil)
}()
<-deployStarted
// Give it time to acquire the lock and register the cancel func
time.Sleep(100 * time.Millisecond)
// Cancel should work if deploy is still in progress
_ = svc.CancelAppDeploy(app.ID)
// Wait for deploy to finish
select {
case <-deployDone:
// Deploy finished (either cancelled or failed for other reason)
case <-time.After(5 * time.Second):
t.Fatal("deploy did not finish after cancellation")
}
}
func TestErrDeployCancelledExists(t *testing.T) {
t.Parallel()
// Verify the sentinel error exists
require.Error(t, deploy.ErrDeployCancelled)
assert.Equal(t, "deployment cancelled by newer deploy", deploy.ErrDeployCancelled.Error())
}