300 lines
6.6 KiB
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"`
|
|
}
|