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
173 lines
4.4 KiB
Go
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())
|
|
}
|