diff --git a/cli.go b/cli.go index 56014c3..ba99fab 100644 --- a/cli.go +++ b/cli.go @@ -11,7 +11,7 @@ import ( func main() { config, err := readConfig() must(err) - must(runner.Run(config, "rootfs/", "cache/")) + must(runner.Run(config, "rootfs/", "cache/", ".", nil)) } func must(err error, where ...string) { diff --git a/gitea-ci/gitea/commit_status.go b/gitea-ci/gitea/commit_status.go new file mode 100644 index 0000000..b25a689 --- /dev/null +++ b/gitea-ci/gitea/commit_status.go @@ -0,0 +1,56 @@ +package gitea + +import ( + "bytes" + "encoding/json" + "errors" + "io" + "log" + "net/http" + "net/url" + "path" +) + +type CommitStatus struct { + Context string `json:"context"` + Description string `json:"description"` + // State holds the state of a CommitStatus + // It can be "pending", "success", "error", "failure", and "warning" + State string `json:"state"` + TargetUrl string `json:"target_url"` +} + +func (g Gitea) CreateCommitStatus(repo, commit string, status CommitStatus) (err error) { + ur, err := url.Parse(g.Url) + if err != nil { + return + } + ur.Path = path.Join("/api/v1/repos/", repo, "/statuses/", commit) + + payload, err := json.Marshal(status) + if err != nil { + return + } + + req, err := http.NewRequest("POST", ur.String(), bytes.NewReader(payload)) + if err != nil { + return + } + + req.Header.Set("Content-Type", "application/json") + g.setAuth(req) + + res, err := http.DefaultClient.Do(req) + if err != nil { + return + } + + if res.StatusCode != http.StatusCreated { + byt, err := io.ReadAll(res.Body) + log.Printf("%s %v", byt, err) + return errors.New("not 200") + } + + // err = json.NewDecoder(res.Body).Decode(&u) + return +} diff --git a/gitea-ci/gitea/gitea.go b/gitea-ci/gitea/gitea.go new file mode 100644 index 0000000..9e12805 --- /dev/null +++ b/gitea-ci/gitea/gitea.go @@ -0,0 +1,52 @@ +package gitea + +import ( + "encoding/base64" + "encoding/json" + "errors" + "io" + "log" + "net/http" + "net/url" +) + +type User struct { + FullName string `json:"full_name"` + Login string `json:"login"` +} + +type Gitea struct { + Url string + Username string + Password string +} + +func (g Gitea) setAuth(req *http.Request) { + req.Header.Set("Authorization", "Basic "+base64.StdEncoding.EncodeToString([]byte(g.Username+":"+g.Password))) + +} + +func (g Gitea) GetUser() (u User, err error) { + ur, err := url.Parse(g.Url) + if err != nil { + return + } + ur.Path = "/api/v1/user" + req, err := http.NewRequest("GET", ur.String(), nil) + if err != nil { + return + } + g.setAuth(req) + res, err := http.DefaultClient.Do(req) + if err != nil { + return + } + if res.StatusCode != http.StatusOK { + byt, err := io.ReadAll(res.Body) + log.Printf("%s %v", byt, err) + return u, errors.New("not 200") + } + + err = json.NewDecoder(res.Body).Decode(&u) + return +} diff --git a/gitea-ci/go.mod b/gitea-ci/go.mod new file mode 100644 index 0000000..af5ecbd --- /dev/null +++ b/gitea-ci/go.mod @@ -0,0 +1,15 @@ +module gitea.nulo.in/Nulo/repro-run/gitea-ci + +go 1.19 + +require ( + gitea.nulo.in/Nulo/repro-run v0.0.0-20230122131519-07d276b2a3b9 + golang.org/x/exp v0.0.0-20230118134722-a68e582fa157 + nhooyr.io/websocket v1.8.7 +) + +require github.com/klauspost/compress v1.10.3 // indirect + +replace gitea.nulo.in/Nulo/repro-run/gitea-ci/gitea => ./gitea + +replace gitea.nulo.in/Nulo/repro-run => ../ diff --git a/gitea-ci/go.sum b/gitea-ci/go.sum new file mode 100644 index 0000000..b163fc1 --- /dev/null +++ b/gitea-ci/go.sum @@ -0,0 +1,63 @@ +gitea.nulo.in/Nulo/repro-run v0.0.0-20230122131519-07d276b2a3b9 h1:gtm7Z68e/LBFza03lIWZmN8oD/ZGSlkSPdeBlJeSdV0= +gitea.nulo.in/Nulo/repro-run v0.0.0-20230122131519-07d276b2a3b9/go.mod h1:i3bNu9aTQZh6vFjPP+LzC46o9c+Ggohivw2VA8voVgY= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE= +github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI= +github.com/gin-gonic/gin v1.6.3 h1:ahKqKTFpO5KTPHxWZjEdPScmYaGtLo8Y4DMHoEsnp14= +github.com/gin-gonic/gin v1.6.3/go.mod h1:75u5sXoLsGZoRN5Sgbi1eraJ4GU3++wFwWzhwvtwp4M= +github.com/go-playground/assert/v2 v2.0.1/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4= +github.com/go-playground/locales v0.13.0 h1:HyWk6mgj5qFqCT5fjGBuRArbVDfE4hi8+e8ceBS/t7Q= +github.com/go-playground/locales v0.13.0/go.mod h1:taPMhCMXrRLJO55olJkUXHZBHCxTMfnGwq/HNwmWNS8= +github.com/go-playground/universal-translator v0.17.0 h1:icxd5fm+REJzpZx7ZfpaD876Lmtgy7VtROAbHHXk8no= +github.com/go-playground/universal-translator v0.17.0/go.mod h1:UkSxE5sNxxRwHyU+Scu5vgOQjsIJAF8j9muTVoKLVtA= +github.com/go-playground/validator/v10 v10.2.0 h1:KgJ0snyC2R9VXYN2rneOtQcw5aHQB1Vv0sFl1UcHBOY= +github.com/go-playground/validator/v10 v10.2.0/go.mod h1:uOYAAleCW8F/7oMFd6aG0GOhaH6EGOAJShg8Id5JGkI= +github.com/gobwas/httphead v0.0.0-20180130184737-2c6c146eadee h1:s+21KNqlpePfkah2I+gwHF8xmJWRjooY+5248k6m4A0= +github.com/gobwas/httphead v0.0.0-20180130184737-2c6c146eadee/go.mod h1:L0fX3K22YWvt/FAX9NnzrNzcI4wNYi9Yku4O0LKYflo= +github.com/gobwas/pool v0.2.0 h1:QEmUOlnSjWtnpRGHF3SauEiOsy82Cup83Vf2LcMlnc8= +github.com/gobwas/pool v0.2.0/go.mod h1:q8bcK0KcYlCgd9e7WYLm9LpyS+YeLd8JVDW6WezmKEw= +github.com/gobwas/ws v1.0.2 h1:CoAavW/wd/kulfZmSIBt6p24n4j7tHgNVCjsfHVNUbo= +github.com/gobwas/ws v1.0.2/go.mod h1:szmBTxLgaFppYjEmNtny/v3w89xOydFnnZMcgRRu/EM= +github.com/golang/protobuf v1.3.3/go.mod h1:vzj43D7+SQXF/4pzW/hwtAqwc6iTitCiVSaWz5lYuqw= +github.com/golang/protobuf v1.3.5 h1:F768QJ1E9tib+q5Sc8MkdJi1RxLTbRcTf8LJV56aRls= +github.com/golang/protobuf v1.3.5/go.mod h1:6O5/vntMXwX2lRkT1hjjk0nAC1IDOTvTlVgjlRvqsdk= +github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.5.8 h1:e6P7q2lk1O+qJJb4BtCQXlK8vWEO8V1ZeuEdJNOqZyg= +github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/gorilla/websocket v1.4.1 h1:q7AeDBpnBk8AogcD4DSag/Ukw/KV+YhzLj2bP5HvKCM= +github.com/gorilla/websocket v1.4.1/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= +github.com/json-iterator/go v1.1.9 h1:9yzud/Ht36ygwatGx56VwCZtlI/2AD15T1X2sjSuGns= +github.com/json-iterator/go v1.1.9/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4= +github.com/klauspost/compress v1.10.3 h1:OP96hzwJVBIHYU52pVTI6CczrxPvrGfgqF9N5eTO0Q8= +github.com/klauspost/compress v1.10.3/go.mod h1:aoV0uJVorq1K+umq18yTdKaF57EivdYsUV+/s2qKfXs= +github.com/leodido/go-urn v1.2.0 h1:hpXL4XnriNwQ/ABnpepYM/1vCLWNDfUNts8dX3xTG6Y= +github.com/leodido/go-urn v1.2.0/go.mod h1:+8+nEpDfqqsY+g338gtMEUOtuK+4dEMhiQEgxpxOKII= +github.com/mattn/go-isatty v0.0.12 h1:wuysRhFDzyxgEmMf5xjvJ2M9dZoWAXNNr5LSBS7uHXY= +github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU= +github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421 h1:ZqeYNhU3OHLH3mGKHDcjJRFFRrJa6eAM5H+CtDdOsPc= +github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/reflect2 v0.0.0-20180701023420-4b7aa43c6742 h1:Esafd1046DLDQ0W1YjYsBW+p8U2u7vzgW2SQVmlNazg= +github.com/modern-go/reflect2 v0.0.0-20180701023420-4b7aa43c6742/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= +github.com/ugorji/go v1.1.7 h1:/68gy2h+1mWMrwZFeD1kQialdSzAb432dtpeJ42ovdo= +github.com/ugorji/go v1.1.7/go.mod h1:kZn38zHttfInRq0xu/PH0az30d+z6vm202qpg1oXVMw= +github.com/ugorji/go/codec v1.1.7 h1:2SvQaVZ1ouYrrKKwoSk2pzd4A9evlKJb9oTL+OaLUSs= +github.com/ugorji/go/codec v1.1.7/go.mod h1:Ax+UKWsSmolVDwsd+7N3ZtXu+yMGCf907BLYF3GoBXY= +golang.org/x/exp v0.0.0-20230118134722-a68e582fa157 h1:fiNkyhJPUvxbRPbCqY/D9qdjmPzfHcpK3P4bM4gioSY= +golang.org/x/exp v0.0.0-20230118134722-a68e582fa157/go.mod h1:CxIveKay+FTh1D0yPZemJVgC/95VzuuOLq5Qi4xnoYc= +golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.1.0 h1:kunALQeHf1/185U1i0GOB/fy1IPRDDpuoOOqRReG57U= +golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= +golang.org/x/time v0.0.0-20191024005414-555d28b269f0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.2.8 h1:obN1ZagJSUGI0Ek/LBmuj4SNLPfIny3KsKFopxRdj10= +gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +nhooyr.io/websocket v1.8.7 h1:usjR2uOr/zjjkVMy0lW+PPohFok7PCow5sDjLgX4P4g= +nhooyr.io/websocket v1.8.7/go.mod h1:B70DZP8IakI65RVQ51MsWP/8jndNma26DVA/nFSCgW0= diff --git a/gitea-ci/index.html b/gitea-ci/index.html new file mode 100644 index 0000000..0a7b65e --- /dev/null +++ b/gitea-ci/index.html @@ -0,0 +1,12 @@ + + +
+ diff --git a/gitea-ci/log.go b/gitea-ci/log.go new file mode 100644 index 0000000..ae9d478 --- /dev/null +++ b/gitea-ci/log.go @@ -0,0 +1,58 @@ +package main + +import ( + "context" + "log" + "net/http" + "strings" + + "nhooyr.io/websocket" +) + +type logWebsocket struct { +} + +func (l logWebsocket) ServeHTTP(w http.ResponseWriter, r *http.Request) { + parts := strings.Split(r.URL.Path[1:], "/") + run := runss.getRun(parts[2]) + if run == nil { + http.Error(w, "log not found", http.StatusNotFound) + return + } + + c, err := websocket.Accept(w, r, nil) + if err != nil { + http.Error(w, "this a websocket path", http.StatusBadRequest) + return + } + defer c.Close(websocket.StatusInternalError, "the sky is falling") + + ch := make(chan *[]byte) + err = run.addWriter(conn{ch: ch}) + if err != nil { + log.Println("addWriter", err) + http.Error(w, "error", http.StatusInternalServerError) + return + } + for payload := range ch { + if payload == nil { + break + } else { + c.Write(context.Background(), websocket.MessageBinary, *payload) + } + } + c.Close(websocket.StatusNormalClosure, "") +} + +type conn struct { + ch chan *[]byte +} + +func (c conn) Write(payload []byte) (n int, err error) { + c.ch <- &payload + return len(payload), nil +} +func (c conn) Close() error { + c.ch <- nil + return nil +} diff --git a/gitea-ci/main.go b/gitea-ci/main.go new file mode 100644 index 0000000..6758fe4 --- /dev/null +++ b/gitea-ci/main.go @@ -0,0 +1,63 @@ +package main + +import ( + "log" + "net/http" + "os" + "strings" + + "gitea.nulo.in/Nulo/repro-run/gitea-ci/gitea" +) + +func main() { + config := parseConfig() + + g := gitea.Gitea{ + Url: config.giteaUrl, + Username: config.giteaUsername, + Password: config.giteaPassword, + } + + u, err := g.GetUser() + if err != nil { + log.Fatal(err) + } + log.Printf("Logged in as @%s", u.Login) + + http.Handle("/webhook", webhook{config: config, gitea: g}) + http.Handle("/logs/socket/", logWebsocket{}) + http.Handle("/", http.FileServer(http.Dir("."))) + log.Fatal(http.ListenAndServe(":8080", nil)) +} + +type config struct { + allowedUsers []string + giteaWebhookSecret string + giteaUrl string + giteaUsername string + giteaPassword string +} + +func parseConfig() (c config) { + var allowedUsersStr string + if allowedUsersStr = os.Getenv("ALLOWED_REPOS"); len(allowedUsersStr) == 0 { + log.Fatal("ALLOWED_REPOS is nil") + } + c.allowedUsers = strings.Split(allowedUsersStr, ",") + + if c.giteaWebhookSecret = os.Getenv("GITEA_WEBHOOK_SECRET"); len(c.giteaWebhookSecret) == 0 { + log.Fatal("GITEA_WEBHOOK_SECRET is nil") + } + + if c.giteaUrl = os.Getenv("GITEA_URL"); len(c.giteaUrl) == 0 { + log.Fatal("GITEA_URL is nil") + } + if c.giteaUsername = os.Getenv("GITEA_USERNAME"); len(c.giteaUsername) == 0 { + log.Fatal("GITEA_USERNAME is nil") + } + if c.giteaPassword = os.Getenv("GITEA_PASSWORD"); len(c.giteaPassword) == 0 { + log.Fatal("GITEA_PASSWORD is nil") + } + + return +} diff --git a/gitea-ci/webhook.go b/gitea-ci/webhook.go new file mode 100644 index 0000000..06cefef --- /dev/null +++ b/gitea-ci/webhook.go @@ -0,0 +1,280 @@ +package main + +import ( + "crypto/hmac" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "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 + previous []byte + writers []io.WriteCloser +} + +func (r *run) Write(p []byte) (n int, err error) { + log.Println(string(p)) + 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() + + go w.Write(slices.Clone(r.previous)) + r.writers = append(r.writers, w) + return +} +func (r *run) finishRun() { + r.mutex.Lock() + defer r.mutex.Unlock() + 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") + // TODO: make dynamic + runUrl := "http://localhost:8080/#" + 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 os.RemoveAll(dir) + + 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") + src := path.Join(dir, "src") + if err = os.MkdirAll(rootfs, 0700); err != nil { + log.Println(err) + http.Error(w, "error", http.StatusInternalServerError) + return + } + if err = os.MkdirAll(src, 0700); err != nil { + log.Println(err) + http.Error(w, "error", http.StatusInternalServerError) + return + } + + if err = os.MkdirAll(src, 0700); err != nil { + log.Println(err) + http.Error(w, "error", http.StatusInternalServerError) + return + } + + var cmd *exec.Cmd + if _, err = os.ReadDir(path.Join(src, ".git")); os.IsExist(err) { + 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(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 + } + + err = runner.Run(*runnerConfig, rootfs, cache, src, run) + state := "success" + if err != nil { + log.Println("runner.Run", err) + state = "failure" + } + 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 +} + +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"` +} diff --git a/go.mod b/go.mod index b969676..e8bae1e 100644 --- a/go.mod +++ b/go.mod @@ -1,3 +1,10 @@ module gitea.nulo.in/Nulo/repro-run go 1.19 + +require golang.org/x/exp v0.0.0-20230118134722-a68e582fa157 + +require ( + github.com/klauspost/compress v1.10.3 // indirect + nhooyr.io/websocket v1.8.7 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..1ff463e --- /dev/null +++ b/go.sum @@ -0,0 +1,41 @@ +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI= +github.com/gin-gonic/gin v1.6.3/go.mod h1:75u5sXoLsGZoRN5Sgbi1eraJ4GU3++wFwWzhwvtwp4M= +github.com/go-playground/assert/v2 v2.0.1/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4= +github.com/go-playground/locales v0.13.0/go.mod h1:taPMhCMXrRLJO55olJkUXHZBHCxTMfnGwq/HNwmWNS8= +github.com/go-playground/universal-translator v0.17.0/go.mod h1:UkSxE5sNxxRwHyU+Scu5vgOQjsIJAF8j9muTVoKLVtA= +github.com/go-playground/validator/v10 v10.2.0/go.mod h1:uOYAAleCW8F/7oMFd6aG0GOhaH6EGOAJShg8Id5JGkI= +github.com/gobwas/httphead v0.0.0-20180130184737-2c6c146eadee/go.mod h1:L0fX3K22YWvt/FAX9NnzrNzcI4wNYi9Yku4O0LKYflo= +github.com/gobwas/pool v0.2.0/go.mod h1:q8bcK0KcYlCgd9e7WYLm9LpyS+YeLd8JVDW6WezmKEw= +github.com/gobwas/ws v1.0.2/go.mod h1:szmBTxLgaFppYjEmNtny/v3w89xOydFnnZMcgRRu/EM= +github.com/golang/protobuf v1.3.3/go.mod h1:vzj43D7+SQXF/4pzW/hwtAqwc6iTitCiVSaWz5lYuqw= +github.com/golang/protobuf v1.3.5/go.mod h1:6O5/vntMXwX2lRkT1hjjk0nAC1IDOTvTlVgjlRvqsdk= +github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/gorilla/websocket v1.4.1/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= +github.com/json-iterator/go v1.1.9/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4= +github.com/klauspost/compress v1.10.3 h1:OP96hzwJVBIHYU52pVTI6CczrxPvrGfgqF9N5eTO0Q8= +github.com/klauspost/compress v1.10.3/go.mod h1:aoV0uJVorq1K+umq18yTdKaF57EivdYsUV+/s2qKfXs= +github.com/leodido/go-urn v1.2.0/go.mod h1:+8+nEpDfqqsY+g338gtMEUOtuK+4dEMhiQEgxpxOKII= +github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU= +github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/reflect2 v0.0.0-20180701023420-4b7aa43c6742/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= +github.com/ugorji/go v1.1.7/go.mod h1:kZn38zHttfInRq0xu/PH0az30d+z6vm202qpg1oXVMw= +github.com/ugorji/go/codec v1.1.7/go.mod h1:Ax+UKWsSmolVDwsd+7N3ZtXu+yMGCf907BLYF3GoBXY= +golang.org/x/exp v0.0.0-20230118134722-a68e582fa157 h1:fiNkyhJPUvxbRPbCqY/D9qdjmPzfHcpK3P4bM4gioSY= +golang.org/x/exp v0.0.0-20230118134722-a68e582fa157/go.mod h1:CxIveKay+FTh1D0yPZemJVgC/95VzuuOLq5Qi4xnoYc= +golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= +golang.org/x/time v0.0.0-20191024005414-555d28b269f0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +nhooyr.io/websocket v1.8.7 h1:usjR2uOr/zjjkVMy0lW+PPohFok7PCow5sDjLgX4P4g= +nhooyr.io/websocket v1.8.7/go.mod h1:B70DZP8IakI65RVQ51MsWP/8jndNma26DVA/nFSCgW0= diff --git a/go.work b/go.work new file mode 100644 index 0000000..38ef778 --- /dev/null +++ b/go.work @@ -0,0 +1,3 @@ +go 1.19 + +use ./gitea-ci diff --git a/runner/config.go b/runner/config.go deleted file mode 100644 index 8ce4f0a..0000000 --- a/runner/config.go +++ /dev/null @@ -1,6 +0,0 @@ -package runner - -type Config struct { - Command string - Cache []string -} diff --git a/runner/runner.go b/runner/runner.go index a447c41..e1599b6 100644 --- a/runner/runner.go +++ b/runner/runner.go @@ -14,21 +14,21 @@ import ( //go:embed alpine/keys var alpineKeys embed.FS -func Run(config Config, rootfs string, cache string) (err error) { - if err = os.RemoveAll("rootfs/"); err != nil { +func Run(config Config, rootfs string, cache string, src string, w io.Writer) (err error) { + if err = os.RemoveAll(rootfs); err != nil { return } - if err = os.MkdirAll("rootfs/etc/apk/keys", 0700); err != nil { + if err = os.MkdirAll(path.Join(rootfs, "etc/apk/keys"), 0700); err != nil { return } - if err = os.WriteFile("rootfs/etc/apk/repositories", []byte( + if err = os.WriteFile(path.Join(rootfs, "etc/apk/repositories"), []byte( `https://dl-cdn.alpinelinux.org/alpine/v3.17/main https://dl-cdn.alpinelinux.org/alpine/v3.17/community`), 0600); err != nil { return } - if err = os.WriteFile("rootfs/etc/resolv.conf", []byte(`nameserver 8.8.8.8`), 0644); err != nil { + if err = os.WriteFile(path.Join(rootfs, "etc/resolv.conf"), []byte(`nameserver 8.8.8.8`), 0644); err != nil { return } keys, err := alpineKeys.ReadDir("alpine/keys") @@ -36,7 +36,7 @@ https://dl-cdn.alpinelinux.org/alpine/v3.17/community`), 0600); err != nil { return } for _, entry := range keys { - o, err := os.Create(path.Join("rootfs/etc/apk/keys/", entry.Name())) + o, err := os.Create(path.Join(rootfs, "etc/apk/keys/", entry.Name())) if err != nil { return err } @@ -49,17 +49,16 @@ https://dl-cdn.alpinelinux.org/alpine/v3.17/community`), 0600); err != nil { } } - if err = os.MkdirAll("cache/_var_cache_apk", 0700); err != nil { + if err = os.MkdirAll(path.Join(cache, "_var_cache_apk"), 0700); err != nil { return } - os.Remove("rootfs/etc/apk/cache") - if err = os.Symlink("/var/cache/apk", "rootfs/etc/apk/cache"); err != nil { + if err = os.Symlink("/var/cache/apk", path.Join(rootfs, "etc/apk/cache")); err != nil { return } cmd := exec.Command("apk", "add", - "--root", "rootfs", + "--root", rootfs, "--initdb", - "--cache-dir", "../cache/_var_cache_apk", + "--cache-dir", path.Join(cache, "_var_cache_apk"), "apk-tools") if err = cmd.Run(); err != nil { return fmt.Errorf("apk add apk-tools: %w", err) @@ -68,7 +67,7 @@ https://dl-cdn.alpinelinux.org/alpine/v3.17/community`), 0600); err != nil { cached := append(config.Cache, "/var/cache/apk") var cachedParams []string for _, c := range cached { - cacheDir := path.Join("./cache", strings.ReplaceAll(c, "/", "_")) + cacheDir := path.Join(cache, strings.ReplaceAll(c, "/", "_")) if err = os.MkdirAll(cacheDir, 0700); err != nil { return } @@ -83,8 +82,8 @@ https://dl-cdn.alpinelinux.org/alpine/v3.17/community`), 0600); err != nil { log.Println("/work = " + tmp) params := append( - []string{"--bind", "./rootfs", "/", - "--ro-bind", ".", "/src", + []string{"--bind", rootfs, "/", + "--ro-bind", src, "/src", "--dev-bind", "/dev", "/dev", "--bind", tmp, "/work", "--unshare-user", "--uid", "1000", "--gid", "1000", @@ -108,12 +107,22 @@ https://dl-cdn.alpinelinux.org/alpine/v3.17/community`), 0600); err != nil { } cmd = exec.Command("bwrap", append(params, strings.Split(config.Command, " ")...)...) - cmd.Stdout = os.Stdout - cmd.Stdin = os.Stdin - cmd.Stderr = os.Stderr + // cmd.Stdin = os.Stdin + if w == nil { + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + } else { + cmd.Stdout = w + cmd.Stderr = w + } if err = cmd.Run(); err != nil { return fmt.Errorf("running Command: %w", err) } return } + +type Config struct { + Command string + Cache []string +}