Major changes: - Add internal/logging package with field constants, context propagation, sensitive data auto-redaction, and per-component log levels - Add worker timeout constants (TimeoutQuickOp, TimeoutHealthCheck, etc.) - Extend SDLC with callback handlers, generate endpoints, and executor - Add new cookbook trees for aeries and slackpath progression - Add skeleton templates for queue, realtime, and microservices - Add worker component template with async job processing - Refactor services and handlers to use new logging infrastructure - Split component.go into component_infra.go and component_listing.go Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
362 lines
11 KiB
Go
362 lines
11 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/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=<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))
|
|
}
|
|
|
|
// 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)
|
|
}
|
|
}
|
|
}
|
|
}
|