Last active
April 23, 2024 20:18
-
-
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 file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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. |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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(), | |
) | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
#!/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