Documentation
¶
Overview ¶
Package webhook is the git integration: a GitHub push-webhook receiver that verifies the request signature, extracts the pushed commit SHA, fetches the repository at that SHA to a local checkout, and hands the result to internal/deploy's Pipeline, the same pipeline a manual deploy trigger will use once the HTTP API exists.
Handler is an http.Handler, not a standalone server: the HTTP API package mounts it at whatever path it chooses, so this package must never call http.ListenAndServe itself.
Scope boundary: Config is deliberately static, single-app configuration (one secret, one repo, one branch, one spec.Service). This phase's exit criterion is a single app, thesvg.org, deploying from a git push; this package does not build a multi-tenant "which repo maps to which app.yaml" registry, since designing that now would be exactly the speculative-ahead-of-a-real-second-app work the project's engineering standards warn against. A second app arriving is the trigger to revisit this, not a hypothesis about one arriving.
That second app has now arrived: internal/api's own multi-app git source registry (git_sources.go, git_webhook.go) reuses this package's verified signature/payload logic (VerifySignature, ParsePushEvent, MaxPayloadBytes, Config.TargetRef) rather than duplicating it, but lives in internal/api because turning a stored app back into a full spec.Service already lives there too (specServiceFromDesired). Handler and Config above are unchanged and still back the original single-app, env-var-configured path.
Index ¶
- Constants
- Variables
- func IsMergeGroupEvent(header http.Header) bool
- func IsPullRequestEvent(header http.Header) bool
- func VerifySignature(secret, body []byte, header string) bool
- type AttemptStore
- type Config
- type DeployNotifier
- type Deployer
- type Handler
- type MergeGroupEvent
- type PullRequestAction
- type PullRequestEvent
- type PushEvent
- type ReconcileNudger
Constants ¶
const ( HeaderGitHubEvent = "X-GitHub-Event" HeaderGitLabEvent = "X-Gitlab-Event" HeaderGiteaEventType = "X-Gitea-Event-Type" HeaderBitbucketEventKey = "X-Event-Key" HeaderGitLabToken = "X-Gitlab-Token" //nolint:gosec // header name, not a credential value HeaderHubSignature256 = "X-Hub-Signature-256" HeaderHubSignature = "X-Hub-Signature" HeaderGitHubDelivery = "X-GitHub-Delivery" HeaderGitLabEventUUID = "X-Gitlab-Event-Uuid" HeaderGiteaDelivery = "X-Gitea-Delivery" HeaderBitbucketRequest = "X-Request-Uuid" )
Provider webhook header names, exported so internal/api's multi-app route (git_webhook.go) reads the identical header names this package uses to route and verify a delivery, rather than a second hardcoded copy that could drift out of sync.
const DefaultBranch = "main"
DefaultBranch is used when Config.Branch is empty.
const MaxPayloadBytes = maxPayloadBytes
MaxPayloadBytes is maxPayloadBytes, exported so a per-app git source webhook receiver outside this package (internal/api's multi-app route, a later follow-up: see that package's own git_webhook.go doc comment for why the multi-app handler lives there instead of here) applies GitHub's own protocol size limit identically, rather than picking its own number.
Variables ¶
var ErrPullRequestEventFieldsMissing = errors.New("webhook: pull request payload missing required fields")
ErrPullRequestEventFieldsMissing is returned by a provider-specific parse function when the payload decodes as valid JSON but is missing a field this package needs, or names an action outside this package's normalized vocabulary (normalizePullRequestAction).
var ErrPushEventFieldsMissing = errors.New("webhook: payload missing ref or after")
ErrPushEventFieldsMissing is returned by ParsePushEvent when body decodes as valid JSON but is missing ref or after, distinct from a json.Unmarshal failure so ServeHTTP (and internal/api's multi-app equivalent) can keep reporting the two cases with their own, more specific messages.
Functions ¶
func IsMergeGroupEvent ¶ added in v0.2.0
IsMergeGroupEvent reports whether header names GitHub's merge_group event.
func IsPullRequestEvent ¶
IsPullRequestEvent reports whether header names a provider's own pull request / merge request event, so a caller can route to ParsePullRequestEventForProvider instead of ParsePushEventForProvider before looking at the body at all. GitHub, GitLab, and Gitea each send an unambiguous event-name header on every delivery (X-GitHub-Event, X-Gitlab-Event, X-Gitea-Event-Type); Bitbucket reuses X-Event-Key, already read for its own push detection (ParsePushEventForProvider), just with a "pullrequest:"-prefixed value instead of "repo:push".
func VerifySignature ¶
VerifySignature checks header against an HMAC-SHA256 of body keyed by secret, GitHub's X-Hub-Signature-256 scheme (docs.github.com/webhooks: "validating-webhook-deliveries"). Comparison uses hmac.Equal, constant-time by construction, rather than a plain byte-slice or string comparison, since a timing-observable comparison here would let an attacker recover a valid signature byte by byte.
Types ¶
type AttemptStore ¶
type AttemptStore interface {
SaveDeployAttempt(ctx context.Context, a store.DeployAttempt) error
FinishDeployAttempt(ctx context.Context, id, status string, finishedAt time.Time, errMsg string) error
}
AttemptStore is the narrow store surface Handler needs to record one deploy_attempts row per triggering push: this package is the third, and most important, of the three real deploy-trigger paths this history exists for. An unattended push-to-deploy that fails with no replayable record defeats the point of having a log viewer at all. *store.DB satisfies this structurally.
type Config ¶
type Config struct {
// Secret is the shared HMAC secret configured on the GitHub webhook,
// used to verify the X-Hub-Signature-256 header. Required: a Handler
// built with an empty Secret rejects every request, since an empty
// secret would make the signature check meaningless.
Secret []byte
// RepoURL is the git remote this handler clones on a triggering
// push, e.g. "https://github.com/org/thesvg.org.git". Deliberately
// server-side configuration, never taken from the webhook payload:
// only the commit SHA (a hash, not a URL) comes from the untrusted
// request, so a forged payload cannot redirect a clone at an
// attacker-controlled remote.
RepoURL string
// Branch triggers a deploy on push. Pushes to any other branch are
// accepted (200 OK, so GitHub does not retry) but do not deploy.
// Defaults to DefaultBranch when empty.
Branch string
// ServiceName, Service, and ImageRepo are passed straight through to
// deploy.Request; see internal/deploy for what each means. Ignored
// when Services (below) is non-empty.
ServiceName string
Service spec.Service
ImageRepo string
// Services, when non-empty, switches a triggering push from a
// single-service Deploy call to a multi-service DeploySpec fan-out
// (deploy.MultiRequest): ServiceName above becomes the app name every
// fanned-out service links to, and ImageRepo above becomes the image
// repo base every fanned-out service tags under. This is still
// single-app, env-var-configured Config, exactly like every other
// field here (the package doc comment's own scope boundary): Services
// is one more static, operator-configured value, not a per-push
// choice, so it does not reopen the "no multi-tenant registry" design
// decision that comment already makes.
Services map[string]spec.Service
}
Config is everything needed to map a validated GitHub push into a deploy.Request. See the package doc comment for why this is single-app, not a multi-tenant registry.
func (Config) TargetRef ¶
TargetRef is targetRef, exported so a per-app Config resolved outside this package (internal/api's multi-app webhook route) can compute the identical "refs/heads/<branch>" ref-matching target this package's own ServeHTTP already checks pushes against, without duplicating DefaultBranch's fallback logic a second time.
type DeployNotifier ¶
type DeployNotifier interface {
Dispatch(ctx context.Context, resourceID string, ev alerting.DeployOutcome)
}
DeployNotifier is the narrow surface Handler needs to fire a deploy-outcome notification (Slack/Discord/Telegram/generic-webhook/ email, wave-2 roadmap item #5) once a push-triggered deploy reaches a terminal state: distinct from this package's own git-integration concern, this is deliberately just "hand the finished outcome to whatever internal/alerting.DeployDispatcher does with it," the same narrow-consumer-interface shape AttemptStore above already uses. *alerting.DeployDispatcher satisfies this structurally. nil is valid: Handler.notifier's own doc comment covers what a nil value does.
type Deployer ¶
type Deployer interface {
Deploy(ctx context.Context, req deploy.Request, progress func(build.ProgressEvent)) (string, error)
// DeploySpec is the multi-service fan-out entry point, exercised only
// when Config.Services is non-empty (see Config's own doc comment).
// Same *deploy.Pipeline satisfies both methods.
DeploySpec(ctx context.Context, req deploy.MultiRequest, progress func(serviceKey string, ev build.ProgressEvent)) ([]deploy.ServiceOutcome, error)
}
Deployer is the narrow surface this package needs from internal/deploy.Pipeline, so tests can fake it without a real BuildKit connection or Docker daemon, the same narrow-interface pattern internal/deploy and internal/reconcile/application already use. *deploy.Pipeline satisfies this.
type Handler ¶
type Handler struct {
// contains filtered or unexported fields
}
Handler is an http.Handler that receives GitHub push event payloads, verifies them, and triggers a deploy through Deployer when the push targets Config.Branch.
func New ¶
func New(cfg Config, deployer Deployer, attempts AttemptStore, recorder *deploylog.Recorder, notifier DeployNotifier, nudger ReconcileNudger, log *slog.Logger) *Handler
New builds a Handler. attempts and recorder may both be nil (see Handler.attempts' own doc comment); notifier and nudger may be nil (see their own doc comments); log defaults to slog.Default() if nil.
func (*Handler) ServeHTTP ¶
func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request)
ServeHTTP implements http.Handler. Response codes: 200 for a successfully triggered or correctly-ignored (wrong branch) push, 400 for a malformed payload, 401 for a missing or invalid signature, 500 with a non-leaky message if the deploy itself fails. Deploy progress is logged via slog, not streamed in the response: GitHub expects a fast response and does not wait for one, actual progress streaming to a UI is the HTTP API's job.
func (*Handler) UseSourceCache ¶
func (h *Handler) UseSourceCache(c *sourcecache.Cache)
UseSourceCache routes this handler's fetches through the shared source mirror, so a known commit needs no git host and an outage fails fast.
type MergeGroupEvent ¶ added in v0.2.0
type MergeGroupEvent struct {
// Action is "checks_requested" when the queue wants checks to run, or
// "destroyed" when the group is dissolved.
Action string
HeadSHA string
// HeadRef is the temporary queue branch, a full "refs/heads/..." ref.
HeadRef string
// BaseRef is the branch the group merges into, a full "refs/heads/..." ref.
BaseRef string
BaseSHA string
}
MergeGroupEvent is the subset of GitHub's merge_group webhook payload a merge queue check needs.
func ParseMergeGroupEvent ¶ added in v0.2.0
func ParseMergeGroupEvent(body []byte) (MergeGroupEvent, error)
ParseMergeGroupEvent decodes a GitHub merge_group payload.
func (MergeGroupEvent) BaseBranch ¶ added in v0.2.0
func (e MergeGroupEvent) BaseBranch() string
BaseBranch is BaseRef without its "refs/heads/" prefix.
func (MergeGroupEvent) ChecksRequested ¶ added in v0.2.0
func (e MergeGroupEvent) ChecksRequested() bool
ChecksRequested reports whether the queue is asking for checks.
type PullRequestAction ¶
type PullRequestAction string
PullRequestAction is a normalized pull-request lifecycle event, collapsing each provider's own action vocabulary (GitHub's opened/reopened/synchronize/closed, GitLab's open/reopen/update/ close/merge, Bitbucket's created/updated/fulfilled/rejected event keys) down to the three this package's callers actually branch on.
const ( // PullRequestOpened covers a pull request being opened or reopened: // a fresh preview deploy target. PullRequestOpened PullRequestAction = "opened" // PullRequestSynchronize covers new commits pushed to an // already-open pull request: redeploy its existing preview. PullRequestSynchronize PullRequestAction = "synchronize" // PullRequestClosed covers a pull request being closed, whether // merged or not: tear its preview down either way. PullRequestClosed PullRequestAction = "closed" )
type PullRequestEvent ¶
type PullRequestEvent struct {
Action PullRequestAction
Number int
// HeadRef is the pull request's source branch name, not a full
// "refs/heads/"-prefixed ref: unlike PushEvent.Ref, no provider's
// pull request payload carries one, and a preview deploy only ever
// needs the branch name to name/label the preview, not to match it
// against a target ref.
HeadRef string
HeadSHA string
// BaseRef is the pull request's target branch name, compared against
// a connected git source's own Branch the same way PushEvent.Ref is
// compared against Config.TargetRef: a pull request against any
// other branch is ignored.
BaseRef string
// HeadRepoFullName and BaseRepoFullName are the "owner/name" of the
// repositories the source and target branches live in. Empty when the
// payload carries none (for example a deleted fork).
HeadRepoFullName string
BaseRepoFullName string
// Reopened marks an Opened action that was a reopened pull request
// rather than a new one.
Reopened bool
}
PullRequestEvent is the subset of a provider's pull/merge request webhook payload this package needs to drive a preview environment.
func ParsePullRequestEventForProvider ¶
func ParsePullRequestEventForProvider(body []byte, header http.Header) (PullRequestEvent, error)
ParsePullRequestEventForProvider dispatches to the right provider's own payload shape based on header, the same header-driven dispatch IsPullRequestEvent already uses to decide this function should even be called.
func (PullRequestEvent) IsFork ¶ added in v0.2.0
func (e PullRequestEvent) IsFork() bool
IsFork reports whether the pull request comes from a different repository than it targets. It fails closed: an event that names no head or base repository is treated as a fork, since code from an unknown source must not be trusted with secrets.
type PushEvent ¶
type PushEvent struct {
Ref string `json:"ref"`
After string `json:"after"`
// Before is the commit the branch moved from, the stale-deploy guard's
// strongest ordering signal.
Before string `json:"before"`
// HeadCommitAt is the pushed head commit's timestamp, zero if unknown.
HeadCommitAt time.Time `json:"-"`
// HeadMessage and HeadAuthor are the pushed head commit's message and
// author name, empty when the payload carries none.
HeadMessage string `json:"-"`
HeadAuthor string `json:"-"`
// RepoNames and RepoURLs identify the repository the push is for
// (full_name, path_with_namespace, clone/ssh/html URLs). Empty when
// the payload does not say, which callers must treat as "unknown".
RepoNames []string `json:"-"`
RepoURLs []string `json:"-"`
// Changed lists the files the pushed commits touched. Nil means the
// payload did not say (no commit list, or a list the provider truncates).
Changed []string `json:"-"`
}
PushEvent is the subset of GitHub's push event payload this package needs. See https://docs.github.com/en/webhooks/webhook-events-and-payloads#push. Exported (renamed from the pre-existing unexported pushEvent) so internal/api's multi-app webhook route can decode the identical payload shape through ParsePushEvent below, rather than redeclaring it.
func ParseBitbucketPushEvent ¶
ParseBitbucketPushEvent decodes body as a Bitbucket repo:push webhook payload, normalizing it into the same PushEvent shape ParsePushEvent produces for GitHub/GitLab: Ref gets Bitbucket's bare branch name prefixed with "refs/heads/" so Config.TargetRef's own comparison keeps working unchanged regardless of which provider sent the push. Picks the first change carrying a non-null New (the common case is exactly one), and fails with ErrPushEventFieldsMissing if every change in the payload is a branch delete (New null throughout): there is no commit to deploy in that case, the same "nothing meaningful to act on" reasoning that error already carries for a GitHub/GitLab payload missing ref or after.
func ParsePushEvent ¶
ParsePushEvent decodes body as a GitHub push event payload, requiring both Ref and After to be non-empty (see ServeHTTP's own doc comment: only the commit SHA and target ref matter to this package, everything else in GitHub's much larger push payload is ignored).
func ParsePushEventForProvider ¶
ParsePushEventForProvider dispatches to ParseBitbucketPushEvent when eventKeyHeader (the request's own X-Event-Key value) is Bitbucket's "repo:push", or ParsePushEvent otherwise: GitHub sends no such header (X-GitHub-Event instead, a different name this function deliberately never reads, so a GitHub delivery always falls through to the default case) and GitLab's own X-Gitlab-Event value ("Push Hook") never matches "repo:push" either. Centralizing the branch here, rather than in internal/api's own handler, keeps every provider's payload shape knowledge inside this package, the same "this package owns verified signature/payload logic" boundary handleGitPushWebhook's own doc comment already establishes.
type ReconcileNudger ¶ added in v0.2.0
type ReconcileNudger interface {
Nudge()
}
ReconcileNudger is *reconcile.Engine's own Nudge method, narrowed the same way internal/api.ReconcileNudger is (that interface can't be imported here: internal/api already imports internal/webhook). A push that just deployed a new image calls it so the reconciler doesn't wait up to a full resyncInterval to notice, the same gap that interface's doc comment describes for create/stop/start/restart.