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:
+301
-2
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user