rdev/internal/handlers/woodpecker_webhook.go
jordan c280a92012 feat: add operations audit system and template improvements
Operations Audit (new feature):
- Add Operation domain model with status tracking (pending, running, completed, failed, cancelled)
- Add OperationRepository with PostgreSQL implementation
- Add OperationService for CRUD and lifecycle management
- Add operations handlers (list, get, cancel endpoints)
- Add migration 015_operations.sql for operations table
- Add operation cleanup worker for stale operation handling
- Add ErrOperationNotFound to domain errors

Template Improvements:
- Add CLAUDE.md configuration files to astro-landing, default, and go-api templates
- Fix PORT template variable usage in nginx configs for app templates
- Add replace directives for local pkg module in Go templates
- Simplify Go service/worker Dockerfiles for workspace builds
- Fix TypeScript error in logger template

Other:
- Refactor landing-test.sh cookbook script
- Update CLAUDE.md version reference

Note: Some files exceed 500-line limit (pre-existing debt + new feature)
- component.go: 550 lines (unchanged, pre-existing)
- main.go: 522 lines (added operations wiring)
- operation_repo.go: 569 lines (new, needs splitting)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-01 19:08:57 -07:00

241 lines
6.8 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)
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
}
}
// 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,
)
api.WriteSuccess(w, r, map[string]any{
"status": "success",
"project": projectName,
"image": imageTag,
"commit": payload.Build.Commit,
"note": "component deployments managed by CI pipeline",
})
}
// 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))
}