repro-run/gitea-ci/webhook.go

300 lines
6.6 KiB
Go

package main
import (
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"io"
"log"
"net/http"
"net/url"
"os"
"os/exec"
"path"
"sync"
"gitea.nulo.in/Nulo/repro-run/gitea-ci/gitea"
"gitea.nulo.in/Nulo/repro-run/runner"
"golang.org/x/exp/slices"
)
type run struct {
mutex sync.Mutex
finished bool
previous []byte
writers []io.WriteCloser
}
func (r *run) Write(p []byte) (n int, err error) {
r.mutex.Lock()
defer r.mutex.Unlock()
r.previous = append(r.previous, p...)
var writers []io.Writer
for _, w := range r.writers {
writers = append(writers, w)
}
n, err = io.MultiWriter(writers...).Write(slices.Clone(p))
return len(p), nil
}
func (r *run) addWriter(w io.WriteCloser) (err error) {
r.mutex.Lock()
defer r.mutex.Unlock()
w.Write(slices.Clone(r.previous))
if r.finished {
w.Close()
} else {
r.writers = append(r.writers, w)
}
return
}
func (r *run) finishRun() {
r.mutex.Lock()
defer r.mutex.Unlock()
r.finished = true
for _, c := range r.writers {
c.Close()
}
}
type runs struct {
things map[string]*run
mutex sync.Mutex
}
func (r *runs) newRun(id string) *run {
r.mutex.Lock()
defer r.mutex.Unlock()
_, found := r.things[id]
if found {
log.Fatalf("run %s already exists", id)
}
rr := &run{}
r.things[id] = rr
return rr
}
func (r *runs) getRun(id string) *run {
r.mutex.Lock()
defer r.mutex.Unlock()
return r.things[id]
}
var runss = runs{
things: make(map[string]*run),
}
var wgs map[string]*sync.WaitGroup = make(map[string]*sync.WaitGroup)
var wgsMutex sync.Mutex
type webhook struct {
config config
gitea gitea.Gitea
}
func (h webhook) ServeHTTP(w http.ResponseWriter, req *http.Request) {
if req.Method != "POST" {
http.Error(w, "not POST", http.StatusMethodNotAllowed)
return
}
payload, err := io.ReadAll(req.Body)
if err != nil {
return
}
// https://github.com/go-gitea/gitea/blob/0f4e1b9ac66b8ffa0083a5a2516e4710393bb0da/services/webhook/deliver.go#L112
sig256 := hmac.New(sha256.New, []byte(h.config.giteaWebhookSecret))
_, err = sig256.Write(payload)
if err != nil {
log.Println(err)
http.Error(w, "error", http.StatusInternalServerError)
return
}
sig := hex.EncodeToString(sig256.Sum(nil))
if sig != req.Header.Get("X-Gitea-Signature") {
http.Error(w, "HMAC doesn't match", http.StatusUnauthorized)
return
}
event := req.Header.Get("X-Gitea-Event")
if event != "push" {
log.Printf("event not push")
return
}
var hook recWebhook
err = json.Unmarshal(payload, &hook)
if err != nil {
http.Error(w, "invalid JSON", http.StatusBadRequest)
return
}
if !slices.Contains(h.config.allowedUsers, hook.Repository.Owner.Username) {
log.Printf("username %s not allowed", hook.Repository.Owner.Username)
return
}
var runnerConfig *runner.Config
if runnerConfig = h.getConfig(hook, w); runnerConfig == nil {
return
}
runId := req.Header.Get("X-Gitea-Delivery")
runUrl := h.config.publicUrl + "/run#" + runId
log.Printf("New run: %s", runUrl)
run := runss.newRun(runId)
defer run.finishRun()
wgsMutex.Lock()
wg := wgs[hook.Repository.FullName]
if wg == nil {
wg = &sync.WaitGroup{}
wgs[hook.Repository.FullName] = wg
}
wgsMutex.Unlock()
wg.Wait()
wg.Add(1)
defer wg.Done()
dir, err := os.MkdirTemp("", "repro-run-gitea-ci-")
if err != nil {
log.Println(err)
http.Error(w, "error", http.StatusInternalServerError)
return
}
defer rmIfNot(dir, h.config.doNotDeleteTmp)
cache := path.Join("cache/", hook.Repository.FullName)
if err = os.MkdirAll(cache, 0700); err != nil {
log.Println(err)
http.Error(w, "error", http.StatusInternalServerError)
return
}
rootfs := path.Join(dir, "rootfs")
if err = os.MkdirAll(rootfs, 0700); err != nil {
log.Println(err)
http.Error(w, "error", http.StatusInternalServerError)
return
}
src := path.Join(cache, "src")
if err = os.MkdirAll(src, 0700); err != nil {
log.Println(err)
http.Error(w, "error", http.StatusInternalServerError)
return
}
var cmd *exec.Cmd
if entries, _ := os.ReadDir(path.Join(src, ".git")); len(entries) > 0 {
cmd = exec.Command("git", "pull")
cmd.Dir = src
} else {
cmd = exec.Command("git", "clone", hook.Repository.CloneUrl, src)
}
if err = cmd.Run(); err != nil {
log.Println(cmd.Args, ":", err)
http.Error(w, "error", http.StatusInternalServerError)
return
}
err = h.gitea.CreateCommitStatus(hook.Repository.FullName, hook.AfterCommit, gitea.CommitStatus{
Context: "repro-run",
Description: "Corre repro-run.json",
State: "pending",
TargetUrl: runUrl,
})
if err != nil {
return
}
query := req.URL.Query()
env := make(map[string]string)
for key := range query {
val := query.Get(key)
env[key] = val
}
err = runner.Run(*runnerConfig, rootfs, cache, src, run, h.config.doNotDeleteTmp, env)
state := "success"
if err != nil {
log.Println("runner.Run", err)
state = "failure"
if unwrapped := errors.Unwrap(err); unwrapped != nil {
if ee, ok := err.(*exec.ExitError); ok {
fmt.Fprintf(run, "\n######### Failed with error code %d", ee.ProcessState.ExitCode())
}
}
}
err = h.gitea.CreateCommitStatus(hook.Repository.FullName, hook.AfterCommit, gitea.CommitStatus{
Context: "repro-run",
Description: "Corre repro-run.json",
State: state,
TargetUrl: runUrl,
})
if err != nil {
return
}
}
func (h webhook) getConfig(hook recWebhook, w http.ResponseWriter) (c *runner.Config) {
u, err := url.Parse(h.config.giteaUrl)
if err != nil {
log.Println("parsing gitea_url", err)
http.Error(w, "error", http.StatusInternalServerError)
return
}
u.Path = path.Join("/api/v1/repos", hook.Repository.Owner.Username, hook.Repository.Name, "raw", "repro-run.json")
q := u.Query()
q.Add("ref", hook.AfterCommit)
u.RawQuery = q.Encode()
res, err := http.Get(u.String())
if err != nil {
log.Println("getting repro-run.json", err)
http.Error(w, "error", http.StatusInternalServerError)
return
}
if res.StatusCode == http.StatusNotFound {
http.Error(w, "no repro-run.json", http.StatusBadRequest)
return
}
if err = json.NewDecoder(res.Body).Decode(&c); err != nil {
log.Println(err)
http.Error(w, "invalid repro-run.json", http.StatusBadRequest)
return
}
return
}
func rmIfNot(dir string, doNotDelete bool) {
if !doNotDelete {
os.RemoveAll(dir)
}
}
type recWebhook struct {
Repository recRepository `json:"repository"`
AfterCommit string `json:"after"`
}
type recRepository struct {
Id uint64 `json:"id"`
Owner recUser `json:"owner"`
CloneUrl string `json:"clone_url"`
FullName string `json:"full_name"`
Name string `json:"name"`
}
type recUser struct {
Username string `json:"username"`
}