82d32181ba
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>
391 lines
12 KiB
Go
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
|
|
}
|