upaas/internal/service/webhook/webhook.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

213 lines
5.5 KiB
Go

// Package webhook provides webhook handling services.
package webhook
import (
"context"
"database/sql"
"encoding/json"
"errors"
"fmt"
"log/slog"
"time"
"go.uber.org/fx"
"git.eeqj.de/sneak/upaas/internal/database"
"git.eeqj.de/sneak/upaas/internal/logger"
"git.eeqj.de/sneak/upaas/internal/models"
"git.eeqj.de/sneak/upaas/internal/service/deploy"
)
// ServiceParams contains dependencies for Service.
type ServiceParams struct {
fx.In
Logger *logger.Logger
Database *database.Database
Deploy *deploy.Service
}
// Service provides webhook handling functionality.
type Service struct {
log *slog.Logger
db *database.Database
deploy *deploy.Service
params *ServiceParams
}
// New creates a new webhook Service.
func New(_ fx.Lifecycle, params ServiceParams) (*Service, error) {
return &Service{
log: params.Logger.Get(),
db: params.Database,
deploy: params.Deploy,
params: &params,
}, nil
}
// GiteaPushPayload represents a Gitea push webhook payload.
//
//nolint:tagliatelle // Field names match Gitea API (snake_case)
type GiteaPushPayload struct {
Ref string `json:"ref"`
Before string `json:"before"`
After string `json:"after"`
CompareURL string `json:"compare_url"`
Repository struct {
FullName string `json:"full_name"`
CloneURL string `json:"clone_url"`
SSHURL string `json:"ssh_url"`
HTMLURL string `json:"html_url"`
} `json:"repository"`
Pusher struct {
Username string `json:"username"`
Email string `json:"email"`
} `json:"pusher"`
Commits []struct {
ID string `json:"id"`
URL string `json:"url"`
Message string `json:"message"`
Author struct {
Name string `json:"name"`
Email string `json:"email"`
} `json:"author"`
} `json:"commits"`
}
// HandleWebhook processes a webhook request.
func (svc *Service) HandleWebhook(
ctx context.Context,
app *models.App,
eventType string,
payload []byte,
) error {
svc.log.Info("processing webhook", "app", app.Name, "event", eventType)
// Parse payload
var pushPayload GiteaPushPayload
unmarshalErr := json.Unmarshal(payload, &pushPayload)
if unmarshalErr != nil {
svc.log.Warn("failed to parse webhook payload", "error", unmarshalErr)
// Continue anyway to log the event
}
// Extract branch from ref
branch := extractBranch(pushPayload.Ref)
commitSHA := pushPayload.After
commitURL := extractCommitURL(pushPayload)
// Check if branch matches
matched := branch == app.Branch
// Create webhook event record
event := models.NewWebhookEvent(svc.db)
event.AppID = app.ID
event.EventType = eventType
event.Branch = branch
event.CommitSHA = sql.NullString{String: commitSHA, Valid: commitSHA != ""}
event.CommitURL = sql.NullString{String: commitURL, Valid: commitURL != ""}
event.Payload = sql.NullString{String: string(payload), Valid: true}
event.Matched = matched
event.Processed = false
saveErr := event.Save(ctx)
if saveErr != nil {
return fmt.Errorf("failed to save webhook event: %w", saveErr)
}
svc.log.Info("webhook event recorded",
"app", app.Name,
"branch", branch,
"matched", matched,
"commit", commitSHA,
)
// If branch matches, trigger deployment
if matched {
svc.triggerDeployment(ctx, app, event)
}
return nil
}
// cancelRetryDelay is the time to wait after cancelling a deploy before retrying.
const cancelRetryDelay = 2 * time.Second
// cancelRetryAttempts is the maximum number of times to retry after cancelling.
const cancelRetryAttempts = 30
func (svc *Service) triggerDeployment(
ctx context.Context,
app *models.App,
event *models.WebhookEvent,
) {
// Capture values for goroutine
eventID := event.ID
appName := app.Name
go func() {
// Use context.WithoutCancel to ensure deployment completes
// even if the HTTP request context is cancelled.
deployCtx := context.WithoutCancel(ctx)
deployErr := svc.deploy.Deploy(deployCtx, app, &eventID)
if deployErr != nil && errors.Is(deployErr, deploy.ErrDeploymentInProgress) {
// Cancel the in-progress deployment and retry
svc.log.Info("cancelling in-progress deployment for new webhook trigger", "app", appName)
svc.deploy.CancelAppDeploy(app.ID)
// Retry until the lock is released by the cancelled deploy
for attempt := range cancelRetryAttempts {
time.Sleep(cancelRetryDelay)
svc.log.Info("retrying deployment after cancel",
"app", appName, "attempt", attempt+1)
deployErr = svc.deploy.Deploy(deployCtx, app, &eventID)
if deployErr == nil || !errors.Is(deployErr, deploy.ErrDeploymentInProgress) {
break
}
}
}
if deployErr != nil {
svc.log.Error("deployment failed", "error", deployErr, "app", appName)
}
// Mark event as processed
event.Processed = true
_ = event.Save(deployCtx)
}()
}
// extractBranch extracts the branch name from a git ref.
func extractBranch(ref string) string {
// refs/heads/main -> main
const prefix = "refs/heads/"
if len(ref) >= len(prefix) && ref[:len(prefix)] == prefix {
return ref[len(prefix):]
}
return ref
}
// extractCommitURL extracts the commit URL from the webhook payload.
// Prefers the URL from the head commit, falls back to constructing from repo URL.
func extractCommitURL(payload GiteaPushPayload) string {
// Try to find the URL from the head commit (matching After SHA)
for _, commit := range payload.Commits {
if commit.ID == payload.After && commit.URL != "" {
return commit.URL
}
}
// Fall back to constructing URL from repo HTML URL
if payload.Repository.HTMLURL != "" && payload.After != "" {
return payload.Repository.HTMLURL + "/commit/" + payload.After
}
return ""
}