From 064765b19a270d806a66661dbcdc9f17bb145c14 Mon Sep 17 00:00:00 2001 From: Florian Klink Date: Thu, 15 May 2025 20:42:58 +0300 Subject: [PATCH] feat(ops/gerrit-webhook-to-irccat): init This is a listener for gerrit events, sent by their "webhooks" plugin, as well as a NixOS module to deploy it. Issue: https://git.snix.dev/snix/snix/issues/74 Change-Id: I65c5c5a991e6b1f4f330b3439c8a25aec3f1b484 Reviewed-on: https://cl.snix.dev/c/snix/+/30526 Reviewed-by: Ryan Lahfa Tested-by: besadii Autosubmit: Florian Klink --- ops/gerrit-webhook-to-irccat/default.nix | 14 +++ ops/gerrit-webhook-to-irccat/go.mod | 22 ++++ ops/gerrit-webhook-to-irccat/go.sum | 29 ++++++ ops/gerrit-webhook-to-irccat/main.go | 122 +++++++++++++++++++++++ ops/modules/gerrit-webhook-to-irccat.nix | 50 ++++++++++ 5 files changed, 237 insertions(+) create mode 100644 ops/gerrit-webhook-to-irccat/default.nix create mode 100644 ops/gerrit-webhook-to-irccat/go.mod create mode 100644 ops/gerrit-webhook-to-irccat/go.sum create mode 100644 ops/gerrit-webhook-to-irccat/main.go create mode 100644 ops/modules/gerrit-webhook-to-irccat.nix diff --git a/ops/gerrit-webhook-to-irccat/default.nix b/ops/gerrit-webhook-to-irccat/default.nix new file mode 100644 index 000000000..fb59ff1a5 --- /dev/null +++ b/ops/gerrit-webhook-to-irccat/default.nix @@ -0,0 +1,14 @@ +{ pkgs, lib, ... }: + +pkgs.buildGoModule { + name = "gerrit-webhook-to-irccat"; + src = lib.fileset.toSource { + root = ./.; + fileset = lib.fileset.unions [ + ./main.go + ./go.mod + ./go.sum + ]; + }; + vendorHash = "sha256-x5ldt3KWL6ri5UqbKFXN717R4JVTIFZyn5DsgGi/RY4="; +} diff --git a/ops/gerrit-webhook-to-irccat/go.mod b/ops/gerrit-webhook-to-irccat/go.mod new file mode 100644 index 000000000..98d2e38f7 --- /dev/null +++ b/ops/gerrit-webhook-to-irccat/go.mod @@ -0,0 +1,22 @@ +module snix.dev/ops/gerrit-webhook-to-irccat + +go 1.24.2 + +require ( + github.com/andygrunwald/go-gerrit v1.0.0 + github.com/coreos/go-systemd/v22 v22.5.0 + github.com/samber/slog-http v1.6.0 + golang.org/x/sync v0.13.0 +) + +require ( + github.com/google/go-querystring v1.1.0 // indirect + github.com/google/uuid v1.6.0 // indirect + go.opentelemetry.io/otel v1.29.0 // indirect + go.opentelemetry.io/otel/trace v1.29.0 // indirect +) + +// switch to the "fixes" branch, which renames _number to number and +// work_in_progress to wip to match the structure of the JSON being sent. +// Tracking issue: https://github.com/andygrunwald/go-gerrit/pull/187 +replace github.com/andygrunwald/go-gerrit v1.0.0 => github.com/flokli/go-gerrit v0.0.0-20250515192813-cf3a6e735367 diff --git a/ops/gerrit-webhook-to-irccat/go.sum b/ops/gerrit-webhook-to-irccat/go.sum new file mode 100644 index 000000000..6e68774c8 --- /dev/null +++ b/ops/gerrit-webhook-to-irccat/go.sum @@ -0,0 +1,29 @@ +github.com/coreos/go-systemd/v22 v22.5.0 h1:RrqgGjYQKalulkV8NGVIfkXQf6YYmOyiJKk8iXXhfZs= +github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/flokli/go-gerrit v0.0.0-20250515192813-cf3a6e735367 h1:qurVA28MaXYGbk1nWD5+PQhO5URhJW0PTgPNT1CPp0c= +github.com/flokli/go-gerrit v0.0.0-20250515192813-cf3a6e735367/go.mod h1:SeP12EkHZxEVjuJ2HZET304NBtHGG2X6w2Gzd0QXAZw= +github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= +github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= +github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/google/go-querystring v1.1.0 h1:AnCroh3fv4ZBgVIf1Iwtovgjaw/GiKJo8M8yD/fhyJ8= +github.com/google/go-querystring v1.1.0/go.mod h1:Kcdr2DB4koayq7X8pmAG4sNG59So17icRSOU623lUBU= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/samber/slog-http v1.6.0 h1:+rD5QtOWGTcFT7jq8Yf0EgGy87krv0pcgh9jtWkrqjQ= +github.com/samber/slog-http v1.6.0/go.mod h1:PAcQQrYFo5KM7Qbk50gNNwKEAMGCyfsw6GN5dI0iv9g= +github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= +github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +go.opentelemetry.io/otel v1.29.0 h1:PdomN/Al4q/lN6iBJEN3AwPvUiHPMlt93c8bqTG5Llw= +go.opentelemetry.io/otel v1.29.0/go.mod h1:N/WtXPs1CNCUEx+Agz5uouwCba+i+bJGFicT8SR4NP8= +go.opentelemetry.io/otel/trace v1.29.0 h1:J/8ZNK4XgR7a21DZUAsbF8pZ5Jcw1VhACmnYt39JTi4= +go.opentelemetry.io/otel/trace v1.29.0/go.mod h1:eHl3w0sp3paPkYstJOmAimxhiFXPg+MMTlEh3nsQgWQ= +golang.org/x/sync v0.13.0 h1:AauUjRAJ9OSnvULf/ARrrVywoJDy0YS2AwQ98I37610= +golang.org/x/sync v0.13.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/ops/gerrit-webhook-to-irccat/main.go b/ops/gerrit-webhook-to-irccat/main.go new file mode 100644 index 000000000..02d6b4db4 --- /dev/null +++ b/ops/gerrit-webhook-to-irccat/main.go @@ -0,0 +1,122 @@ +package main + +import ( + "bytes" + "context" + "encoding/json" + "flag" + "log" + "log/slog" + "net" + "net/http" + "os" + "os/signal" + "strings" + "text/template" + + "github.com/coreos/go-systemd/v22/activation" + sloghttp "github.com/samber/slog-http" + "golang.org/x/sync/errgroup" + + "github.com/andygrunwald/go-gerrit" +) + +// TODO: +// {"submitter":{"name":"Florian Klink","email":"flokli@flokli.de","username":"flokli"},"refUpdate":{"oldRev":"6097f070f549df94339a2b90b2e8670195c99ec3","newRev":"b339defea41b329aa33d80dcaa22623daeb040b6","refName":"refs/changes/25/30525/meta","project":"snix"},"type":"ref-updated","eventCreatedOn":1747336314} +// {"changer":{"name":"Florian Klink","email":"flokli@flokli.de","username":"flokli"},"patchSet":{"number":1,"revision":"ede307a009aa0b1eb62e9f18b7bf1f26e9fc98a9","parents":["9f8fb55318f2bafb37e4587fa4b6c793b2b540c0"],"ref":"refs/changes/25/30525/1","uploader":{"name":"Florian Klink","email":"flokli@flokli.de","username":"flokli"},"createdOn":1747335735,"author":{"name":"Florian Klink","email":"flokli@flokli.de","username":"flokli"},"kind":"REWORK","sizeInsertions":11,"sizeDeletions":1545},"change":{"project":"snix","branch":"canon","id":"If8faecdd018b45dd087b7332fe3d3a8280947358","number":30525,"subject":"fix(ops): drop clbot","owner":{"name":"Florian Klink","email":"flokli@flokli.de","username":"flokli"},"url":"https://cl.snix.dev/c/snix/+/30525","commitMessage":"fix(ops): drop clbot\n\nThis removes the old clbot, which kept an SSH connection to gerrit open.\n\nChange-Id: If8faecdd018b45dd087b7332fe3d3a8280947358\n","createdOn":1747335735,"status":"NEW"},"project":"snix","refName":"refs/heads/canon","changeKey":{"id":"If8faecdd018b45dd087b7332fe3d3a8280947358"},"type":"wip-state-changed","eventCreatedOn":1747336314} + +var logger *slog.Logger +var tmplStr = `{{- if eq .Type "patchset-created" -}} +{{- if (and (eq .PatchSet.Number "1") (eq .Change.WorkInProgress false) ) -}} +#snix CL/{{.Change.Number}} proposed by {{.Change.Owner.Username}} - {{.Change.Subject}} - {{.Change.URL}} +{{- end -}} +{{- else if eq .Type "change-merged" -}} +{{- if eq .Submitter.Username "clbot" -}} +#snix CL/{{.Change.Number}} by {{.Change.Owner.Username}} autosubmitted - {{.Change.Subject}} - {{.Change.URL}} +{{- else -}} +#snix CL/{{.Change.Number}} applied by {{.Change.Owner.Username}} - {{.Change.Subject}} - {{.Change.URL}} +{{- end -}} +{{- end -}}` +var tmpl = template.Must(template.New("msg").Parse(tmplStr)) + +var irccatUrl = flag.String("irccat-url", "", "Full URL pointing to the irccat /send endpoint.") + +// Receives HTTP requests from Gerrit, with the request payload following the +// same structure as the `gerrit stream-events` command. +func handler(w http.ResponseWriter, r *http.Request) { + var body bytes.Buffer + if _, err := body.ReadFrom(r.Body); err != nil { + logger.WarnContext(r.Context(), "failed to read body", slog.Any("error", err)) + return + } + logger.InfoContext(r.Context(), "received event", slog.Any("body", body.Bytes())) + + var eventInfo gerrit.EventInfo + if err := json.Unmarshal(body.Bytes(), &eventInfo); err != nil { + logger.WarnContext(r.Context(), "failed to parse body", slog.Any("error", err)) + return + } + + logger.InfoContext(r.Context(), "received event", slog.Any("event", eventInfo)) + + // render the template into a buffer. + var msg bytes.Buffer + if err := tmpl.Execute(&msg, eventInfo); err != nil { + logger.WarnContext(r.Context(), "failed to execute template with data", slog.Any("error", err)) + return + } + + // trim whitespace, just in case. + msgStr := strings.TrimSpace(msg.String()) + + // if the template did return data, send to irccat + if len(msgStr) > 0 { + // content-type doesn't matter, we don't run irccat in strict mode + _, err := http.Post(*irccatUrl, "application/octet-stream", bytes.NewReader([]byte(msgStr))) + if err != nil { + logger.WarnContext(r.Context(), "failed to send data to irccat", slog.Any("msg", msgStr), slog.Any("error", err)) + return + } + } +} + +func main() { + ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt) + defer stop() + + logger = slog.New(slog.NewTextHandler(os.Stderr, nil)) + + listeners, err := activation.Listeners() + if err != nil { + log.Fatalf("unable to get listeners: %s", err) + } + + if len(listeners) == 0 { + log.Fatal("no listeners specified, did you configure socket activation correctly?") + } + + flag.Parse() + if *irccatUrl == "" { + log.Fatal("no -irccat-url specified") + } + + g, ctx := errgroup.WithContext(ctx) + server := &http.Server{ + Handler: sloghttp.New(logger)(http.HandlerFunc(handler)), + BaseContext: func(l net.Listener) context.Context { + return ctx + }, + } + + for _, listener := range listeners { + g.Go(func() error { + return server.Serve(listener) + }) + } + + if err := g.Wait(); err != nil { + panic(err) + } + + <-ctx.Done() +} diff --git a/ops/modules/gerrit-webhook-to-irccat.nix b/ops/modules/gerrit-webhook-to-irccat.nix new file mode 100644 index 000000000..2ab08fb3e --- /dev/null +++ b/ops/modules/gerrit-webhook-to-irccat.nix @@ -0,0 +1,50 @@ +{ config, depot, lib, ... }: + +let + cfg = config.services.depot.gerrit-webhook-to-irccat; + description = "receive gerrit webhooks and forward to irccat"; +in + +{ + options.services.depot.gerrit-webhook-to-irccat = { + enable = lib.mkEnableOption description; + + irccatUrl = lib.mkOption { + type = lib.types.str; + }; + + listenAddress = lib.mkOption { + type = lib.types.str; + }; + }; + + config = lib.mkIf cfg.enable { + systemd.services.gerrit-webhook-to-irccat = { + serviceConfig = { + ExecStart = "${depot.ops.gerrit-webhook-to-irccat}/bin/gerrit-webhook-to-irccat" + + " -irccat-url ${cfg.irccatUrl}"; + Restart = "always"; + RestartSec = 5; + User = "gerrit-webhook-to-irccat"; + DynamicUser = true; + ProtectHome = true; + ProtectSystem = true; + MemoryDenyWriteExecute = true; + ProtectControlGroups = true; + ProtectKernelModules = true; + ProtectKernelTunables = true; + RestrictNamespaces = true; + RestrictRealtime = true; + SystemCallArchitectures = "native"; + SystemCallFilter = [ + "@system-service" + "~@privileged" + ]; + }; + }; + systemd.sockets.gerrit-webhook-to-irccat = { + wantedBy = [ "sockets.target" ]; + socketConfig.ListenStream = cfg.listenAddress; + }; + }; +}