// 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/` 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) }