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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user