Skip to content

Instantly share code, notes, and snippets.

@philpennock
Last active April 23, 2024 20:18
Show Gist options
  • Save philpennock/a019ca386ad07d4008a7de678df813a6 to your computer and use it in GitHub Desktop.
Save philpennock/a019ca386ad07d4008a7de678df813a6 to your computer and use it in GitHub Desktop.
Golang version of git post-receive hook for gitolite to publish updates to NATS
This is for gitolite to publish notifications to NATS with details of commits.
I use the "hooks in admin repo" approach: I have root on the gitolite server and only I have commit access.
The only action taken outside of this repo was to install Go (1.15.5).
This approach uses a shell wrapper to on-demand re-compile the binary hook, which is written in Go.
Shell script: local/hooks/repo-specific/wrap-go-nats-publish.post-receive
Symlink: local/hooks/common/post-receive -> ../repo-specific/wrap-go-nats-publish.post-receive
Go source: local/src/nats-publish-postreceive.go
The symlink approach lets me disable this as a global hook but still invoke it on a per-repo basis if I want.
I then created local/src/ to hold the Go code, which is stealing a bit of the gitolite admin repo namespace.
I used `go mod init my.name.space` inside that directory, so that go modules will be used for fetching the dependencies and the versions can be tracked.
The shell script and the Go code should both be below.
NB: to enable push options, I added to my gitolite configuration:
repo @all
config receive.advertisePushOptions = true
where the .gitolite.rc already adjusted $RC{GIT_CONFIG_KEYS} to be sufficiently permissive to allow this.
package main
import (
"bufio"
"bytes"
"encoding/json"
"fmt"
"io"
"os"
"os/exec"
"path/filepath"
"regexp"
"strconv"
"strings"
"time"
"github.com/nats-io/nats.go"
)
const (
OUR_NATS_NAME = "git-nats-publish"
PUBLISH_PREFIX = "git-update"
NATS_SERVER_URL = "tls://nats.lan"
// These are ones which exist purely so that I don't have typo-potential
// string constants below.
ENV_GIT_PUSH_OPTION_COUNT = "GIT_PUSH_OPTION_COUNT"
ENVPREFIX_GIT_PUSH_OPTION = "GIT_PUSH_OPTION_"
ENV_HOSTNAME = "HOSTNAME"
ENV_GL_REPO_BASE = "GL_REPO_BASE" // ~/repositories
ENV_GL_USER = "GL_USER" // remote user
ENV_GL_REPO = "GL_REPO" // repo slug
// sysexits.h compatibility
EX_USAGE = 64
EX_UNAVAILABLE = 69
)
func main() {
processStartTime := time.Now()
progname = filepath.Base(os.Args[0])
pushOpts := ParsePushOptionsMaybeExit()
reporter := NewReporter(pushOpts, processStartTime)
if reporter.failed > 0 {
os.Exit(reporter.failed)
}
reporter.TunePerRepo()
reporter.ProcessStdinReferences()
reporter.MainLogGitCommits()
reporter.Done()
}
var progname string
func stderr(spec string, args ...interface{}) {
call := make([]interface{}, 1, 1+len(args))
call[0] = progname
call = append(call, args...)
fmt.Fprintf(os.Stderr, "%s: "+spec+"\n", call...)
}
type PushOptions struct {
Verbose int
}
func ParsePushOptionsMaybeExit() PushOptions {
var (
count int
err error
opts PushOptions
)
countStr, ok := os.LookupEnv(ENV_GIT_PUSH_OPTION_COUNT)
if ok {
count, err = strconv.Atoi(countStr)
if err != nil {
stderr("error parsing $%s: %v", ENV_GIT_PUSH_OPTION_COUNT, err)
return opts
}
}
for i := 0; i < count; i++ {
varname := ENVPREFIX_GIT_PUSH_OPTION + strconv.Itoa(i)
option := os.Getenv(varname)
upperOption := strings.ToUpper(option)
switch upperOption {
case "NONATS", "NO-NATS", "NO_NATS":
stderr("skipping per request")
os.Exit(0)
case "VERBOSE", "V":
opts.Verbose = 1
}
eq := strings.IndexRune(upperOption, '=')
if eq == -1 {
continue
}
switch upperOption[:eq] {
case "VERBOSE", "V":
lvl, err := strconv.Atoi(upperOption[eq+1:])
if err != nil {
stderr("error parsing verbose as integer, treating as 1: %v", err)
opts.Verbose = 1
} else {
opts.Verbose = lvl
}
}
}
return opts
}
type Reporter struct {
nats *nats.Conn
verbose int
failed int
baseTopic string
dottedRepo string
user string
hostname string
mainName string
mainOld string
mainNew string
mainRef string
baseMessage BaseMessage
gitStartTime time.Time
processStartTime time.Time
didMainlogPublish bool
}
func NewReporter(pushOpts PushOptions, processStartTime time.Time) *Reporter {
var err error
r := &Reporter{
verbose: pushOpts.Verbose,
dottedRepo: strings.Replace(os.Getenv(ENV_GL_REPO), "/", ".", -1),
user: os.Getenv(ENV_GL_USER),
hostname: os.Getenv(ENV_HOSTNAME),
processStartTime: processStartTime,
}
if r.dottedRepo == "" {
stderr("missing env var %q", ENV_GL_REPO)
r.failed = EX_USAGE
} else if r.user == "" {
stderr("missing env var %q", ENV_GL_USER)
r.failed = EX_USAGE
}
if r.failed != 0 {
return r
}
if r.hostname == "" {
r.hostname, err = os.Hostname()
if err != nil {
r.hostname = "unknown"
stderr("unable to derive hostname, logging as %q", r.hostname)
}
}
r.baseTopic = PUBLISH_PREFIX + "." + strings.SplitN(r.hostname, ".", 2)[0]
r.baseMessage = BaseMessage{
Host: r.hostname,
User: r.user,
Repo: os.Getenv(ENV_GL_REPO),
}
r.connectToNATS()
return r
}
func (r *Reporter) VerboseNf(level int, spec string, args ...interface{}) {
if level > r.verbose {
return
}
call := make([]interface{}, 2, 2+len(args))
call[0] = progname
call[1] = time.Now().Sub(r.processStartTime).Round(time.Millisecond)
call = append(call, args...)
fmt.Fprintf(os.Stderr, "%s: [%v] "+spec+"\n", call...)
}
func (r *Reporter) connectToNATS() {
r.VerboseNf(1, "starting NATS connection to %q, naming ourselves %q", NATS_SERVER_URL, OUR_NATS_NAME)
opts := make([]nats.Option, 0, 16)
opts = append(opts, nats.Name(OUR_NATS_NAME))
// TODO: authentication options and credentials handling goes here
nc, err := nats.Connect(NATS_SERVER_URL, opts...)
if err != nil {
stderr("connecting to NATS at %q failed: %v", NATS_SERVER_URL, err)
r.failed = EX_UNAVAILABLE
return
}
r.nats = nc
r.VerboseNf(1, "connected to NATS %q at %s", r.nats.ConnectedServerId(), r.nats.ConnectedAddr())
}
func (r *Reporter) TunePerRepo() {
r.VerboseNf(1, "checking for git config options to tune us")
defer r.VerboseNf(1, "collected any git config options")
cfgOutput := &bytes.Buffer{}
cmd := exec.Command("git", "config", "--local", "--get", "-z", "pdp.mainline-branch-name")
cmd.Stdout = cfgOutput
err := cmd.Run()
if err != nil {
// This will be normal: it's rare for this to be set
return
}
r.mainName = "refs/heads/" + strings.TrimSuffix(cfgOutput.String(), "\000")
}
type BaseMessage struct {
Host string `json:"host"`
User string `json:"user"`
Repo string `json:"repo"`
}
type UpdateMessage struct {
Ref string `json:"ref"`
Old string `json:"old"`
New string `json:"new"`
}
type MessagePerRef struct {
BaseMessage
Ref string `json:"ref"`
Update UpdateMessage `json:"update"`
}
type MessageMain struct {
BaseMessage
Updates []UpdateMessage `json:"updates"`
}
type MessageLogs struct {
BaseMessage
Logs []LogMessage `json:"logs"`
}
type LogMessage struct {
Abbrev string `json:"abbrev"`
Date string `json:"date"`
Msg string `json:"msg"`
}
func (r *Reporter) ProcessStdinReferences() {
stderr("publishing NATS notifications to \"%s.(PERREF,UPDATE).%s\"", r.baseTopic, r.dottedRepo)
r.gitStartTime = time.Now()
var (
oldVal, newVal, refName string
mainMessage MessageMain
lineNo int
)
mainMessage = MessageMain{
BaseMessage: r.baseMessage,
Updates: make([]UpdateMessage, 0, 50),
}
perRefTopic := r.baseTopic + ".PERREF." + r.dottedRepo
updateTopic := r.baseTopic + ".UPDATE." + r.dottedRepo
scanner := bufio.NewScanner(os.Stdin)
for scanner.Scan() {
lineNo += 1
r.VerboseNf(2, "scanning ref-line %d", lineNo)
fields := strings.Fields(scanner.Text())
if len(fields) != 3 {
stderr("warning: line %d of stdin did not contain three fields", lineNo)
continue
}
oldVal, newVal, refName = fields[0], fields[1], fields[2]
update := UpdateMessage{
Ref: refName,
Old: oldVal,
New: newVal,
}
perRef := MessagePerRef{
BaseMessage: r.baseMessage,
Ref: refName,
Update: update,
}
mainMessage.Updates = append(mainMessage.Updates, update)
r.VerboseNf(2, "marshalling JSON ref-line %d", lineNo)
payload, err := json.Marshal(perRef)
if err != nil {
stderr("failed to marshall JSON for reference %q: %v", refName, err)
continue
}
r.VerboseNf(2, "publishing to PERREF NATS, ref-line %d", lineNo)
if err := r.nats.Publish(perRefTopic, payload); err != nil {
stderr("nats publish to %q failed: %v", perRefTopic, err)
}
r.VerboseNf(2, "published")
switch refName {
case "refs/heads/main":
r.mainOld, r.mainNew, r.mainRef = oldVal, newVal, refName
r.mainName = "refs/heads/main"
case "refs/heads/master":
if r.mainRef == "" {
r.mainOld, r.mainNew, r.mainRef = oldVal, newVal, refName
r.mainName = "refs/heads/master"
}
case r.mainName:
if r.mainName != "" {
r.mainOld, r.mainNew, r.mainRef = oldVal, newVal, refName
}
}
}
r.VerboseNf(2, "marshalling main JSON message")
payload, err := json.Marshal(mainMessage)
if err != nil {
stderr("failed to marshall JSON for main message: %v", err)
} else {
r.VerboseNf(2, "publishing to UPDATE NATS")
if err := r.nats.Publish(updateTopic, payload); err != nil {
stderr("nats publish to %q failed: %v", updateTopic, err)
}
r.VerboseNf(2, "published")
}
}
func (r *Reporter) MainLogGitCommits() {
if r.mainRef == "" {
r.VerboseNf(1, "no ref update on a main branch (%q)", r.mainName)
return
}
r.VerboseNf(1, "preparing MAINLOG NATS message")
var rangeSpec string
switch r.mainOld {
case "0000000000000000000000000000000000000000":
rangeSpec = r.mainNew
default:
rangeSpec = r.mainOld + ".." + r.mainNew
}
mainlogTopic := r.baseTopic + ".MAINLOG." + r.dottedRepo
matcher := regexp.MustCompile(`(?P<abbrev>\S+)\s+(?P<date>\S+)\s+(?P<msg>.*)`)
reAbbrev := matcher.SubexpIndex("abbrev")
reDate := matcher.SubexpIndex("date")
reMsg := matcher.SubexpIndex("msg")
reportMsg := MessageLogs{
BaseMessage: r.baseMessage,
Logs: make([]LogMessage, 0, 50),
}
r.VerboseNf(2, "invoking git log %q", rangeSpec)
logOutput := &bytes.Buffer{}
cmd := exec.Command("git", "log", "--date=short", "--reverse", "--pretty=tformat:%h %ad %s", rangeSpec)
cmd.Stdout = logOutput
err := cmd.Run()
if err != nil {
stderr("git log failed: %v", err)
return
}
r.VerboseNf(1, "got git log lines (%d octets)", logOutput.Len())
lineNo := 0
for {
lineNo += 1
line, err := logOutput.ReadBytes('\n')
if err != nil && err != io.EOF {
stderr("reading git output failed: %v", err)
break
}
if len(line) == 0 {
break
}
submatches := matcher.FindSubmatch(line)
if submatches == nil {
stderr("reading git output line %d failed to match regexp [%q]", lineNo, string(line))
continue
}
reportMsg.Logs = append(reportMsg.Logs, LogMessage{
Abbrev: string(submatches[reAbbrev]),
Date: string(submatches[reDate]),
Msg: string(submatches[reMsg]),
})
if err != nil {
break
}
}
r.VerboseNf(2, "marshalling mainlog JSON message")
payload, err := json.Marshal(reportMsg)
if err != nil {
stderr("failed to marshall JSON for MAINLOG message: %v", err)
} else {
r.VerboseNf(2, "publishing to MAINLOG NATS")
if err := r.nats.Publish(mainlogTopic, payload); err != nil {
stderr("nats publish to %q failed: %v", mainlogTopic, err)
} else {
r.VerboseNf(2, "published")
r.didMainlogPublish = true
}
}
}
func (r *Reporter) Done() {
var also string
r.VerboseNf(2, "flushing NATS")
if err := r.nats.Flush(); err != nil {
stderr("nats flush failed: %v", err)
}
r.VerboseNf(2, "closing NATS")
r.nats.Close()
r.VerboseNf(2, "closed NATS")
if r.didMainlogPublish {
also = " (also published to MAINLOG)"
}
stderr("done%s [%s] (process total: %s)", also,
time.Now().Sub(r.gitStartTime).Round(time.Millisecond).String(),
time.Now().Sub(r.processStartTime).Round(time.Millisecond).String(),
)
}
#!/bin/sh -eu
cmd=nats-publish-postreceive
if [ -z "${GL_ADMIN_BASE:-}" ]; then
# not running from inside gitolite
GL_ADMIN_BASE="$(git rev-parse --show-toplevel)"
fi
: "${PDP_GITOLITE_COMPILED_HOOKSDIR:=$HOME/compiled-hooks}"
# We call this one out as it can hold go.mod files etc and we compile inside it
go_srcdir="${GL_ADMIN_BASE:?}/local/src"
go_srcfile="${go_srcdir}/${cmd}.go"
go_binfile="${PDP_GITOLITE_COMPILED_HOOKSDIR:?}/${cmd}"
if [ -e "$go_binfile" ] && [ "$go_binfile" -nt "$go_srcfile" ]; then
exec "$go_binfile" "$@"
exit 70
fi
printf >&2 '%s: compiling %s\n' "$(basename "$0" .sh)" "${cmd}.go"
PATH="$PATH${PATH:+:}/usr/local/go/bin"
[ -d "$PDP_GITOLITE_COMPILED_HOOKSDIR" ] || mkdir -pv "$PDP_GITOLITE_COMPILED_HOOKSDIR"
(
# only chdir for the go build, so that we get go.mod references
# we want to keep the original cwd for the invocation of $go_binfile because it
# will be the active git dir and we want to run git commands
cd "$go_srcdir"
go build -v -o "$go_binfile" "$go_srcfile"
)
exec "$go_binfile" "$@"
# vim: set sw=2 et :
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment