From 83a38a6da9ef99bc6596f6cfb53395a89f0165c7 Mon Sep 17 00:00:00 2001 From: Franck Cuny Date: Mon, 30 May 2022 13:32:43 -0700 Subject: feat(gerrit-hook): a small tool to act as a dispatcher for gerrit When a patchset is created, gerrit will call this tool with a number of arguments. This hook triggers a build with buildKite for the given patchset, and add a comment to gerrit with a link to the build. We do not wait for the build to be successful to update gerrit. This will be done by another hook which the buildKite agents will call once they are done with the build. Change-Id: Iaa221765f3c52875ec37c5d282ba0557291eb5a4 Reviewed-on: https://cl.fcuny.net/c/world/+/171 Reviewed-by: Franck Cuny --- tools/gerrit-hook/buildkite.go | 81 +++++++++++++++++++++++ tools/gerrit-hook/default.nix | 16 +++++ tools/gerrit-hook/gerrit.go | 144 +++++++++++++++++++++++++++++++++++++++++ tools/gerrit-hook/go.mod | 3 + tools/gerrit-hook/main.go | 64 ++++++++++++++++++ 5 files changed, 308 insertions(+) create mode 100644 tools/gerrit-hook/buildkite.go create mode 100644 tools/gerrit-hook/default.nix create mode 100644 tools/gerrit-hook/gerrit.go create mode 100644 tools/gerrit-hook/go.mod create mode 100644 tools/gerrit-hook/main.go (limited to 'tools/gerrit-hook') diff --git a/tools/gerrit-hook/buildkite.go b/tools/gerrit-hook/buildkite.go new file mode 100644 index 0000000..d8723b6 --- /dev/null +++ b/tools/gerrit-hook/buildkite.go @@ -0,0 +1,81 @@ +package main + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io/ioutil" + "log/syslog" + "net/http" + "time" +) + +// https://buildkite.com/docs/apis/rest-api/builds#create-a-Build +type Build struct { + Commit string `json:"commit"` + Branch string `json:"branch"` +} + +type buildResponse struct { + WebUrl string `json:"web_url"` +} + +func triggerBuild(cfg *config, log *syslog.Writer, trigger *buildTrigger) error { + b := Build{ + Commit: trigger.commit, + Branch: trigger.ref, + } + + body, _ := json.Marshal(b) + reader := ioutil.NopCloser(bytes.NewReader(body)) + + bkUrl := fmt.Sprintf("https://api.buildkite.com/v2/organizations/%s/pipelines/%s/builds", cfg.BuildKiteOrganization, trigger.project) + req, err := http.NewRequest("POST", bkUrl, reader) + if err != nil { + return fmt.Errorf("failed to create an HTTP request: %v", err) + } + + req.Header.Add("Authorization", fmt.Sprintf("Bearer %s", cfg.BuildKiteToken)) + req.Header.Add("Content-Type", "application/json") + + // Let's budget this to 10 seconds maximum, this should be more + // than enough, as we're only triggering the build, we're not + // waiting on the status of the build + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + resp, err := http.DefaultClient.Do(req.WithContext(ctx)) + if err != nil { + return fmt.Errorf("failed to send buildKite request: %v", err) + } + defer resp.Body.Close() + + respBody, err := ioutil.ReadAll(resp.Body) + if err != nil { + return fmt.Errorf("failed to parse buildKite response: %v", err) + } + + if resp.StatusCode != http.StatusCreated { + return fmt.Errorf("received a non-success response from buildKite: %s (%v)", respBody, resp.Status) + } + + var buildResp buildResponse + err = json.Unmarshal(respBody, &buildResp) + if err != nil { + return fmt.Errorf("failed to unmarshal build response: %v", err) + } + + // Report the status back to the Gerrit CL so that users can click + // through to the running build. + msg := fmt.Sprintf("started build for patchset #%s on: %s", trigger.patchset, buildResp.WebUrl) + review := reviewInput{ + Message: msg, + OmitDuplicateComments: true, + Tag: "autogenerated:buildkite~trigger", + IgnoreDefaultAttentionSetRules: true, + Notify: "NONE", + } + updateGerrit(cfg, review, trigger.changeId, trigger.patchset) + return nil +} diff --git a/tools/gerrit-hook/default.nix b/tools/gerrit-hook/default.nix new file mode 100644 index 0000000..c0667ae --- /dev/null +++ b/tools/gerrit-hook/default.nix @@ -0,0 +1,16 @@ +{ pkgs, ... }: + +pkgs.buildGoModule rec { + name = "gerrit-hook"; + src = ./.; + subPackages = [ "." ]; + vendorSha256 = null; + + meta = with pkgs.lib; { + description = "hooks for gerrit"; + homepage = "https://golang.fcuny.net"; + license = licenses.mit; + platforms = platforms.linux; + maintainers = [ ]; + }; +} diff --git a/tools/gerrit-hook/gerrit.go b/tools/gerrit-hook/gerrit.go new file mode 100644 index 0000000..6a23527 --- /dev/null +++ b/tools/gerrit-hook/gerrit.go @@ -0,0 +1,144 @@ +package main + +import ( + "bytes" + "context" + "encoding/json" + "flag" + "fmt" + "io/ioutil" + "log" + "log/syslog" + "net/http" + "os" + "regexp" + "strconv" + "time" +) + +// Regular expression to extract change ID out of a URL +var changeIdRegexp = regexp.MustCompile(`^.*/(\d+)$`) + +func gerritHookMain(cfg *config, log *syslog.Writer, trigger *buildTrigger) { + if trigger == nil { + os.Exit(0) + } + + err := triggerBuild(cfg, log, trigger) + if err != nil { + log.Err(fmt.Sprintf("failed to trigger Buildkite build: %s", err)) + os.Exit(1) + } +} + +type reviewInput struct { + Message string `json:"message"` + Labels map[string]int `json:"labels,omitempty"` + OmitDuplicateComments bool `json:"omit_duplicate_comments"` + IgnoreDefaultAttentionSetRules bool `json:"ignore_default_attention_set_rules"` + Tag string `json:"tag"` + Notify string `json:"notify,omitempty"` +} + +type buildTrigger struct { + project string + change string + kind string + changeUrl string + changeOwner string + changeOwnerUserName string + branch string + topic string + uploader string + uploaderUserName string + commit string + patchset string + changeId string + ref string +} + +// https://gerrit.googlesource.com/plugins/hooks/+/HEAD/src/main/resources/Documentation/hooks.md#patchset_created +func triggerForPatchsetCreated() (*buildTrigger, error) { + var trigger buildTrigger + + flag.StringVar(&trigger.project, "project", "", "Gerrit project") + flag.StringVar(&trigger.change, "change", "", "Gerrit change") + flag.StringVar(&trigger.kind, "kind", "", "Gerrit kind") + flag.StringVar(&trigger.changeUrl, "change-url", "", "Gerrit URL for the change") + flag.StringVar(&trigger.changeOwner, "change-owner", "", "Gerrit owner") + flag.StringVar(&trigger.changeOwnerUserName, "change-owner-username", "", "Gerrit username") + flag.StringVar(&trigger.branch, "branch", "", "name of the branch") + flag.StringVar(&trigger.topic, "topic", "", "name of the topic") + flag.StringVar(&trigger.uploader, "uploader", "", "name ofthe uploader") + flag.StringVar(&trigger.uploaderUserName, "uploader-username", "", "") + flag.StringVar(&trigger.commit, "commit", "", "") + flag.StringVar(&trigger.patchset, "patchset", "", "") + + flag.Parse() + + // for now we only care about the project named `world' and the + // branch named 'main' + if trigger.project != "world" || trigger.branch != "main" { + return nil, nil + } + + // We only care about patchset that are actually modifying the + // code. See + // https://gerrit-review.googlesource.com/Documentation/config-labels.html + if trigger.kind == "NO_CODE_CHANGE" || trigger.kind == "NO_CHANGE" { + return nil, nil + } + + // extract the changeId from the URL + matches := changeIdRegexp.FindStringSubmatch(trigger.changeUrl) + trigger.changeId = matches[1] + + // build the ref + changeId, _ := strconv.Atoi(trigger.changeId) + trigger.ref = fmt.Sprintf( + "refs/changes/%02d/%s/%s", + changeId%100, trigger.changeId, trigger.patchset, + ) + + return &trigger, nil +} + +// after triggering a build with buildKite, we update gerrit to add a +// comment that links to the build. +func updateGerrit(cfg *config, review reviewInput, changeId string, patchSet string) { + body, err := json.Marshal(review) + if err != nil { + log.Fatal(fmt.Sprintf("failed to marshal gerrit update: %v", err)) + os.Exit(1) + } + + reader := ioutil.NopCloser(bytes.NewReader(body)) + url := fmt.Sprintf("%s/a/changes/%s/revisions/%s/review", cfg.GerritUrl, changeId, patchSet) + req, err := http.NewRequest("POST", url, reader) + if err != nil { + fmt.Fprintf(os.Stderr, "failed to create an HTTP request: %v", err) + os.Exit(1) + } + + req.SetBasicAuth(cfg.GerritUser, cfg.GerritPassword) + req.Header.Add("Content-Type", "application/json") + + // Let's budget this to 10 seconds maximum, this should be more + // than enough to add a comment to gerrit. + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + resp, err := http.DefaultClient.Do(req.WithContext(ctx)) + if err != nil { + fmt.Fprintf(os.Stderr, "failed to send gerrit request: %v", err) + os.Exit(1) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + respBody, _ := ioutil.ReadAll(resp.Body) + fmt.Fprintf(os.Stderr, "failed to update gerrit: %s: %s ", respBody, resp.Status) + } else { + fmt.Printf("added link to CI build to %s", patchSet) + } +} diff --git a/tools/gerrit-hook/go.mod b/tools/gerrit-hook/go.mod new file mode 100644 index 0000000..98968da --- /dev/null +++ b/tools/gerrit-hook/go.mod @@ -0,0 +1,3 @@ +module golang.fcuny.net/gerrit-hook + +go 1.16 diff --git a/tools/gerrit-hook/main.go b/tools/gerrit-hook/main.go new file mode 100644 index 0000000..f8ed687 --- /dev/null +++ b/tools/gerrit-hook/main.go @@ -0,0 +1,64 @@ +package main + +import ( + "encoding/json" + "fmt" + "io/ioutil" + "log/syslog" + "os" + "path" +) + +// config represents the configuration for the gerrit hook +type config struct { + GerritUrl string `json:"gerritUrl"` + GerritUser string `json:"gerritUser"` + GerritPassword string `json:"gerritPassword"` + BuildKiteToken string `json:"buildKiteToken"` + BuildKiteOrganization string `json:"buildKiteOrganization"` +} + +func loadConfig() (*config, error) { + configPath := "/var/run/agenix/gerrit/hooks" + + configJson, err := ioutil.ReadFile(configPath) + if err != nil { + return nil, fmt.Errorf("failed to read configuration file %s: %v", configPath, err) + } + + var cfg config + err = json.Unmarshal(configJson, &cfg) + if err != nil { + return nil, fmt.Errorf("failed to unmarshall configuration: %v", err) + } + + return &cfg, nil +} + +func main() { + log, err := syslog.New(syslog.LOG_INFO|syslog.LOG_USER, "gerrit-hook") + if err != nil { + fmt.Fprintf(os.Stderr, "failed to open syslog: %s\n", err) + } + + log.Info(fmt.Sprintf("`gerrit-hook' called with arguments: %v\n", os.Args)) + + cmd := path.Base(os.Args[0]) + + cfg, err := loadConfig() + if err != nil { + os.Exit(1) + } + + if cmd == "patchset-created" { + trigger, err := triggerForPatchsetCreated() + if err != nil { + log.Crit(fmt.Sprintf("failed to create a trigger: %s", err)) + os.Exit(1) + } + gerritHookMain(cfg, log, trigger) + } else { + log.Info(fmt.Sprintf("`%s' is not a supported command", cmd)) + os.Exit(1) + } +} -- cgit 1.4.1