// Package handlers provides HTTP handlers for the rdev API. package handlers import ( "context" "crypto/hmac" "crypto/sha256" "encoding/hex" "encoding/json" "fmt" "io" "log/slog" "net/http" "strings" "github.com/orchard9/rdev/internal/domain" "github.com/orchard9/rdev/internal/logging" "github.com/orchard9/rdev/internal/metrics" "github.com/orchard9/rdev/internal/port" "github.com/orchard9/rdev/internal/service" "github.com/orchard9/rdev/pkg/api" ) // WoodpeckerWebhookHandler handles webhooks from Woodpecker CI. type WoodpeckerWebhookHandler struct { deployer port.Deployer dns port.DNSProvider operationService *service.OperationService logger *slog.Logger // Config webhookSecret string defaultDomain string registryURL string clusterIP string } // WoodpeckerWebhookConfig configures the webhook handler. type WoodpeckerWebhookConfig struct { WebhookSecret string // HMAC secret for verifying webhooks DefaultDomain string // e.g., "threesix.ai" RegistryURL string // e.g., "zot.threesix.svc.cluster.local:5000" ClusterIP string // e.g., "208.122.204.172" Logger *slog.Logger } // NewWoodpeckerWebhookHandler creates a new Woodpecker webhook handler. func NewWoodpeckerWebhookHandler( deployer port.Deployer, dns port.DNSProvider, cfg WoodpeckerWebhookConfig, ) *WoodpeckerWebhookHandler { logger := cfg.Logger if logger == nil { logger = slog.Default() } return &WoodpeckerWebhookHandler{ deployer: deployer, dns: dns, logger: logger, webhookSecret: cfg.WebhookSecret, defaultDomain: cfg.DefaultDomain, registryURL: cfg.RegistryURL, clusterIP: cfg.ClusterIP, } } // SetOperationService sets the operation tracking service. func (h *WoodpeckerWebhookHandler) SetOperationService(svc *service.OperationService) *WoodpeckerWebhookHandler { if svc != nil { h.operationService = svc } return h } // Mount registers the webhook routes. func (h *WoodpeckerWebhookHandler) Mount(r api.Router) { // Woodpecker webhook endpoint - no API key auth, uses HMAC signature r.Post("/webhooks/woodpecker", h.HandleWebhook) } // WoodpeckerPayload represents a Woodpecker webhook payload. // See: https://woodpecker-ci.org/docs/usage/webhooks type WoodpeckerPayload struct { Event string `json:"event"` // "push", "pull_request", "tag", "deployment" Repo WoodpeckerRepo `json:"repo"` Build WoodpeckerBuild `json:"build"` Pipeline WoodpeckerPipeline `json:"pipeline"` } // WoodpeckerRepo represents repository info in the webhook. type WoodpeckerRepo struct { ID int64 `json:"id"` Owner string `json:"owner"` Name string `json:"name"` FullName string `json:"full_name"` // owner/name CloneURL string `json:"clone_url"` HTMLURL string `json:"html_url"` } // WoodpeckerBuild represents build info in the webhook. type WoodpeckerBuild struct { ID int64 `json:"id"` Number int64 `json:"number"` Status string `json:"status"` // "success", "failure", "pending", "running" Event string `json:"event"` Branch string `json:"branch"` Commit string `json:"commit"` Message string `json:"message"` Author string `json:"author"` Started int64 `json:"started"` Finished int64 `json:"finished"` } // WoodpeckerPipeline represents pipeline info in the webhook. type WoodpeckerPipeline struct { ID int64 `json:"id"` Number int64 `json:"number"` Status string `json:"status"` Event string `json:"event"` Branch string `json:"branch"` Commit string `json:"commit"` Started int64 `json:"started"` Finished int64 `json:"finished"` } // HandleWebhook processes incoming Woodpecker webhooks. // POST /webhooks/woodpecker func (h *WoodpeckerWebhookHandler) HandleWebhook(w http.ResponseWriter, r *http.Request) { ctx, cancel := context.WithTimeout(r.Context(), TimeoutHeavyWrite) defer cancel() log := logging.FromContext(ctx).WithHandler("HandleWebhook") // Read body body, err := io.ReadAll(r.Body) if err != nil { log.Error("failed to read webhook body", logging.FieldError, err.Error()) api.WriteBadRequest(w, r, "failed to read request body") return } // Debug log the raw payload for troubleshooting if h.logger.Enabled(ctx, slog.LevelDebug) { log.Debug("webhook payload received", "body", string(body)) } // Verify signature if secret is configured if h.webhookSecret != "" { signature := r.Header.Get("X-Woodpecker-Signature") if !h.verifySignature(body, signature) { log.Warn("webhook signature verification failed") api.WriteUnauthorized(w, r, "invalid signature") return } } // Parse payload var payload WoodpeckerPayload if err := json.Unmarshal(body, &payload); err != nil { log.Error("failed to parse webhook payload", logging.FieldError, err.Error()) api.WriteBadRequest(w, r, "invalid request body") return } log.Info("received woodpecker webhook", "event", payload.Event, "repo", payload.Repo.FullName, "build_status", payload.Build.Status, "build_number", payload.Build.Number, ) // Track failed builds for visibility if payload.Build.Status == "failure" { h.handleFailedBuild(ctx, payload) api.WriteSuccess(w, r, map[string]any{ "status": "recorded", "reason": "build failed", "project": payload.Repo.Name, "build": payload.Build.Number, }) return } // Only process successful builds on main/master branch if payload.Build.Status != "success" { api.WriteSuccess(w, r, map[string]string{ "status": "ignored", "reason": "build not successful", "build": payload.Build.Status, }) return } if payload.Build.Branch != "main" && payload.Build.Branch != "master" { api.WriteSuccess(w, r, map[string]string{ "status": "ignored", "reason": "not main/master branch", "branch": payload.Build.Branch, }) return } // Extract project name from repo name projectName := payload.Repo.Name // Build image tag from commit SHA (short) commitShort := payload.Build.Commit if len(commitShort) > 8 { commitShort = commitShort[:8] } imageTag := fmt.Sprintf("%s/%s:%s", h.registryURL, projectName, commitShort) log.Info("triggering deployment", logging.FieldProjectName, projectName, "image", imageTag, "commit", payload.Build.Commit, ) // Create DNS record if needed if h.dns != nil { _, err := h.dns.CreateRecord(ctx, domain.DNSRecord{ Type: "A", Name: projectName, Content: h.clusterIP, TTL: 1, Proxied: false, }) if err != nil { log.Warn("failed to create DNS record", logging.FieldError, err.Error(), logging.FieldProjectName, projectName) // Continue anyway - DNS might already exist } } // Track build operation var operationID string if h.operationService != nil { operationID, _ = h.operationService.StartOperation(ctx, projectName, domain.OperationTypeBuild, map[string]any{ "repo": payload.Repo.FullName, "branch": payload.Build.Branch, "commit": payload.Build.Commit, "build_number": payload.Build.Number, }, "") if operationID != "" { // Set external reference to build number if opErr := h.operationService.SetExternalRef(ctx, operationID, fmt.Sprintf("build#%d", payload.Build.Number)); opErr != nil { log.Error("failed to set external ref", logging.FieldError, opErr.Error(), logging.FieldOperation, operationID) } // Link to parent operation via commit SHA if parent, err := h.operationService.FindByCommit(ctx, projectName, payload.Build.Commit); err == nil && parent != nil { if opErr := h.operationService.LinkToParent(ctx, operationID, parent.ID); opErr != nil { log.Error("failed to link to parent operation", logging.FieldError, opErr.Error(), logging.FieldOperation, operationID) } } // Build webhook only fires for successful builds if opErr := h.operationService.CompleteOperation(ctx, operationID, map[string]any{ "image": imageTag, "commit": payload.Build.Commit, }); opErr != nil { log.Error("failed to record operation completion", logging.FieldError, opErr.Error(), logging.FieldOperation, operationID) } } } // Note: Project-level deployment is skipped for composable projects. // Component deployments are created by createInitialComponentDeployment // and updated by the CI pipeline's kubectl set image commands. log.Info("build succeeded, component deployments updated by CI", logging.FieldProjectName, projectName, "commit", payload.Build.Commit, ) resp := map[string]any{ "status": "success", "project": projectName, "image": imageTag, "commit": payload.Build.Commit, "note": "component deployments managed by CI pipeline", } if operationID != "" { resp["operation_id"] = operationID } api.WriteSuccess(w, r, resp) } // verifySignature verifies the HMAC-SHA256 signature of the webhook payload. func (h *WoodpeckerWebhookHandler) verifySignature(body []byte, signature string) bool { if signature == "" { return false } // Woodpecker sends signature as "sha256=" signature = strings.TrimPrefix(signature, "sha256=") mac := hmac.New(sha256.New, []byte(h.webhookSecret)) mac.Write(body) expected := hex.EncodeToString(mac.Sum(nil)) return hmac.Equal([]byte(signature), []byte(expected)) } // handleFailedBuild records a failed CI build for visibility and debugging. func (h *WoodpeckerWebhookHandler) handleFailedBuild(ctx context.Context, payload WoodpeckerPayload) { projectName := payload.Repo.Name log := logging.FromContext(ctx).WithHandler("handleFailedBuild") log.Warn("CI build failed", logging.FieldProjectName, projectName, "build_number", payload.Build.Number, "branch", payload.Build.Branch, "commit", payload.Build.Commit, "author", payload.Build.Author, ) // Record metrics metrics.RecordCIBuild(projectName, "failure") // Check if this looks like a registry push failure // (We can't get detailed logs here, but we track the failure) if payload.Build.Branch == "main" || payload.Build.Branch == "master" { // Failed builds on main are likely image push failures metrics.RecordCIPushFailure(projectName) } // Track as operation if operation service is configured if h.operationService != nil { operationID, _ := h.operationService.StartOperation(ctx, projectName, domain.OperationTypeCIBuild, map[string]any{ "repo": payload.Repo.FullName, "branch": payload.Build.Branch, "commit": payload.Build.Commit, "build_number": payload.Build.Number, "author": payload.Build.Author, }, "") if operationID != "" { // Set external reference to build number if opErr := h.operationService.SetExternalRef(ctx, operationID, fmt.Sprintf("build#%d", payload.Build.Number)); opErr != nil { log.Error("failed to set external ref", logging.FieldError, opErr.Error(), logging.FieldOperation, operationID) } // Link to parent operation via commit SHA if parent, err := h.operationService.FindByCommit(ctx, projectName, payload.Build.Commit); err == nil && parent != nil { if opErr := h.operationService.LinkToParent(ctx, operationID, parent.ID); opErr != nil { log.Error("failed to link to parent operation", logging.FieldError, opErr.Error(), logging.FieldOperation, operationID) } } // Mark as failed if opErr := h.operationService.FailOperation(ctx, operationID, "CI build failed", ""); opErr != nil { log.Error("failed to record operation failure", logging.FieldError, opErr.Error(), logging.FieldOperation, operationID) } } } }