Check / check (pull_request) Successful in 3m33s
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
148 lines
3.7 KiB
Go
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: ¶ms,
|
|
}, 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)
|
|
})
|
|
}
|