From a942e23b393c2ab7f819d1234153682add139d48 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?P=C5=99emysl=20Janouch?= Date: Sat, 2 Dec 2017 22:07:25 +0100 Subject: [PATCH] Initial commit --- .gitignore | 1 + LICENSE | 13 +++ README.adoc | 49 +++++++++++ main.go | 232 ++++++++++++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 295 insertions(+) create mode 100644 .gitignore create mode 100644 LICENSE create mode 100644 README.adoc create mode 100644 main.go diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..5ea369e --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +/gitea-obs diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..c949ca2 --- /dev/null +++ b/LICENSE @@ -0,0 +1,13 @@ +Copyright (c) 2017, Přemysl Janouch + +Permission to use, copy, modify, and/or distribute this software for any +purpose with or without fee is hereby granted, provided that the above +copyright notice and this permission notice appear in all copies. + +THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES +WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF +MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY +SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES +WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION +OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN +CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. diff --git a/README.adoc b/README.adoc new file mode 100644 index 0000000..54fabc2 --- /dev/null +++ b/README.adoc @@ -0,0 +1,49 @@ +gitea-obs +========= + +'gitea-obs' is a webhook endpoint to trigger the services in Open Build Service, +meant as a replacement for the Obs service on GitHub. + +Unfortunately someone thought I wasn't being funny with my project names and +blocked my OBS account before I could test it. Therefore it comes as is. + +Usage +----- + + $ go build + $ mkdir gitea-obs-workdir + $ cd gitea-obs-workdir + $ ../gitea-obs :3000 secret + +Then, in Gitea, assuming it's running on the same machine as the program, +use a 'Payload URL' like the following: + + http://localhost:3000/?token=TOKEN + +Optional arguments: + + * 'project' is the OBS project name (when not implied by the token itself) + * 'package' is the OBS package name (when not implied by the token itself) + * 'refs' is a colon-separated list of Go filepath patterns to filter branches, + for example 'refs/heads/*'; defaults to 'refs/heads/master' + * 'obs' specifies a different URL for the OBS instance + +The program uses the current working directory as a dispatch queue, and it must +be run in a dedicated directory. + +Contributing and Support +------------------------ +Use https://git.janouch.name/p/gitea-obs to report any bugs, request features, +or submit pull requests. If you want to discuss this project, or maybe just +hang out with the developer, feel free to join me at irc://irc.janouch.name, +channel #dev. + +License +------- +'gitea-obs' is written by Přemysl Janouch . + +You may use the software under the terms of the ISC license, the text of which +is included within the package, or, at your option, you may relicense the work +under the MIT or the Modified BSD License, as listed at the following site: + +http://www.gnu.org/licenses/license-list.html diff --git a/main.go b/main.go new file mode 100644 index 0000000..395497a --- /dev/null +++ b/main.go @@ -0,0 +1,232 @@ +// Open Build System Gitea trigger webhook +package main + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io/ioutil" + "log" + "net/http" + "net/url" + "os" + "os/signal" + "path" + "path/filepath" + "strings" + "syscall" + "time" +) + +var programName = path.Base(os.Args[0]) +var secretToken = "" +var apiClient *http.Client = &http.Client{Timeout: 60 * time.Second} + +// We're on a relatively short timeout (10s), just queue the events for later +func handler(w http.ResponseWriter, r *http.Request) { + data, err := ioutil.ReadAll(r.Body) + if err != nil { + w.WriteHeader(http.StatusInternalServerError) + return + } + + // Presence of the header marks the format of hook data + event := r.Header.Get("X-Gitea-Event") + if event == "" { + w.WriteHeader(http.StatusBadRequest) + return + } + + // React to new commits being added + if event != "push" { + return + } + + buf := new(bytes.Buffer) + if err := json.Indent(buf, data, "", " "); err != nil { + log.Printf("req: %s", err) + w.WriteHeader(http.StatusBadRequest) + return + } + + // Mark our files as such, use the filesystem as a simple database + base := programName + "&" + r.URL.RawQuery + if err := func() error { + f, err := os.OpenFile(base+".tmp", + os.O_WRONLY|os.O_CREATE|os.O_TRUNC|os.O_SYNC, 0644) + if err != nil { + return err + } + if _, err := f.Write(buf.Bytes()); err != nil { + f.Close() + return err + } + if err := f.Close(); err != nil { + return err + } + // Ready for processing -- needs to be the last step here + if err := os.Rename(base+".tmp", base); err != nil { + return err + } + return nil + }(); err != nil { + log.Printf("req: %s", err) + w.WriteHeader(http.StatusInternalServerError) + } +} + +func process(ctx context.Context, values url.Values, data []byte) error { + pkg := values.Get("package") + project := values.Get("project") + token := values.Get("token") + if token == "" { + return fmt.Errorf("no token given") + } + + obs := "https://build.opensuse.org" + if v := values.Get("obs"); v != "" { + obs = v + } + + refs := "refs/heads/master" + if v := values.Get("refs"); v != "" { + refs = v + } + + // No need for the whole "code.gitea.io/sdk/gitea" package so far + push := struct { + Secret string `json:"secret"` + Ref string `json:"ref"` + }{} + if err := json.Unmarshal(data, &push); err != nil { + return err + } + if push.Secret != secretToken { + return fmt.Errorf("invalid secret: %s", push.Secret) + } + + // This roughly matches the behaviour of the GitHub service + matches := false + for _, ref := range strings.Split(refs, ":") { + if m, err := filepath.Match(ref, push.Ref); m { + matches = true + break + } else if err != nil { + return fmt.Errorf("%s: %s", ref, err) + } + } + + // Trigger the OBS source service to make it rebuild the package + if matches { + uri := obs + "/trigger/runservice" + if pkg != "" && project != "" { + escape := url.QueryEscape + uri += "?package=" + escape(pkg) + "&project=" + escape(project) + } + req, err := http.NewRequest("POST", uri, nil) + if err != nil { + return err + } + req.Header.Set("Authorization", "Token "+token) + resp, err := apiClient.Do(req.WithContext(ctx)) + if err != nil { + return err + } + defer resp.Body.Close() + } + return nil +} + +func consume(ctx context.Context) { + list, err := ioutil.ReadDir(".") + if err != nil { + log.Fatalln(err) + } + + for _, info := range list { + base := info.Name() + if strings.HasSuffix(base, ".tmp") || !info.Mode().IsRegular() { + continue + } + data, err := ioutil.ReadFile(base) + if err != nil { + log.Println(err) + continue + } + + values, err := url.ParseQuery(base) + if err != nil { + log.Printf("%s: %s\n", base, err) + continue + } + if _, ok := values[programName]; !ok { + continue + } + + if err := process(ctx, values, data); err != nil { + log.Printf("%s: %s\n", base, err) + continue + } + // XXX minor race condition + if err := os.Remove(base); err != nil { + log.Println(err) + continue + } + } +} + +func main() { + if len(os.Args) < 2 || len(os.Args) > 3 { + fmt.Fprintf(os.Stderr, "Usage: %s LISTEN-ADDR [SECRET]\n", os.Args[0]) + os.Exit(1) + } + + listenAddr := os.Args[1] + if len(os.Args) == 3 { + secretToken = os.Args[2] + } + + http.HandleFunc("/", handler) + server := &http.Server{Addr: listenAddr} + + // Run the producer + go func() { + if err := server.ListenAndServe(); err != nil && + err != http.ErrServerClosed { + log.Fatalln(err) + } + }() + + // Run the consumer + ctx, ctxCancel := context.WithCancel(context.Background()) + consumerFinished := make(chan struct{}) + // Process the currently available batch and retry after a few seconds + go func() { + defer close(consumerFinished) + for { + consume(ctx) + select { + case <-time.After(3 * time.Second): + case <-ctx.Done(): + return + } + } + }() + + // Wait for a termination signal + sigs := make(chan os.Signal, 1) + signal.Notify(sigs, syscall.SIGINT, syscall.SIGTERM) + <-sigs + + // Stop the producer gracefully + ctxSd, ctxSdCancel := context.WithTimeout(context.Background(), 5) + if err := server.Shutdown(ctxSd); err != nil { + log.Println(err) + } + ctxSdCancel() + + // Stop the consumer gracefully + ctxCancel() + <-consumerFinished +}