Files
upaas/internal/service/webhook/webhook.go
T
clawbot f2e4be5eed
Check / check (pull_request) Successful in 3m33s
Fix flaky t.TempDir cleanup race in webhook tests (closes #198)
HandleWebhook starts a deployment in a detached goroutine that writes
under the app data directory, which is the tests t.TempDir; the tests
slept 100ms and returned, racing Go automatic TempDir cleanup and
intermittently failing with RemoveAll: directory not empty. The webhook
Service now tracks those goroutines in a sync.WaitGroup and exposes
WaitForDeployments; the tests wait on it instead of sleeping. Production
behavior is unchanged apart from making completion observable.

Model: opus-4-8
2026-09-22 12:28:23 +02:00

148 lines
3.7 KiB
Go

// Package webhook provides webhook handling services.
package webhook
import (
"context"
"database/sql"
"fmt"
"log/slog"
"sync"
"go.uber.org/fx"
"sneak.berlin/go/upaas/internal/database"
"sneak.berlin/go/upaas/internal/logger"
"sneak.berlin/go/upaas/internal/models"
"sneak.berlin/go/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
// deployments tracks the deployment goroutines started by
// triggerDeployment so callers can wait for them to finish.
deployments sync.WaitGroup
}
// 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
}
// HandleWebhook processes a webhook request from any supported source
// (Gitea, GitHub, or GitLab). The source parameter determines which
// payload format to use for parsing.
func (svc *Service) HandleWebhook(
ctx context.Context,
app *models.App,
source Source,
eventType string,
payload []byte,
) error {
svc.log.Info("processing webhook",
"app", app.Name,
"source", source.String(),
"event", eventType,
)
// Parse payload into normalized push event
pushEvent, parseErr := ParsePushPayload(source, payload)
if parseErr != nil {
svc.log.Warn("failed to parse webhook payload",
"error", parseErr,
"source", source.String(),
)
// Continue with empty push event to still log the webhook
pushEvent = &PushEvent{Source: source}
}
// Check if branch matches
matched := pushEvent.Branch == app.Branch
// Create webhook event record
event := models.NewWebhookEvent(svc.db)
event.AppID = app.ID
event.EventType = eventType
event.Branch = pushEvent.Branch
event.CommitSHA = sql.NullString{String: pushEvent.After, Valid: pushEvent.After != ""}
event.CommitURL = sql.NullString{
String: pushEvent.CommitURL.String(),
Valid: pushEvent.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,
"source", source.String(),
"branch", pushEvent.Branch,
"matched", matched,
"commit", pushEvent.After,
)
// If branch matches, trigger deployment
if matched {
svc.triggerDeployment(ctx, app, event)
}
return nil
}
// WaitForDeployments blocks until every deployment goroutine started by
// HandleWebhook has finished, including all writes under the data
// directory. It exists so callers and tests can synchronize on async
// deployment completion instead of polling or sleeping.
func (svc *Service) WaitForDeployments() {
svc.deployments.Wait()
}
func (svc *Service) triggerDeployment(
ctx context.Context,
app *models.App,
event *models.WebhookEvent,
) {
// Capture values for goroutine
eventID := event.ID
appName := app.Name
svc.deployments.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, true)
if deployErr != nil {
svc.log.Error("deployment failed", "error", deployErr, "app", appName)
}
// Mark event as processed
event.Processed = true
_ = event.Save(deployCtx)
})
}