diff --git a/internal/webhook/handler.go b/internal/webhook/handler.go index ab3112a..ca8a8eb 100644 --- a/internal/webhook/handler.go +++ b/internal/webhook/handler.go @@ -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, + } +} diff --git a/internal/webhook/inbound_event_test.go b/internal/webhook/inbound_event_test.go new file mode 100644 index 0000000..23e2f67 --- /dev/null +++ b/internal/webhook/inbound_event_test.go @@ -0,0 +1,98 @@ +package webhook + +import ( + "net/http" + "testing" +) + +func TestBuildInboundEventEmpty(t *testing.T) { + evt, err := buildInboundEvent(nil, http.Header{}) + if err != nil { + t.Fatalf("unexpected err: %v", err) + } + if evt.Kind != "manual" { + t.Errorf("empty body should be manual, got %q", evt.Kind) + } + if evt.Manual == nil || evt.Manual.Actor != "webhook" { + t.Errorf("expected manual.Actor=webhook, got %+v", evt.Manual) + } +} + +func TestBuildInboundEventImagePush(t *testing.T) { + body := []byte(`{"image":"registry.example.com/owner/app:v1.2.3"}`) + evt, err := buildInboundEvent(body, http.Header{}) + if err != nil { + t.Fatalf("unexpected err: %v", err) + } + if evt.Kind != "image-push" || evt.Image == nil { + t.Fatalf("expected image-push, got kind=%q image=%+v", evt.Kind, evt.Image) + } + if evt.Image.Registry != "registry.example.com" { + t.Errorf("Registry=%q want registry.example.com", evt.Image.Registry) + } + if evt.Image.Repo != "owner/app" { + t.Errorf("Repo=%q want owner/app", evt.Image.Repo) + } + if evt.Image.Tag != "v1.2.3" { + t.Errorf("Tag=%q want v1.2.3", evt.Image.Tag) + } +} + +func TestBuildInboundEventGitPush(t *testing.T) { + body := []byte(`{ + "ref":"refs/heads/main", + "after":"abc123", + "repository":{"full_name":"owner/repo","clone_url":"https://example.com/owner/repo.git"}, + "pusher":{"name":"alice"} + }`) + evt, err := buildInboundEvent(body, http.Header{}) + if err != nil { + t.Fatalf("unexpected err: %v", err) + } + if evt.Kind != "git-push" || evt.Git == nil { + t.Fatalf("expected git-push, got kind=%q git=%+v", evt.Kind, evt.Git) + } + if evt.Git.Repo != "owner/repo" { + t.Errorf("Repo=%q want owner/repo", evt.Git.Repo) + } + if evt.Git.Branch != "main" { + t.Errorf("Branch=%q want main", evt.Git.Branch) + } + if evt.Git.CommitSHA != "abc123" { + t.Errorf("CommitSHA=%q want abc123", evt.Git.CommitSHA) + } + if evt.Git.Pusher != "alice" { + t.Errorf("Pusher=%q want alice", evt.Git.Pusher) + } +} + +func TestBuildInboundEventGitTag(t *testing.T) { + body := []byte(`{"ref":"refs/tags/v2.0.0","after":"deadbeef"}`) + evt, err := buildInboundEvent(body, http.Header{}) + if err != nil { + t.Fatalf("unexpected err: %v", err) + } + if evt.Kind != "git-tag" { + t.Errorf("kind=%q want git-tag", evt.Kind) + } + if evt.Git == nil || evt.Git.Tag != "v2.0.0" { + t.Errorf("expected tag=v2.0.0, got %+v", evt.Git) + } + if evt.Git.Branch != "" { + t.Errorf("tag event should not have Branch, got %q", evt.Git.Branch) + } +} + +func TestBuildInboundEventInvalidJSON(t *testing.T) { + if _, err := buildInboundEvent([]byte(`not json`), http.Header{}); err == nil { + t.Fatal("expected error on bad JSON") + } +} + +func TestBuildInboundEventMissingFields(t *testing.T) { + // Body present but neither image nor ref — caller must get a useful + // error pointing at the missing field rather than a silent default. + if _, err := buildInboundEvent([]byte(`{"foo":"bar"}`), http.Header{}); err == nil { + t.Fatal("expected error on payload without image or ref") + } +} diff --git a/internal/webhook/vendor_parsers.go b/internal/webhook/vendor_parsers.go new file mode 100644 index 0000000..71001fa --- /dev/null +++ b/internal/webhook/vendor_parsers.go @@ -0,0 +1,390 @@ +package webhook + +import ( + "encoding/json" + "fmt" + "net/http" + "strings" + + "github.com/alexei/tinyforge/internal/workload/plugin" +) + +// Vendor headers we recognize for short-circuit dispatch. The header +// presence (not just value) is the trigger — vendors version event names +// over time so matching on value alone is brittle. +const ( + headerGiteaEvent = "X-Gitea-Event" + headerGiteaDelivery = "X-Gitea-Delivery" + headerGitHubEvent = "X-GitHub-Event" + headerGitLabEvent = "X-Gitlab-Event" +) + +// vendorParseResult bundles the parsed event with an "ok" flag distinct +// from the error: ok=false means "this parser doesn't apply to the +// request, try the next one"; ok=true with err!=nil means "this parser +// applied but the payload was malformed." +type vendorParseResult struct { + event plugin.InboundEvent + ok bool + err error +} + +// tryVendorParsers runs each vendor-specific parser in order and returns +// the first one that recognizes the payload. ok=false means no vendor +// parser claimed the request — caller should fall back to the generic +// simple-body parser. +func tryVendorParsers(body []byte, headers http.Header) vendorParseResult { + for _, fn := range []func([]byte, http.Header) vendorParseResult{ + parseGiteaPackageEvent, + parseGitHubPackageEvent, + parseGitHubPushEvent, + parseGiteaPushEvent, + parseGitLabPushEvent, + } { + res := fn(body, headers) + if res.ok { + return res + } + } + return vendorParseResult{} +} + +// parseGiteaPackageEvent recognizes Gitea container-registry package +// pushes. Distinguishing fingerprint: X-Gitea-Event: package and a body +// with package.type == "container". +// +// Reference: https://docs.gitea.com/usage/webhooks +// +// Body excerpt: +// +// { +// "action": "created", +// "package": { +// "name": "my-app", +// "type": "container", +// "version": "v1.2.3", +// "owner": {"login": "alexei"}, +// "repository": {"full_name": "alexei/my-app"} +// } +// } +func parseGiteaPackageEvent(body []byte, headers http.Header) vendorParseResult { + if headers.Get(headerGiteaEvent) != "package" { + return vendorParseResult{} + } + var probe struct { + Action string `json:"action"` + Package struct { + Name string `json:"name"` + Type string `json:"type"` + Version string `json:"version"` + Owner struct { + Login string `json:"login"` + Username string `json:"username"` + } `json:"owner"` + Repository struct { + FullName string `json:"full_name"` + HTMLURL string `json:"html_url"` + } `json:"repository"` + } `json:"package"` + Sender struct { + Login string `json:"login"` + Username string `json:"username"` + } `json:"sender"` + } + if err := json.Unmarshal(body, &probe); err != nil { + return vendorParseResult{ok: true, err: fmt.Errorf("invalid Gitea package JSON")} + } + if !strings.EqualFold(probe.Package.Type, "container") { + // Non-container package (npm, maven, generic, etc.) — Tinyforge + // only deploys containers today, so drop these as not-applicable. + // Return ok=true so we don't fall through to the simple-body + // parser and accidentally pick up the "version" field as a ref. + return vendorParseResult{ok: true, err: fmt.Errorf("Gitea package type %q is not a container", probe.Package.Type)} + } + if probe.Package.Name == "" || probe.Package.Version == "" { + return vendorParseResult{ok: true, err: fmt.Errorf("Gitea package event missing name/version")} + } + owner := probe.Package.Owner.Login + if owner == "" { + owner = probe.Package.Owner.Username + } + repo := probe.Package.Name + if owner != "" { + repo = owner + "/" + probe.Package.Name + } + registry := registryHostFromGiteaRepoURL(probe.Package.Repository.HTMLURL) + + evt := plugin.InboundEvent{ + Kind: "image-push", + Image: &plugin.ImagePushEvent{ + Registry: registry, + Repo: repo, + Tag: probe.Package.Version, + }, + } + return vendorParseResult{event: evt, ok: true} +} + +// registryHostFromGiteaRepoURL extracts the host portion of a Gitea +// repository URL. Gitea container registry lives on the same host, so +// the repo URL host is a reliable proxy for the registry host. +func registryHostFromGiteaRepoURL(repoURL string) string { + if repoURL == "" { + return "" + } + // Strip scheme. + if i := strings.Index(repoURL, "://"); i >= 0 { + repoURL = repoURL[i+3:] + } + if i := strings.Index(repoURL, "/"); i >= 0 { + repoURL = repoURL[:i] + } + return repoURL +} + +// parseGitHubPackageEvent recognizes GitHub Container Registry (ghcr.io) +// publish events. Two event names exist depending on the year the webhook +// was registered: "package" and "registry_package". Both share the same +// body shape under registry_package. +// +// Reference: +// - https://docs.github.com/en/webhooks/webhook-events-and-payloads#package +// - https://docs.github.com/en/webhooks/webhook-events-and-payloads#registry_package +// +// Body excerpt: +// +// { +// "action": "published", +// "registry_package": { +// "name": "my-app", +// "namespace": "owner", +// "package_type": "CONTAINER", +// "package_version": { +// "container_metadata": { "tag": { "name": "v1.2.3" } }, +// "name": "sha256:..." +// }, +// "registry": { "url": "https://ghcr.io/..." } +// } +// } +func parseGitHubPackageEvent(body []byte, headers http.Header) vendorParseResult { + ev := headers.Get(headerGitHubEvent) + if ev != "package" && ev != "registry_package" { + return vendorParseResult{} + } + // GitHub sends the body under either "package" or "registry_package" + // depending on event name; tolerate both by probing for whichever is + // populated. + var probe struct { + Action string `json:"action"` + Package json.RawMessage `json:"package"` + RegistryPackage json.RawMessage `json:"registry_package"` + } + if err := json.Unmarshal(body, &probe); err != nil { + return vendorParseResult{ok: true, err: fmt.Errorf("invalid GitHub package JSON")} + } + raw := probe.RegistryPackage + if len(raw) == 0 { + raw = probe.Package + } + if len(raw) == 0 { + return vendorParseResult{ok: true, err: fmt.Errorf("GitHub package event missing package body")} + } + var pkg struct { + Name string `json:"name"` + Namespace string `json:"namespace"` + PackageType string `json:"package_type"` + PackageVersion struct { + Name string `json:"name"` + Version string `json:"version"` + ContainerMetadata struct { + Tag struct { + Name string `json:"name"` + } `json:"tag"` + } `json:"container_metadata"` + } `json:"package_version"` + Registry struct { + URL string `json:"url"` + } `json:"registry"` + } + if err := json.Unmarshal(raw, &pkg); err != nil { + return vendorParseResult{ok: true, err: fmt.Errorf("invalid GitHub package payload")} + } + if !strings.EqualFold(pkg.PackageType, "CONTAINER") && !strings.EqualFold(pkg.PackageType, "container") { + return vendorParseResult{ok: true, err: fmt.Errorf("GitHub package type %q is not a container", pkg.PackageType)} + } + tag := pkg.PackageVersion.ContainerMetadata.Tag.Name + digest := "" + // PackageVersion.Name is "sha256:..." for container versions. + if strings.HasPrefix(pkg.PackageVersion.Name, "sha256:") { + digest = pkg.PackageVersion.Name + } + if tag == "" && digest == "" { + return vendorParseResult{ok: true, err: fmt.Errorf("GitHub package event missing tag and digest")} + } + repo := pkg.Name + if pkg.Namespace != "" { + repo = pkg.Namespace + "/" + pkg.Name + } + evt := plugin.InboundEvent{ + Kind: "image-push", + Image: &plugin.ImagePushEvent{ + Registry: registryHostFromGitHubRegistry(pkg.Registry.URL), + Repo: repo, + Tag: tag, + Digest: digest, + }, + } + return vendorParseResult{event: evt, ok: true} +} + +// registryHostFromGitHubRegistry pulls the host from a GitHub registry +// URL ("https://ghcr.io/..." → "ghcr.io"). Defaults to "ghcr.io" since +// that's the only host GitHub uses for container packages today. +func registryHostFromGitHubRegistry(rawURL string) string { + host := registryHostFromGiteaRepoURL(rawURL) // same scheme/host stripping + if host == "" { + return "ghcr.io" + } + return host +} + +// parseGitHubPushEvent handles the standard GitHub push event with a +// vendor annotation. The generic parser already covers the body shape +// (ref / after / repository.full_name / pusher.name) — this exists so +// the resulting GitEvent.Vendor is populated and trigger plugins can +// distinguish GitHub from Gitea without re-parsing headers. +func parseGitHubPushEvent(body []byte, headers http.Header) vendorParseResult { + if headers.Get(headerGitHubEvent) != "push" { + return vendorParseResult{} + } + evt, err := parseGenericGitPush(body) + if err != nil { + return vendorParseResult{ok: true, err: err} + } + if evt.Git != nil { + evt.Git.Vendor = "github" + } + return vendorParseResult{event: evt, ok: true} +} + +// parseGiteaPushEvent — same idea, for Gitea. Gitea push events share +// shape with GitHub's (Gitea explicitly modeled them). +func parseGiteaPushEvent(body []byte, headers http.Header) vendorParseResult { + if headers.Get(headerGiteaEvent) != "push" { + return vendorParseResult{} + } + evt, err := parseGenericGitPush(body) + if err != nil { + return vendorParseResult{ok: true, err: err} + } + if evt.Git != nil { + evt.Git.Vendor = "gitea" + } + return vendorParseResult{event: evt, ok: true} +} + +// parseGitLabPushEvent handles GitLab's push event, which uses a +// different body shape than GitHub/Gitea: +// +// { +// "ref": "refs/heads/main", +// "after": "abc123", +// "user_username": "alice", +// "project": { "path_with_namespace": "owner/repo" } +// } +// +// Reference: https://docs.gitlab.com/user/project/integrations/webhook_events/ +func parseGitLabPushEvent(body []byte, headers http.Header) vendorParseResult { + ev := headers.Get(headerGitLabEvent) + if ev != "Push Hook" && ev != "Tag Push Hook" { + return vendorParseResult{} + } + var probe struct { + Ref string `json:"ref"` + After string `json:"after"` + UserName string `json:"user_username"` + UserNameAlt string `json:"user_name"` + Project struct { + PathWithNamespace string `json:"path_with_namespace"` + GitHTTPURL string `json:"git_http_url"` + } `json:"project"` + } + if err := json.Unmarshal(body, &probe); err != nil { + return vendorParseResult{ok: true, err: fmt.Errorf("invalid GitLab push JSON")} + } + if probe.Ref == "" { + return vendorParseResult{ok: true, err: fmt.Errorf("GitLab push event missing ref")} + } + pusher := probe.UserName + if pusher == "" { + pusher = probe.UserNameAlt + } + evt := plugin.InboundEvent{ + Kind: "git-push", + Git: &plugin.GitEvent{ + Vendor: "gitlab", + Repo: probe.Project.PathWithNamespace, + Ref: probe.Ref, + CommitSHA: probe.After, + Pusher: pusher, + }, + } + if strings.HasPrefix(probe.Ref, "refs/heads/") { + evt.Git.Branch = strings.TrimPrefix(probe.Ref, "refs/heads/") + } + if strings.HasPrefix(probe.Ref, "refs/tags/") { + evt.Git.Tag = strings.TrimPrefix(probe.Ref, "refs/tags/") + evt.Kind = "git-tag" + } + return vendorParseResult{event: evt, ok: true} +} + +// parseGenericGitPush extracts a GitEvent from a GitHub/Gitea-style push +// body. Shared by the GitHub and Gitea vendor wrappers and by the +// generic fallback in buildInboundEvent. +func parseGenericGitPush(body []byte) (plugin.InboundEvent, error) { + var probe struct { + Ref string `json:"ref"` + After string `json:"after"` + Repository struct { + FullName string `json:"full_name"` + CloneURL string `json:"clone_url"` + } `json:"repository"` + Repo string `json:"repo"` + Pusher struct { + Name string `json:"name"` + Username string `json:"username"` + } `json:"pusher"` + } + if err := json.Unmarshal(body, &probe); err != nil { + return plugin.InboundEvent{}, fmt.Errorf("invalid git push JSON") + } + if probe.Ref == "" { + return plugin.InboundEvent{}, fmt.Errorf("git push event missing ref") + } + repo := probe.Repository.FullName + if repo == "" { + repo = probe.Repo + } + pusher := probe.Pusher.Name + if pusher == "" { + pusher = probe.Pusher.Username + } + evt := plugin.InboundEvent{ + Kind: "git-push", + Git: &plugin.GitEvent{ + Repo: repo, + Ref: probe.Ref, + CommitSHA: probe.After, + Pusher: pusher, + }, + } + if strings.HasPrefix(probe.Ref, "refs/heads/") { + evt.Git.Branch = strings.TrimPrefix(probe.Ref, "refs/heads/") + } + if strings.HasPrefix(probe.Ref, "refs/tags/") { + evt.Git.Tag = strings.TrimPrefix(probe.Ref, "refs/tags/") + evt.Kind = "git-tag" + } + return evt, nil +} diff --git a/internal/webhook/vendor_parsers_test.go b/internal/webhook/vendor_parsers_test.go new file mode 100644 index 0000000..545f06d --- /dev/null +++ b/internal/webhook/vendor_parsers_test.go @@ -0,0 +1,291 @@ +package webhook + +import ( + "net/http" + "strings" + "testing" +) + +func headerWith(k, v string) http.Header { + h := http.Header{} + h.Set(k, v) + return h +} + +func TestParseGiteaPackageEvent_Container(t *testing.T) { + body := []byte(`{ + "action": "created", + "package": { + "name": "my-app", + "type": "container", + "version": "v1.2.3", + "owner": {"login": "alexei"}, + "repository": {"full_name": "alexei/my-app", "html_url": "https://git.example.com/alexei/my-app"} + } + }`) + res := parseGiteaPackageEvent(body, headerWith(headerGiteaEvent, "package")) + if !res.ok { + t.Fatalf("expected ok=true") + } + if res.err != nil { + t.Fatalf("unexpected err: %v", res.err) + } + if res.event.Kind != "image-push" || res.event.Image == nil { + t.Fatalf("kind=%q image=%+v", res.event.Kind, res.event.Image) + } + if res.event.Image.Repo != "alexei/my-app" { + t.Errorf("Repo=%q want alexei/my-app", res.event.Image.Repo) + } + if res.event.Image.Tag != "v1.2.3" { + t.Errorf("Tag=%q want v1.2.3", res.event.Image.Tag) + } + if res.event.Image.Registry != "git.example.com" { + t.Errorf("Registry=%q want git.example.com", res.event.Image.Registry) + } +} + +func TestParseGiteaPackageEvent_NonContainerRejected(t *testing.T) { + body := []byte(`{"action": "created", "package": {"name": "lib", "type": "npm", "version": "1.0.0"}}`) + res := parseGiteaPackageEvent(body, headerWith(headerGiteaEvent, "package")) + if !res.ok { + t.Fatalf("expected ok=true (claimed)") + } + if res.err == nil || !strings.Contains(res.err.Error(), "container") { + t.Errorf("expected container-type error, got %v", res.err) + } +} + +func TestParseGiteaPackageEvent_NoHeaderSkips(t *testing.T) { + res := parseGiteaPackageEvent([]byte(`{}`), http.Header{}) + if res.ok { + t.Errorf("expected ok=false when header missing") + } +} + +func TestParseGitHubPackageEvent_RegistryPackage(t *testing.T) { + body := []byte(`{ + "action": "published", + "registry_package": { + "name": "my-app", + "namespace": "owner", + "package_type": "CONTAINER", + "package_version": { + "name": "sha256:deadbeef", + "container_metadata": {"tag": {"name": "v2.0.0"}} + }, + "registry": {"url": "https://ghcr.io/owner/my-app"} + } + }`) + res := parseGitHubPackageEvent(body, headerWith(headerGitHubEvent, "registry_package")) + if !res.ok || res.err != nil { + t.Fatalf("ok=%v err=%v", res.ok, res.err) + } + if res.event.Image == nil || res.event.Image.Tag != "v2.0.0" { + t.Errorf("Tag mismatch: %+v", res.event.Image) + } + if res.event.Image.Digest != "sha256:deadbeef" { + t.Errorf("Digest=%q want sha256:deadbeef", res.event.Image.Digest) + } + if res.event.Image.Repo != "owner/my-app" { + t.Errorf("Repo=%q want owner/my-app", res.event.Image.Repo) + } + if res.event.Image.Registry != "ghcr.io" { + t.Errorf("Registry=%q want ghcr.io", res.event.Image.Registry) + } +} + +func TestParseGitHubPackageEvent_PackageAlias(t *testing.T) { + // Older webhooks deliver under "package" with event name "package". + body := []byte(`{ + "action": "published", + "package": { + "name": "img", + "namespace": "org", + "package_type": "container", + "package_version": {"container_metadata": {"tag": {"name": "latest"}}}, + "registry": {"url": "https://ghcr.io/"} + } + }`) + res := parseGitHubPackageEvent(body, headerWith(headerGitHubEvent, "package")) + if !res.ok || res.err != nil { + t.Fatalf("ok=%v err=%v", res.ok, res.err) + } + if res.event.Image.Repo != "org/img" { + t.Errorf("Repo=%q want org/img", res.event.Image.Repo) + } + if res.event.Image.Tag != "latest" { + t.Errorf("Tag=%q want latest", res.event.Image.Tag) + } +} + +func TestParseGitHubPushEvent_StampsVendor(t *testing.T) { + body := []byte(`{ + "ref": "refs/heads/main", + "after": "abc", + "repository": {"full_name": "owner/repo"}, + "pusher": {"name": "alice"} + }`) + res := parseGitHubPushEvent(body, headerWith(headerGitHubEvent, "push")) + if !res.ok || res.err != nil { + t.Fatalf("ok=%v err=%v", res.ok, res.err) + } + if res.event.Git == nil || res.event.Git.Vendor != "github" { + t.Errorf("Vendor=%q want github (git=%+v)", "", res.event.Git) + } + if res.event.Git.Branch != "main" { + t.Errorf("Branch=%q want main", res.event.Git.Branch) + } +} + +func TestParseGiteaPushEvent_StampsVendor(t *testing.T) { + body := []byte(`{ + "ref": "refs/tags/v1.0", + "after": "deadbeef", + "repository": {"full_name": "alexei/app"}, + "pusher": {"username": "alexei"} + }`) + res := parseGiteaPushEvent(body, headerWith(headerGiteaEvent, "push")) + if !res.ok || res.err != nil { + t.Fatalf("ok=%v err=%v", res.ok, res.err) + } + if res.event.Kind != "git-tag" { + t.Errorf("Kind=%q want git-tag", res.event.Kind) + } + if res.event.Git.Vendor != "gitea" { + t.Errorf("Vendor=%q want gitea", res.event.Git.Vendor) + } + if res.event.Git.Tag != "v1.0" { + t.Errorf("Tag=%q want v1.0", res.event.Git.Tag) + } + if res.event.Git.Pusher != "alexei" { + t.Errorf("Pusher=%q want alexei", res.event.Git.Pusher) + } +} + +func TestParseGitLabPushEvent(t *testing.T) { + body := []byte(`{ + "ref": "refs/heads/develop", + "after": "feedface", + "user_username": "bob", + "project": {"path_with_namespace": "group/proj", "git_http_url": "https://gitlab.example.com/group/proj.git"} + }`) + res := parseGitLabPushEvent(body, headerWith(headerGitLabEvent, "Push Hook")) + if !res.ok || res.err != nil { + t.Fatalf("ok=%v err=%v", res.ok, res.err) + } + if res.event.Git.Vendor != "gitlab" { + t.Errorf("Vendor=%q want gitlab", res.event.Git.Vendor) + } + if res.event.Git.Repo != "group/proj" { + t.Errorf("Repo=%q want group/proj", res.event.Git.Repo) + } + if res.event.Git.Branch != "develop" { + t.Errorf("Branch=%q want develop", res.event.Git.Branch) + } + if res.event.Git.Pusher != "bob" { + t.Errorf("Pusher=%q want bob", res.event.Git.Pusher) + } +} + +func TestParseGitLabTagPushEvent(t *testing.T) { + body := []byte(`{ + "ref": "refs/tags/v3", + "after": "abc", + "user_name": "carol", + "project": {"path_with_namespace": "g/p"} + }`) + res := parseGitLabPushEvent(body, headerWith(headerGitLabEvent, "Tag Push Hook")) + if !res.ok || res.err != nil { + t.Fatalf("ok=%v err=%v", res.ok, res.err) + } + if res.event.Kind != "git-tag" || res.event.Git.Tag != "v3" { + t.Errorf("Tag mismatch: kind=%q git=%+v", res.event.Kind, res.event.Git) + } + if res.event.Git.Pusher != "carol" { + t.Errorf("Pusher=%q want carol (user_name fallback)", res.event.Git.Pusher) + } +} + +func TestBuildInboundEvent_GiteaPackageRouted(t *testing.T) { + body := []byte(`{ + "action": "created", + "package": { + "name": "svc", + "type": "container", + "version": "v9", + "owner": {"login": "alexei"}, + "repository": {"html_url": "https://git.example.com/alexei/svc"} + } + }`) + evt, err := buildInboundEvent(body, headerWith(headerGiteaEvent, "package")) + if err != nil { + t.Fatalf("unexpected err: %v", err) + } + if evt.Image == nil || evt.Image.Tag != "v9" { + t.Errorf("expected Gitea-routed image-push, got %+v", evt) + } + // RawBody and Headers should round-trip even via the vendor branch. + if len(evt.RawBody) == 0 { + t.Errorf("RawBody should be attached") + } + if http.Header(evt.Headers).Get(headerGiteaEvent) != "package" { + t.Errorf("Headers not attached") + } +} + +func TestBuildInboundEvent_FallbackToGeneric(t *testing.T) { + // No vendor header — generic simple-body parser still works. + body := []byte(`{"image":"reg.example.com/x/y:tag"}`) + evt, err := buildInboundEvent(body, http.Header{}) + if err != nil { + t.Fatalf("unexpected err: %v", err) + } + if evt.Image == nil || evt.Image.Tag != "tag" { + t.Errorf("generic fallback failed: %+v", evt) + } +} + +func TestBuildInboundEvent_GenericRefStillSupported(t *testing.T) { + body := []byte(`{"ref":"refs/heads/main","repository":{"full_name":"a/b"},"after":"sha"}`) + evt, err := buildInboundEvent(body, http.Header{}) + if err != nil { + t.Fatalf("unexpected err: %v", err) + } + if evt.Kind != "git-push" || evt.Git == nil || evt.Git.Branch != "main" { + t.Errorf("generic ref parse failed: %+v", evt) + } + // Generic path does NOT stamp a vendor — only the vendor-header + // paths do, so trigger plugins can tell them apart. + if evt.Git.Vendor != "" { + t.Errorf("generic parser should leave Vendor empty, got %q", evt.Git.Vendor) + } +} + +func TestBuildInboundEvent_VendorErrorDoesNotFallThrough(t *testing.T) { + // A request with the Gitea header but a non-container package should + // error out cleanly rather than fall through to the generic parser + // (which would silently accept the body as JSON-with-no-fields). + body := []byte(`{"package":{"name":"x","type":"npm","version":"1.0"}}`) + _, err := buildInboundEvent(body, headerWith(headerGiteaEvent, "package")) + if err == nil { + t.Fatal("expected error for non-container Gitea package") + } + if !strings.Contains(err.Error(), "container") { + t.Errorf("error should mention container, got %v", err) + } +} + +func TestRegistryHostFromGiteaRepoURL(t *testing.T) { + cases := []struct{ in, out string }{ + {"https://git.example.com/owner/repo", "git.example.com"}, + {"http://localhost:3000/o/r", "localhost:3000"}, + {"git.example.com/owner/repo", "git.example.com"}, + {"", ""}, + } + for _, c := range cases { + got := registryHostFromGiteaRepoURL(c.in) + if got != c.out { + t.Errorf("registryHostFromGiteaRepoURL(%q) = %q, want %q", c.in, got, c.out) + } + } +}