Adds POST /webhooks/brain-sync: verifies Gitea's HMAC-SHA256 signature, checks the push is to mathias/brain main, then creates a one-off Job from the existing brain-sync CronJob's template (same script the 15-min poll already runs, just triggered on-demand). Off by default -- opt in via GITEA_WEBHOOK_SECRET, since it needs Job-create RBAC in the "brain" namespace a fresh deploy won't have. 10 new tests (internal/webhook), including a fake-clientset reactor to simulate server-side GenerateName expansion, which the plain fake tracker doesn't do on its own. Needs (follow-up, infra repo): RBAC granting ingestion's ServiceAccount get on cronjobs/brain-sync + create on jobs in the brain namespace, the GITEA_WEBHOOK_SECRET env, and the actual Gitea webhook registration.
110 lines
3.5 KiB
Go
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)
|
|
}
|