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 <ryan@lahfa.xyz>
Tested-by: besadii
Autosubmit: Florian Klink <flokli@flokli.de>
This commit is contained in:
Florian Klink 2025-05-15 20:42:58 +03:00 committed by clbot
parent af4e1303b0
commit 064765b19a
5 changed files with 237 additions and 0 deletions

View file

@ -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=";
}

View file

@ -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

View file

@ -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=

View file

@ -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()
}

View file

@ -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;
};
};
}