// 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" "time" "github.com/orchard9/rdev/internal/domain" "github.com/orchard9/rdev/internal/port" "github.com/orchard9/rdev/pkg/api" ) // WoodpeckerWebhookHandler handles webhooks from Woodpecker CI. type WoodpeckerWebhookHandler struct { deployer port.Deployer dns port.DNSProvider 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, } } // 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(), 60*time.Second) defer cancel() // Read body body, err := io.ReadAll(r.Body) if err != nil { h.logger.Error("failed to read webhook body", "error", err) api.WriteBadRequest(w, r, "failed to read request body") return } // Debug log the raw payload for troubleshooting if h.logger.Enabled(ctx, slog.LevelDebug) { h.logger.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) { h.logger.Warn("webhook signature verification failed") api.WriteError(w, r, http.StatusUnauthorized, "UNAUTHORIZED", "invalid signature") return } } // Parse payload var payload WoodpeckerPayload if err := json.Unmarshal(body, &payload); err != nil { h.logger.Error("failed to parse webhook payload", "error", err) api.WriteBadRequest(w, r, "invalid JSON payload") return } h.logger.Info("received woodpecker webhook", "event", payload.Event, "repo", payload.Repo.FullName, "build_status", payload.Build.Status, "build_number", payload.Build.Number, ) // 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) imageLatest := fmt.Sprintf("%s/%s:latest", h.registryURL, projectName) h.logger.Info("triggering deployment", "project", 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 { h.logger.Warn("failed to create DNS record", "error", err, "project", projectName) // Continue anyway - DNS might already exist } } // Deploy if h.deployer == nil { api.WriteInternalError(w, r, "deployer not configured") return } deployDomain := projectName + "." + h.defaultDomain err = h.deployer.Deploy(ctx, domain.DeploySpec{ ProjectName: projectName, Image: imageLatest, // Use :latest tag, Woodpecker should push both Domain: deployDomain, Port: 8080, Replicas: 1, }) if err != nil { h.logger.Error("deployment failed", "error", err, "project", projectName) api.WriteInternalError(w, r, "deployment failed") return } h.logger.Info("deployment triggered successfully", "project", projectName, "url", "https://"+deployDomain, ) api.WriteSuccess(w, r, map[string]any{ "status": "deployed", "project": projectName, "image": imageTag, "url": "https://" + deployDomain, "commit": payload.Build.Commit, }) } // 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)) }