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

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

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

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

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

391 lines
12 KiB
Go

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
}