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>
This commit is contained in:
2026-05-11 22:17:53 +03:00
parent 8d6a527a2b
commit 82d32181ba
4 changed files with 1080 additions and 2 deletions
+301 -2
View File
@@ -13,10 +13,20 @@ import (
"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
@@ -138,6 +148,14 @@ 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.
@@ -226,6 +244,7 @@ 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
@@ -258,6 +277,13 @@ 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() {
@@ -269,11 +295,13 @@ func (h *Handler) Drain() {
//
// Routes:
//
// POST /{secret} — per-project deploy trigger
// POST /sites/{secret} — per-site sync trigger
// 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
}
@@ -646,3 +674,274 @@ func (h *Handler) handleSiteWebhook(w http.ResponseWriter, r *http.Request) {
"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,
}
}