Add Gitea, Cloudflare DNS, and Kubernetes deployer adapters following hexagonal architecture. These enable automated project provisioning: - Git repository creation/management via Gitea - DNS record management via Cloudflare - Container deployment to Kubernetes Includes domain models, ports, handlers, and Woodpecker CI webhook integration for automated deployments on push. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
261 lines
7.1 KiB
Go
261 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"
|
|
"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=<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))
|
|
}
|