Files
hyperguild/ingestion/internal/webhook/webhook.go
T
mathiasandClaude Opus 4.8 fcbd1072b6
CI / Lint / Test / Vet (push) Successful in 16s
CI / Mirror to GitHub (push) Has been skipped
fix(lint): check fmt.Fprintf errors in webhook handler (infra#190)
errcheck flagged two unchecked fmt.Fprintf return values in
internal/webhook/webhook.go, failing CI (run 310/311) for the whole
'trigger brain-sync on Gitea push' feature -- so no new ingestion image was
ever built, and the deployed image predates this feature entirely even
though infra's manifest (secret, RBAC, env wiring) was already live.

Both writes are best-effort informational text after WriteHeader has already
committed the status code -- a failed write here only happens on client
disconnect and nothing depends on it succeeding, so explicitly discard
(_, _ =) rather than log-and-continue noise.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-09 08:29:39 +02:00

110 lines
3.5 KiB
Go

// Package webhook triggers an on-demand brain-sync Job when Gitea pushes to
// mathias/brain, instead of waiting for the next 15-minute CronJob poll.
package webhook
import (
"context"
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"io"
"log/slog"
"net/http"
batchv1 "k8s.io/api/batch/v1"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/client-go/kubernetes"
)
// VerifySignature checks a Gitea webhook's X-Gitea-Signature header: a
// hex-encoded HMAC-SHA256 of the raw request body, keyed by the shared
// webhook secret. Constant-time compare — timing must not leak how much of
// the signature matched.
func VerifySignature(payload []byte, signatureHeader, secret string) bool {
if signatureHeader == "" {
return false
}
mac := hmac.New(sha256.New, []byte(secret))
mac.Write(payload)
expected := hex.EncodeToString(mac.Sum(nil))
return hmac.Equal([]byte(expected), []byte(signatureHeader))
}
// pushEvent is the subset of Gitea's push webhook payload this handler needs.
type pushEvent struct {
Ref string `json:"ref"`
Repo struct {
FullName string `json:"full_name"`
} `json:"repository"`
}
// TriggerJobFromCronJob reads the named CronJob's job template and creates a
// new, uniquely-named Job from it — the same thing `kubectl create job
// --from=cronjob/<name>` does. Reuses the CronJob's already-tested script
// rather than re-implementing sync logic here.
func TriggerJobFromCronJob(ctx context.Context, cs kubernetes.Interface, namespace, cronJobName string) (string, error) {
cj, err := cs.BatchV1().CronJobs(namespace).Get(ctx, cronJobName, metav1.GetOptions{})
if err != nil {
return "", fmt.Errorf("get cronjob %s/%s: %w", namespace, cronJobName, err)
}
job := &batchv1.Job{
ObjectMeta: metav1.ObjectMeta{
GenerateName: cronJobName + "-webhook-",
Namespace: namespace,
Annotations: map[string]string{
"triggered-by": "brain-webhook",
},
},
Spec: cj.Spec.JobTemplate.Spec,
}
created, err := cs.BatchV1().Jobs(namespace).Create(ctx, job, metav1.CreateOptions{})
if err != nil {
return "", fmt.Errorf("create job from cronjob %s/%s: %w", namespace, cronJobName, err)
}
return created.Name, nil
}
// Handler is the HTTP handler for Gitea's push webhook on mathias/brain.
type Handler struct {
Secret string
Clientset kubernetes.Interface
Namespace string // e.g. "brain"
CronJobName string // e.g. "brain-sync"
WatchRepo string // e.g. "mathias/brain"
Logger *slog.Logger
}
func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
body, err := io.ReadAll(r.Body)
if err != nil {
http.Error(w, "bad body", http.StatusBadRequest)
return
}
sig := r.Header.Get("X-Gitea-Signature")
if !VerifySignature(body, sig, h.Secret) {
http.Error(w, "bad signature", http.StatusUnauthorized)
return
}
var ev pushEvent
if err := json.Unmarshal(body, &ev); err != nil {
http.Error(w, "bad payload", http.StatusBadRequest)
return
}
if ev.Repo.FullName != h.WatchRepo || ev.Ref != "refs/heads/main" {
w.WriteHeader(http.StatusOK)
_, _ = fmt.Fprintf(w, "ignored: repo=%s ref=%s", ev.Repo.FullName, ev.Ref)
return
}
jobName, err := TriggerJobFromCronJob(r.Context(), h.Clientset, h.Namespace, h.CronJobName)
if err != nil {
h.Logger.Error("webhook: trigger job failed", "err", err)
http.Error(w, "trigger failed", http.StatusInternalServerError)
return
}
h.Logger.Info("webhook: triggered brain-sync job", "job", jobName)
w.WriteHeader(http.StatusOK)
_, _ = fmt.Fprintf(w, "triggered %s", jobName)
}