rdev/internal/handlers/woodpecker_webhook.go
jordan 62460bf098 feat: complete template upgrade - chassis framework, UI library, auth, app-nextjs, OpenAPI, and cookbook
Weeks 1-7 of the template upgrade plan:
- pkg/api: typed HTTPError with sentinels, Wrap/WrapMiddleware, Bind, health probes, OpenAPI schema/param builders
- skeleton/packages: ui (design tokens, components), layout (DashboardShell), auth (AuthProvider, ProtectedRoute), api-client
- skeleton/pkg: httperror, app/handler, app/bind, app/health, auth (JWT/API key middleware)
- components/app-nextjs: Next.js 14 App Router template with dashboard, server actions, auth
- cookbooks/feature-development.md with test and validation scripts
- Handler tests for components, project management, and woodpecker webhook
- 3 rounds of code review fixes applied

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-02 00:46:51 -07:00

290 lines
8.5 KiB
Go

// 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/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()
// 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.WriteUnauthorized(w, r, "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 request body")
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)
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
}
}
// 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 {
h.logger.Error("failed to set external ref", "error", opErr, "operation_id", 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 {
h.logger.Error("failed to link to parent operation", "error", opErr, "operation_id", 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 {
h.logger.Error("failed to record operation completion", "error", opErr, "operation_id", 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.
h.logger.Info("build succeeded, component deployments updated by CI",
"project", 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=<hex>"
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))
}