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"` }