rdev/internal/handlers/woodpecker_webhook.go
jordan 8282d60c69 feat: implement composable monorepo template system with component architecture
Adds the composable monorepo template system that generates project skeletons
with pluggable components (service, worker, app-react, app-astro, cli).

Key changes:
- Monorepo skeleton templates with shared pkg/, scripts/, and git hooks
- Component templates (service, worker, app-react, app-astro, cli) with
  Dockerfiles, CI steps, and component.yaml manifests
- Component domain model with validation and dependency resolution
- Component handler endpoints for CRUD and composition
- Template provider extended with BuildComposableProject and component assembly
- Deployer extended with composable project deployment support
- Handler timeout constants (TimeoutFastLookup through TimeoutLongRunning)
- envutil package for centralized env var reads with defaults
- api.DecodeJSON helper for standardized request body decoding
- Standardized response helpers (WriteBadRequest, WriteNotFound, etc.)
- Replaced fullstack-app cookbook with composable-app cookbook
- Hardened handler timeouts, logging, and error responses across all handlers

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-31 19:11:42 -07:00

260 lines
7.1 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/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(), 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)
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=<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))
}