Files
tiny-forge/internal/webhook/handler.go
T
alexei.dolgolyov 82d32181ba feat(webhook): vendor-specific event parsing (Gitea / GitHub / GitLab)
The /api/webhook/workloads/{secret} ingress now short-circuits on a
recognized X-*-Event header before falling back to the generic
simple-body parser. Vendor parsers populate fields the generic
parser cannot (image digest, GitEvent.Vendor, registry host).

internal/webhook/vendor_parsers.go covers:
- Gitea package events (X-Gitea-Event: package, container type)
- GitHub registry_package + package events (CONTAINER package_type)
- GitHub / Gitea push events with vendor stamping
- GitLab Push Hook + Tag Push Hook with path_with_namespace mapping

When a vendor parser claims a request (ok=true), it's authoritative
— a malformed Gitea package payload surfaces as an error rather
than silently re-parsing as generic. The generic {image} /
{ref + repository.full_name} fallback stays in place for legacy
CIs that send those shapes.

Coverage: internal/webhook/vendor_parsers_test.go +
inbound_event_test.go (round-trip through buildInboundEvent).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 22:17:53 +03:00

948 lines
32 KiB
Go

package webhook
import (
"context"
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"io"
"log/slog"
"net/http"
"strings"
"sync"
"time"
"github.com/go-chi/chi/v5"
"github.com/alexei/tinyforge/internal/store"
"github.com/alexei/tinyforge/internal/workload/plugin"
)
// Local aliases — keep the interface surface small and avoid leaking
// plugin types into every consumer of the webhook package's API.
type (
pluginWorkload = plugin.Workload
pluginIntent = plugin.DeploymentIntent
pluginDeps = plugin.Deps
)
// signatureHeader is the canonical Gitea/GitHub-compatible header name for
// HMAC-SHA256 signatures over the raw request body. Tinyforge accepts the
// same header so existing CI integrations work unchanged.
const signatureHeader = "X-Hub-Signature-256"
// signature verification states recorded in the webhook delivery log.
const (
sigStateUnconfigured = "unconfigured"
sigStateMissing = "missing"
sigStateInvalid = "invalid"
sigStateValid = "valid"
)
// outcome values for the delivery log. Stable identifiers — frontend keys
// off these for badge colouring + i18n.
const (
outcomeDeploy = "deploy"
outcomeSkip = "skip"
outcomeRejected = "rejected"
outcomeNotFound = "not_found"
outcomeBadRequest = "bad_request"
outcomeError = "error"
)
// signatureStateFor classifies the HMAC verification result for the delivery
// log: distinguishes "no signing secret configured" from "secret configured
// but caller sent nothing" so users can spot mis-configured CIs.
func signatureStateFor(signingSecret, header string, verified, attempted bool) string {
if signingSecret == "" {
return sigStateUnconfigured
}
if header == "" {
return sigStateMissing
}
if attempted && verified {
return sigStateValid
}
return sigStateInvalid
}
// recordDelivery persists a single inbound webhook delivery as a best-effort
// audit record. Errors are logged but never propagate — the user-visible
// response must not be affected by audit-log churn.
func (h *Handler) recordDelivery(d store.WebhookDelivery) {
if err := h.store.InsertWebhookDelivery(d); err != nil {
slog.Warn("webhook: record delivery", "error", err)
}
}
// clientIP returns the most-trusted source IP for logging. Strips the
// Forwarded-For chain to its first hop and falls back to RemoteAddr.
func clientIP(r *http.Request) string {
if fwd := r.Header.Get("X-Forwarded-For"); fwd != "" {
if i := strings.IndexByte(fwd, ','); i >= 0 {
return strings.TrimSpace(fwd[:i])
}
return strings.TrimSpace(fwd)
}
return r.RemoteAddr
}
// verifyHMAC validates the X-Hub-Signature-256 header against the raw body
// using HMAC-SHA256. The function does the comparison in constant time.
//
// Behavior:
// - signingSecret == "": signing not configured for this entity. The
// function returns (false, false) — the caller decides whether to
// enforce based on the require_signature flag.
// - header missing: returns (false, true) — caller-decided.
// - header malformed or signature mismatch: returns (false, true).
// - signature valid: returns (true, true).
//
// First return: whether the signature was successfully verified.
// Second return: whether the verification was attempted (i.e., a header was
// present or signing is configured). The caller uses this to distinguish
// "no signature submitted" from "wrong signature submitted".
func verifyHMAC(signingSecret string, body []byte, headerValue string) (verified, attempted bool) {
if signingSecret == "" {
return false, false
}
if headerValue == "" {
return false, false
}
const prefix = "sha256="
if !strings.HasPrefix(headerValue, prefix) {
return false, true
}
provided, err := hex.DecodeString(headerValue[len(prefix):])
if err != nil {
return false, true
}
mac := hmac.New(sha256.New, []byte(signingSecret))
mac.Write(body)
expected := mac.Sum(nil)
return hmac.Equal(provided, expected), true
}
// maxSiteConcurrentSyncs caps fan-out of background site syncs triggered by
// webhooks. Above this limit, requests are rejected with 503.
const maxSiteConcurrentSyncs = 4
// maxWebhookBodyBytes caps the request body size for webhook payloads. The
// /api routes already wrap the body with MaxBytesReader, but the webhook
// router relies on its own limit so changes to the parent middleware can't
// silently increase the cap.
const maxWebhookBodyBytes = 256 * 1024 // 256 KiB
// DeployTriggerer is called when a webhook determines a deploy should happen.
// Same interface as registry.DeployTriggerer — kept separate to avoid import cycles.
type DeployTriggerer interface {
TriggerDeploy(ctx context.Context, projectID, stageID, imageTag string) error
}
// SiteSyncTriggerer is called when a static-site webhook determines a sync
// should happen. The manager handles the actual git-pull + redeploy.
type SiteSyncTriggerer interface {
Deploy(ctx context.Context, siteID string, force bool) error
}
// PluginDispatcher is what the plugin-workload webhook handler needs from
// the deployer: the canonical Source-dispatch entry point plus access to
// the same Deps bundle so Trigger.Match can read store / crypto.
type PluginDispatcher interface {
DispatchPlugin(ctx context.Context, w pluginWorkload, intent pluginIntent) error
PluginDeps() pluginDeps
}
// Payload is the expected JSON body for a project webhook request.
type Payload struct {
// Image is the full image reference including tag, e.g.
// "git.dolgolyov-family.by/alexei/web-app-launcher:dev-abc123".
Image string `json:"image"`
}
// SitePayload is the expected JSON body for a static-site webhook request.
// Callers point Gitea/GitHub/GitLab webhooks at the site URL; only the ref
// matters for branch filtering. Body is optional — an empty body triggers
// a sync using the site's configured branch.
type SitePayload struct {
Ref string `json:"ref"` // e.g. "refs/heads/main"; optional
}
// ParsedImage holds the components extracted from a full image reference string.
type ParsedImage struct {
// Registry is the hostname, e.g. "git.dolgolyov-family.by".
Registry string
// Owner is the namespace/org, e.g. "alexei".
Owner string
// Name is the repository name, e.g. "web-app-launcher".
Name string
// Tag is the image tag, e.g. "dev-abc123". Empty string means "latest".
Tag string
}
// FullName returns "owner/name" (the image path without registry and tag).
func (p ParsedImage) FullName() string {
if p.Owner != "" {
return p.Owner + "/" + p.Name
}
return p.Name
}
// ParseImageRef splits a full image reference into its components.
// Accepted formats:
//
// registry.example.com/owner/name:tag
// registry.example.com/owner/name
// owner/name:tag
// name:tag
func ParseImageRef(ref string) (ParsedImage, error) {
ref = strings.TrimSpace(ref)
if ref == "" {
return ParsedImage{}, fmt.Errorf("empty image reference")
}
var parsed ParsedImage
// Split off tag.
if idx := strings.LastIndex(ref, ":"); idx != -1 {
// Make sure the colon is not inside the registry host (e.g. "localhost:5000/img").
afterColon := ref[idx+1:]
if !strings.Contains(afterColon, "/") {
parsed.Tag = afterColon
ref = ref[:idx]
}
}
parts := strings.Split(ref, "/")
switch len(parts) {
case 1:
// "name"
parsed.Name = parts[0]
case 2:
// "owner/name"
parsed.Owner = parts[0]
parsed.Name = parts[1]
default:
// "registry/owner/name" or "registry/owner/sub/name" — first segment is registry.
parsed.Registry = parts[0]
parsed.Owner = strings.Join(parts[1:len(parts)-1], "/")
parsed.Name = parts[len(parts)-1]
}
if parsed.Name == "" {
return ParsedImage{}, fmt.Errorf("invalid image reference: missing name in %q", ref)
}
return parsed, nil
}
// Handler is the HTTP handler for webhook requests.
type Handler struct {
store *store.Store
deployer DeployTriggerer
sites SiteSyncTriggerer
plugins PluginDispatcher // optional; nil disables /workloads/{secret}
// Site sync coordination — webhooks fire syncs in the background; Drain
// blocks until those goroutines finish, so a graceful shutdown does not
// kill an in-flight git fetch + container rebuild.
siteSyncCtx context.Context
siteSyncCancel context.CancelFunc
siteSyncWG sync.WaitGroup
siteSyncSem chan struct{}
}
// NewHandler creates a new webhook Handler. The sites triggerer is optional
// and may be nil (site webhooks will return 404).
func NewHandler(st *store.Store, deployer DeployTriggerer, sites SiteSyncTriggerer) *Handler {
ctx, cancel := context.WithCancel(context.Background())
return &Handler{
store: st,
deployer: deployer,
sites: sites,
siteSyncCtx: ctx,
siteSyncCancel: cancel,
siteSyncSem: make(chan struct{}, maxSiteConcurrentSyncs),
}
}
// SetSiteSyncTriggerer injects the static-site manager after construction.
// The site manager depends on the store + docker client, which are wired up
// in the same startup path as the handler; this setter lets callers defer the
// dependency if needed.
func (h *Handler) SetSiteSyncTriggerer(s SiteSyncTriggerer) {
h.sites = s
}
// SetPluginDispatcher injects the plugin-pipeline dispatcher. Until this
// is called the /workloads/{secret} route returns 503 — preventing partial
// initialization from silently dropping deploys.
func (h *Handler) SetPluginDispatcher(d PluginDispatcher) {
h.plugins = d
}
// Drain cancels in-flight site syncs and waits for their goroutines to exit.
// Safe to call from a graceful-shutdown path.
func (h *Handler) Drain() {
h.siteSyncCancel()
h.siteSyncWG.Wait()
}
// Route returns a chi router with the webhook endpoints mounted.
//
// Routes:
//
// POST /{secret} — per-project deploy trigger (legacy)
// POST /sites/{secret} — per-site sync trigger (legacy)
// POST /workloads/{secret} — plugin-native workload trigger
func (h *Handler) Route() chi.Router {
r := chi.NewRouter()
r.Post("/sites/{secret}", h.handleSiteWebhook)
r.Post("/workloads/{secret}", h.handlePluginWorkloadWebhook)
r.Post("/{secret}", h.handleWebhook)
return r
}
// respondWebhookJSON writes a JSON response for webhook handlers.
func respondWebhookJSON(w http.ResponseWriter, status int, data any) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
json.NewEncoder(w).Encode(data) //nolint:errcheck
}
// respondWebhookError writes a JSON error response for webhook handlers.
func respondWebhookError(w http.ResponseWriter, status int, msg string) {
respondWebhookJSON(w, status, map[string]any{"success": false, "error": msg})
}
// handleWebhook processes an incoming project webhook request.
//
// URL: POST /api/webhook/{secret}
//
// The secret identifies exactly one project. Stage routing is delegated to
// the project's configured stages (tag_pattern match). Returns 404 for
// unknown secrets (no information leak).
func (h *Handler) handleWebhook(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
// Build the audit record incrementally; record on every return path so
// users can debug "why didn't my deploy fire?" without grepping logs.
delivery := store.WebhookDelivery{
TargetType: "project",
SourceIP: clientIP(r),
SignatureState: sigStateUnconfigured,
StatusCode: http.StatusOK,
Outcome: outcomeSkip,
}
defer func() { h.recordDelivery(delivery) }()
secret := chi.URLParam(r, "secret")
if secret == "" {
delivery.StatusCode = http.StatusNotFound
delivery.Outcome = outcomeNotFound
http.NotFound(w, r)
return
}
// Resolve the secret via the workload row only. The project's own
// webhook_secret column is the source of truth, but lookups go through
// workloads.webhook_secret which is kept in lock-step by the
// transactional sync in the project CRUD path. Reading from workloads
// alone closes the rotation-durability gap: any rotation that didn't
// commit also didn't update the workload row, so an old secret
// surfaces here as 404 rather than being silently accepted.
var (
project store.Project
err error
)
wl, wErr := h.store.GetWorkloadByWebhookSecret(secret)
if wErr == nil && wl.Kind == string(store.WorkloadKindProject) {
project, err = h.store.GetProjectByID(wl.RefID)
} else {
err = store.ErrNotFound
}
if err != nil {
if errors.Is(err, store.ErrNotFound) {
delivery.StatusCode = http.StatusNotFound
delivery.Outcome = outcomeNotFound
delivery.Detail = "unknown webhook secret"
http.NotFound(w, r)
return
}
slog.Error("webhook: project lookup failed", "error", err)
delivery.StatusCode = http.StatusNotFound
delivery.Outcome = outcomeError
delivery.Detail = "lookup failed"
http.NotFound(w, r)
return
}
delivery.TargetID = project.ID
delivery.TargetName = project.Name
// Read body once so we can both verify HMAC and decode JSON.
body, err := io.ReadAll(io.LimitReader(r.Body, maxWebhookBodyBytes))
if err != nil {
delivery.StatusCode = http.StatusBadRequest
delivery.Outcome = outcomeBadRequest
delivery.Detail = "failed to read request body"
respondWebhookError(w, http.StatusBadRequest, "failed to read request body")
return
}
delivery.BodySize = len(body)
// HMAC enforcement: a configured signing secret + the require_signature
// flag together produce a hard reject on missing/invalid signatures.
// When the flag is off we still verify any submitted signature so a
// CI misconfiguration surfaces as a 401 rather than silent acceptance.
header := r.Header.Get(signatureHeader)
verified, attempted := verifyHMAC(project.WebhookSigningSecret, body, header)
delivery.SignatureState = signatureStateFor(project.WebhookSigningSecret, header, verified, attempted)
if project.WebhookRequireSignature && !verified {
slog.Warn("webhook: signature required but invalid/missing", "project", project.Name)
delivery.StatusCode = http.StatusUnauthorized
delivery.Outcome = outcomeRejected
delivery.Detail = "invalid or missing signature"
respondWebhookError(w, http.StatusUnauthorized, "invalid or missing signature")
return
}
if attempted && !verified {
slog.Warn("webhook: bad signature", "project", project.Name)
delivery.StatusCode = http.StatusUnauthorized
delivery.Outcome = outcomeRejected
delivery.Detail = "invalid signature"
respondWebhookError(w, http.StatusUnauthorized, "invalid signature")
return
}
var payload Payload
if err := json.Unmarshal(body, &payload); err != nil {
delivery.StatusCode = http.StatusBadRequest
delivery.Outcome = outcomeBadRequest
delivery.Detail = "invalid JSON payload"
respondWebhookError(w, http.StatusBadRequest, "invalid JSON payload")
return
}
if payload.Image == "" {
delivery.StatusCode = http.StatusBadRequest
delivery.Outcome = outcomeBadRequest
delivery.Detail = "missing image field"
respondWebhookError(w, http.StatusBadRequest, "missing image field")
return
}
parsed, err := ParseImageRef(payload.Image)
if err != nil {
delivery.StatusCode = http.StatusBadRequest
delivery.Outcome = outcomeBadRequest
delivery.Detail = "invalid image reference"
respondWebhookError(w, http.StatusBadRequest, "invalid image reference")
return
}
if parsed.Tag == "" {
parsed.Tag = "latest"
}
if project.Image != "" && !imageMatches(project.Image, parsed.FullName()) {
slog.Warn("webhook: image mismatch",
"project", project.Name, "expected", project.Image, "received", parsed.FullName())
delivery.StatusCode = http.StatusBadRequest
delivery.Outcome = outcomeBadRequest
delivery.Detail = fmt.Sprintf("image %q does not match project image %q", parsed.FullName(), project.Image)
respondWebhookError(w, http.StatusBadRequest, delivery.Detail)
return
}
slog.Info("webhook: received push",
"project", project.Name, "image", parsed.FullName(), "tag", parsed.Tag)
stage, found, err := matchStage(h.store, project.ID, parsed.Tag)
if err != nil {
slog.Error("webhook: stage match failed", "project", project.Name, "error", err)
delivery.StatusCode = http.StatusInternalServerError
delivery.Outcome = outcomeError
delivery.Detail = "stage match failed"
respondWebhookError(w, http.StatusInternalServerError, "internal error")
return
}
if !found {
slog.Info("webhook: no stage matches tag",
"project", project.Name, "tag", parsed.Tag)
delivery.Detail = fmt.Sprintf("no stage matches tag %q", parsed.Tag)
respondWebhookJSON(w, http.StatusOK, map[string]any{
"success": true, "deploy": false, "project": project.Name,
"reason": "no stage pattern matched tag",
})
return
}
if !stage.AutoDeploy {
slog.Info("webhook: auto_deploy disabled, skipping",
"project", project.Name, "stage", stage.Name)
delivery.Detail = fmt.Sprintf("stage %q has auto_deploy disabled", stage.Name)
respondWebhookJSON(w, http.StatusOK, map[string]any{
"success": true, "deploy": false,
"project": project.Name, "stage": stage.Name,
})
return
}
if err := h.deployer.TriggerDeploy(ctx, project.ID, stage.ID, parsed.Tag); err != nil {
slog.Error("webhook: deploy trigger failed", "error", err)
delivery.StatusCode = http.StatusInternalServerError
delivery.Outcome = outcomeError
delivery.Detail = "deploy trigger failed: " + err.Error()
respondWebhookError(w, http.StatusInternalServerError, "deploy trigger failed")
return
}
slog.Info("webhook: triggered deploy",
"project", project.Name, "stage", stage.Name, "tag", parsed.Tag)
delivery.Outcome = outcomeDeploy
delivery.Detail = fmt.Sprintf("stage=%s tag=%s", stage.Name, parsed.Tag)
respondWebhookJSON(w, http.StatusOK, map[string]any{
"success": true, "deploy": true,
"project": project.Name, "stage": stage.Name, "tag": parsed.Tag,
})
}
// handleSiteWebhook processes an incoming static-site webhook request.
//
// URL: POST /api/webhook/sites/{secret}
//
// The secret identifies exactly one static site. If the payload includes a
// ref (Git push event), it must match the site's configured branch (when the
// site's sync_trigger is "push"). For tag-based sync, the ref must match the
// stored tag pattern. Manual-trigger sites ignore webhooks entirely.
func (h *Handler) handleSiteWebhook(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
delivery := store.WebhookDelivery{
TargetType: "site",
SourceIP: clientIP(r),
SignatureState: sigStateUnconfigured,
StatusCode: http.StatusOK,
Outcome: outcomeSkip,
}
defer func() { h.recordDelivery(delivery) }()
if h.sites == nil {
delivery.StatusCode = http.StatusNotFound
delivery.Outcome = outcomeNotFound
delivery.Detail = "static site manager not configured"
http.NotFound(w, r)
return
}
secret := chi.URLParam(r, "secret")
if secret == "" {
delivery.StatusCode = http.StatusNotFound
delivery.Outcome = outcomeNotFound
http.NotFound(w, r)
return
}
// Workload-only lookup, mirroring the project handler. Reading from
// workloads.webhook_secret keeps rotation-durability honest — a
// rotation that didn't commit doesn't update the workload row, so the
// stale secret returns 404 instead of being silently accepted.
var (
site store.StaticSite
err error
)
wl, wErr := h.store.GetWorkloadByWebhookSecret(secret)
if wErr == nil && wl.Kind == string(store.WorkloadKindSite) {
site, err = h.store.GetStaticSiteByID(wl.RefID)
} else {
err = store.ErrNotFound
}
if err != nil {
if errors.Is(err, store.ErrNotFound) {
delivery.StatusCode = http.StatusNotFound
delivery.Outcome = outcomeNotFound
delivery.Detail = "unknown webhook secret"
http.NotFound(w, r)
return
}
slog.Error("webhook: site lookup failed", "error", err)
delivery.StatusCode = http.StatusNotFound
delivery.Outcome = outcomeError
delivery.Detail = "lookup failed"
http.NotFound(w, r)
return
}
delivery.TargetID = site.ID
delivery.TargetName = site.Name
if site.SyncTrigger == "manual" {
slog.Info("webhook: site sync_trigger=manual, skipping",
"site", site.Name)
delivery.Detail = "sync_trigger=manual"
respondWebhookJSON(w, http.StatusOK, map[string]any{
"success": true, "sync": false, "site": site.Name,
"reason": "sync_trigger is manual",
})
return
}
var payload SitePayload
body, err := io.ReadAll(io.LimitReader(r.Body, maxWebhookBodyBytes))
if err != nil {
delivery.StatusCode = http.StatusBadRequest
delivery.Outcome = outcomeBadRequest
delivery.Detail = "failed to read request body"
respondWebhookError(w, http.StatusBadRequest, "failed to read request body")
return
}
delivery.BodySize = len(body)
header := r.Header.Get(signatureHeader)
verified, attempted := verifyHMAC(site.WebhookSigningSecret, body, header)
delivery.SignatureState = signatureStateFor(site.WebhookSigningSecret, header, verified, attempted)
if site.WebhookRequireSignature && !verified {
slog.Warn("webhook: site signature required but invalid/missing", "site", site.Name)
delivery.StatusCode = http.StatusUnauthorized
delivery.Outcome = outcomeRejected
delivery.Detail = "invalid or missing signature"
respondWebhookError(w, http.StatusUnauthorized, "invalid or missing signature")
return
}
if attempted && !verified {
slog.Warn("webhook: site bad signature", "site", site.Name)
delivery.StatusCode = http.StatusUnauthorized
delivery.Outcome = outcomeRejected
delivery.Detail = "invalid signature"
respondWebhookError(w, http.StatusUnauthorized, "invalid signature")
return
}
if len(body) > 0 {
if err := json.Unmarshal(body, &payload); err != nil {
delivery.StatusCode = http.StatusBadRequest
delivery.Outcome = outcomeBadRequest
delivery.Detail = "invalid JSON payload"
respondWebhookError(w, http.StatusBadRequest, "invalid JSON payload")
return
}
}
if payload.Ref != "" && !siteRefMatches(site, payload.Ref) {
slog.Info("webhook: site ref does not match configured branch/tag",
"site", site.Name, "ref", payload.Ref,
"branch", site.Branch, "tag_pattern", site.TagPattern,
"trigger", site.SyncTrigger)
delivery.Detail = fmt.Sprintf("ref %q does not match", payload.Ref)
respondWebhookJSON(w, http.StatusOK, map[string]any{
"success": true, "sync": false, "site": site.Name,
"reason": "ref does not match configured branch or tag pattern",
})
return
}
select {
case h.siteSyncSem <- struct{}{}:
default:
delivery.StatusCode = http.StatusServiceUnavailable
delivery.Outcome = outcomeError
delivery.Detail = "site sync queue full"
respondWebhookError(w, http.StatusServiceUnavailable, "site sync queue full")
return
}
h.siteSyncWG.Add(1)
go func(siteID, siteName string) {
defer h.siteSyncWG.Done()
defer func() { <-h.siteSyncSem }()
if err := h.sites.Deploy(h.siteSyncCtx, siteID, false); err != nil {
slog.Error("webhook: site sync failed", "site", siteName, "error", err)
}
}(site.ID, site.Name)
_ = ctx
slog.Info("webhook: triggered site sync", "site", site.Name, "ref", payload.Ref)
delivery.Outcome = outcomeDeploy
if payload.Ref != "" {
delivery.Detail = fmt.Sprintf("ref=%s", payload.Ref)
} else {
delivery.Detail = "no ref filter"
}
respondWebhookJSON(w, http.StatusOK, map[string]any{
"success": true, "sync": true, "site": site.Name,
})
}
// handlePluginWorkloadWebhook processes an inbound webhook for a
// plugin-native workload.
//
// URL: POST /api/webhook/workloads/{secret}
//
// The secret resolves to exactly one workload row whose Source +
// Trigger kinds determine how the payload is interpreted. The body
// shape is the same as the legacy project/site webhooks (Image for
// registry pushes, Ref for git pushes) — Gitea / GitHub / generic
// registry CIs can target this URL without payload changes. The
// workload's configured Trigger plugin then decides whether the event
// fires a deploy.
func (h *Handler) handlePluginWorkloadWebhook(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
delivery := store.WebhookDelivery{
TargetType: "workload",
SourceIP: clientIP(r),
SignatureState: sigStateUnconfigured,
StatusCode: http.StatusOK,
Outcome: outcomeSkip,
}
defer func() { h.recordDelivery(delivery) }()
if h.plugins == nil {
delivery.StatusCode = http.StatusServiceUnavailable
delivery.Outcome = outcomeError
delivery.Detail = "plugin dispatcher not wired"
respondWebhookError(w, http.StatusServiceUnavailable, "plugin dispatcher not wired")
return
}
secret := chi.URLParam(r, "secret")
if secret == "" {
delivery.StatusCode = http.StatusNotFound
delivery.Outcome = outcomeNotFound
http.NotFound(w, r)
return
}
wl, err := h.store.GetWorkloadByWebhookSecret(secret)
if err != nil {
if errors.Is(err, store.ErrNotFound) {
delivery.StatusCode = http.StatusNotFound
delivery.Outcome = outcomeNotFound
delivery.Detail = "unknown webhook secret"
http.NotFound(w, r)
return
}
slog.Error("webhook: workload lookup failed", "error", err)
delivery.StatusCode = http.StatusNotFound
delivery.Outcome = outcomeError
delivery.Detail = "lookup failed"
http.NotFound(w, r)
return
}
if wl.SourceKind == "" || wl.TriggerKind == "" {
// Legacy workload row whose secret happens to also be valid on the
// legacy path. Tell the caller they hit the wrong route rather
// than silently 404-ing — avoids head-scratching.
delivery.StatusCode = http.StatusBadRequest
delivery.Outcome = outcomeBadRequest
delivery.Detail = "workload is legacy; use the project or site route"
respondWebhookError(w, http.StatusBadRequest, "workload is not plugin-native")
return
}
delivery.TargetID = wl.ID
delivery.TargetName = wl.Name
body, err := io.ReadAll(io.LimitReader(r.Body, maxWebhookBodyBytes))
if err != nil {
delivery.StatusCode = http.StatusBadRequest
delivery.Outcome = outcomeBadRequest
delivery.Detail = "failed to read request body"
respondWebhookError(w, http.StatusBadRequest, "failed to read request body")
return
}
delivery.BodySize = len(body)
header := r.Header.Get(signatureHeader)
verified, attempted := verifyHMAC(wl.WebhookSigningSecret, body, header)
delivery.SignatureState = signatureStateFor(wl.WebhookSigningSecret, header, verified, attempted)
if wl.WebhookRequireSignature && !verified {
slog.Warn("webhook: workload signature required but invalid/missing", "workload", wl.Name)
delivery.StatusCode = http.StatusUnauthorized
delivery.Outcome = outcomeRejected
delivery.Detail = "invalid or missing signature"
respondWebhookError(w, http.StatusUnauthorized, "invalid or missing signature")
return
}
if attempted && !verified {
slog.Warn("webhook: workload bad signature", "workload", wl.Name)
delivery.StatusCode = http.StatusUnauthorized
delivery.Outcome = outcomeRejected
delivery.Detail = "invalid signature"
respondWebhookError(w, http.StatusUnauthorized, "invalid signature")
return
}
evt, err := buildInboundEvent(body, r.Header)
if err != nil {
delivery.StatusCode = http.StatusBadRequest
delivery.Outcome = outcomeBadRequest
delivery.Detail = err.Error()
respondWebhookError(w, http.StatusBadRequest, err.Error())
return
}
trig, err := plugin.GetTrigger(wl.TriggerKind)
if err != nil {
slog.Warn("webhook: trigger plugin not registered",
"workload", wl.Name, "trigger", wl.TriggerKind, "error", err)
delivery.StatusCode = http.StatusInternalServerError
delivery.Outcome = outcomeError
delivery.Detail = "trigger plugin missing"
respondWebhookError(w, http.StatusInternalServerError, "trigger plugin missing")
return
}
pwl := toPluginWorkload(wl)
intent, err := trig.Match(ctx, h.plugins.PluginDeps(), pwl, evt)
if err != nil {
slog.Warn("webhook: trigger match error",
"workload", wl.Name, "trigger", wl.TriggerKind, "error", err)
delivery.StatusCode = http.StatusInternalServerError
delivery.Outcome = outcomeError
delivery.Detail = "trigger match error"
respondWebhookError(w, http.StatusInternalServerError, "trigger match error")
return
}
if intent == nil {
delivery.Detail = "trigger declined (no match)"
respondWebhookJSON(w, http.StatusOK, map[string]any{
"success": true, "deploy": false, "workload": wl.Name,
"reason": "trigger declined",
})
return
}
if intent.TriggeredAt.IsZero() {
intent.TriggeredAt = time.Now().UTC()
}
if intent.TriggeredBy == "" {
intent.TriggeredBy = "webhook"
}
if err := h.plugins.DispatchPlugin(ctx, pwl, *intent); err != nil {
slog.Warn("webhook: plugin dispatch failed",
"workload", wl.Name, "error", err)
delivery.StatusCode = http.StatusInternalServerError
delivery.Outcome = outcomeError
delivery.Detail = "dispatch failed; see server logs"
respondWebhookError(w, http.StatusInternalServerError, "dispatch failed; see server logs")
return
}
delivery.Outcome = outcomeDeploy
delivery.Detail = fmt.Sprintf("reason=%s ref=%s", intent.Reason, intent.Reference)
slog.Info("webhook: triggered plugin deploy",
"workload", wl.Name, "trigger", wl.TriggerKind, "reason", intent.Reason)
respondWebhookJSON(w, http.StatusOK, map[string]any{
"success": true, "deploy": true,
"workload": wl.Name, "reference": intent.Reference,
})
}
// buildInboundEvent normalizes the incoming webhook body into the
// plugin.InboundEvent shape. The dispatch order is:
//
// 1. Empty body → manual event (used by the test-trigger UI button).
// 2. Vendor-specific parsers (Gitea package, GitHub registry_package,
// GitHub/Gitea/GitLab push) — short-circuit on a recognized
// X-*-Event header. Vendor parsers can fully populate richer fields
// (image digest, vendor tag, branch) the generic parser cannot.
// 3. Generic simple-body parser: top-level `image` for registry pushes,
// top-level `ref` for git pushes. This is what the legacy webhook
// CIs already send and what the operator-facing API surface
// documents.
//
// RawBody and Headers are always attached so trigger plugins can do
// their own vendor-specific parsing if they need fields outside this
// normalized envelope.
func buildInboundEvent(body []byte, headers http.Header) (plugin.InboundEvent, error) {
evt := plugin.InboundEvent{
RawBody: body,
Headers: headers,
}
if len(body) == 0 {
evt.Kind = "manual"
evt.Manual = &plugin.ManualEvent{Actor: "webhook"}
return evt, nil
}
// Try vendor-specific parsers first. A vendor parser claiming the
// request (ok=true) is authoritative — we don't fall through even
// if it returned an error, because the operator's CI is sending a
// known vendor payload and silently re-parsing as generic would
// hide the real cause.
if res := tryVendorParsers(body, headers); res.ok {
if res.err != nil {
return plugin.InboundEvent{}, res.err
}
res.event.RawBody = body
res.event.Headers = headers
return res.event, nil
}
// Generic simple-body fallback: covers the canonical `{image: ...}`
// and `{ref: ..., repository: {...}}` payloads documented in
// docs/webhooks.md.
var probe struct {
Image string `json:"image"`
}
if err := json.Unmarshal(body, &probe); err != nil {
return plugin.InboundEvent{}, fmt.Errorf("invalid JSON payload")
}
if probe.Image != "" {
parsed, err := ParseImageRef(probe.Image)
if err != nil {
return plugin.InboundEvent{}, fmt.Errorf("invalid image reference")
}
evt.Kind = "image-push"
evt.Image = &plugin.ImagePushEvent{
Registry: parsed.Registry,
Repo: parsed.FullName(),
Tag: parsed.Tag,
}
return evt, nil
}
gitEvt, err := parseGenericGitPush(body)
if err != nil {
// "missing ref" here means we got JSON with neither `image` nor
// `ref` — surface the operator-facing message documented in
// docs/webhooks.md rather than the lower-level parser error.
if strings.Contains(err.Error(), "missing ref") {
return plugin.InboundEvent{}, fmt.Errorf("payload must include either 'image' or 'ref'")
}
return plugin.InboundEvent{}, err
}
gitEvt.RawBody = body
gitEvt.Headers = headers
return gitEvt, nil
}
// toPluginWorkload mirrors the api-layer converter but kept local so the
// webhook package does not depend on internal/api. Inlining is cheap and
// avoids elevating that converter to a shared package.
func toPluginWorkload(w store.Workload) plugin.Workload {
var faces []plugin.PublicFace
if w.PublicFaces != "" {
_ = json.Unmarshal([]byte(w.PublicFaces), &faces)
}
return plugin.Workload{
ID: w.ID,
Name: w.Name,
GroupID: w.AppID,
ParentWorkloadID: w.ParentWorkloadID,
SourceKind: w.SourceKind,
SourceConfig: json.RawMessage(w.SourceConfig),
TriggerKind: w.TriggerKind,
TriggerConfig: json.RawMessage(w.TriggerConfig),
PublicFaces: faces,
NotificationURL: w.NotificationURL,
NotificationSecret: w.NotificationSecret,
WebhookSecret: w.WebhookSecret,
WebhookSigningSecret: w.WebhookSigningSecret,
WebhookRequireSignature: w.WebhookRequireSignature,
CreatedAt: w.CreatedAt,
UpdatedAt: w.UpdatedAt,
}
}