GitHub Webhooks: Event-Dispatcher
Dies ist Folgeartikel 3 aus meiner Serie zu GitHub-Webhooks.
Nachdem die Events in der Queue abgelegt worden sind, müssen sie dort weiterverarbeitet und an die eigentlichen Eventhandler übergeben werden. Darum geht es in diesem Artikel.
Ursprünglich habe ich das mit einem einzigen Skript erledigt, aber es hat sich gezeigt, dass es passender und flexibler ist, das in zwei Teile zu teilen.
Da die Skripte gar nicht respektive nicht tief im JSON rumhantieren müssen (das machen die eigentlichen Event-Handler), kann ich das in Shell lösen und brauche kein Perl.
Der Queue-Beobachter
- #!/bin/sh
- BASE_DIR="$HOME/json"
- QUEUE_DIR="$BASE_DIR/queue"
- PROCESSING_DIR="$BASE_DIR/processing"
- PROCESSED_DIR="$BASE_DIR/done"
- ERROR_DIR="$BASE_DIR/error"
- inotifywait -m -e create -e moved_to "$QUEUE_DIR" | while read -r INOTIFY; do
- for QUEUE_FILE in "$QUEUE_DIR"/*; do
- [ -e "$QUEUE_FILE" ] || break
- FILENAME="$(basename "${QUEUE_FILE}")"
- PROCESSING_FILE="$PROCESSING_DIR/$FILENAME"
- PROCESSED_FILE="$PROCESSED_DIR/$FILENAME"
- ERRORED_FILE="$ERROR_DIR/$FILENAME"
- mv "$QUEUE_FILE" "$PROCESSING_FILE"
- if ./handler.sh "$PROCESSING_FILE"; then
- mv "$PROCESSING_FILE" "$PROCESSED_FILE"
- else
- mv "$PROCESSING_FILE" "$ERRORED_FILE"
- echo "ERROR during processing, see $ERRORED_FILE"
- fi
- done
- done
Das Skript /home/webhook/watcher.sh
macht nichts anderes, als das
Queue-Verzeichnis $QUEUE_DIR
zu überwachen und bei einer neuen
JSON-Datei deren Verarbeitung anzustoßen. Dazu wird die Datei in das
Verzeichnis $PROCESSING_DIR
verschoben und das zweite Skript
./handler.sh
mit dem Dateinamen als Parameter aufgerufen.
Erfolgreich verarbeitete Dateien werden dann nach $PROCESSED_DIR
verschoben, fehlerhafte nach $ERROR_DIR
. Im Fehlerfall gibt es
einen kurzen Hinweis auf STDOUT, ansonsten ist das Skript ruhig.
Um das Verzeichnis $QUEUE_DIR
effizient zu überwachen, arbeite ich
mit inotifywait
, einem Kommandozeilen-Tool für inotify (in Debian
im Paket inotify-tools
).
Race-Conditions
Ursprünglich sah das Warten auf Dateien in der Queue mal so aus:
- while inotifywait -m -e create -e moved_to "$QUEUE_DIR"; do
- for QUEUE_FILE in "$QUEUE_DIR"/*; do
- […]
- done
- done
Das hat aber einen Haken: Wenn eine neue Datei eintrudelt, während die
for
-Schleife noch arbeitet, wird die neue Datei erst berücksichtigt,
wenn inotifywait
wieder dran ist und dann nochmal eine weitere neue
Datei kommt. Da können also ein oder mehrere Events mal ganz lange
liegenbleiben…
Im jetzigen Skript ist das genau andersherum: Wenn viele Dateien auf
einmal eintrudeln, dann kann es sein, dass die for
-Schleife bereits
eine Datei verarbeitet, die inotifywait
noch gar nicht gemeldet hat.
Die Nachmeldung erfolgt dann im nächsten while
-Durchlauf. Wenn die
Datei schon verarbeitet wurde, sieht die for
-Schleife nichts und ist
halt schnell fertig. Da bleibt nichts mehr unverarbeitet liegen, es
wird höchstens einmal unnötigerweise geguckt, was zu verarbeiten wäre.
Autostart
Dieses Skript muss dauerhaft laufen, daher habe ich dafür auch einen
systemd-Service angelegt unter /etc/systemd/system/local-webhook-watcher.service
:
- [Unit]
- Description=Github Webhook Watcher Service (JSON dispatcher)
- [Service]
- Type=simple
- ExecStart=/home/webhook/watcher.sh
- StandardOutput=journal
- StandardError=journal
- User=webhook
- Group=webhook
- WorkingDirectory=~
- Nice=19
- IOSchedulingClass=idle
- ProtectSystem=full
- PrivateTmp=true
- [Install]
- WantedBy=local-webhook-receiver.service
Das sieht fast genauso aus wie der Service für plackup.
Neu hinzugekommen sind Nice
und IOSchedulingClass
, die das Skript
mit minimaler Priorität laufen lassen. Wenn der Webhook das Event
erstmal empfangen und lokal in die Queue geschrieben hat, kann das
Verarbeiten in der Queue gerne mal 5 Sekunden dauern, da geht ja
nichts verloren. Also ist das Skript mal ganz brav und stellt sich
hinten an, wenn gerade was anderes passiert (mein Server langweilt
sich aber eh meist, das dürfte wenig Unterschied machen).
Aktivieren dann wie üblich:
- systemctl daemon-reload
- systemctl enable local-webhook-watcher.service
- systemctl start local-webhook-watcher.service
Autoreload
Ursprünglich hatte ich auch den Aufruf der Event-Handler ebenfalls in diesem
Skript und während des Rumprogrammierens musste ich den
systemd-Service ständig neu starten. Dafür musste ich jedes Mal
root
werden, um systemctl restart local-webhook-watcher.service
aufzurufen, und das hat genervt.
Das geht natürlich einfacher: systemd kann Pfade beobachten und selbständig Dinge tun, wenn etwas passiert:
local-webhook-watcher-restarter.path
anlegen – da steht drin, was beobachtet werden soll:
- Path]
- PathModified=/home/webhook/watcher.sh
- [Install]
- WantedBy=local-webhook-watcher.service
local-webhook-watcher-restarter.service
anlegen – da steht drin, was passiert, wenn sich am beobachteten Pfad etwas ändert:
- [Unit]
- Description=restart local-webhook-watcher.service when watcher.sh script is changed
- [Service]
- Type=oneshot
- ExecStart=/bin/systemctl restart local-webhook-watcher.service
- [Install]
- WantedBy=local-webhook-watcher.service
Durch Type=oneshot
stößt systemd den ExecStart
-Prozess einmalig
an, kümmert sich dann aber nicht weiter um ihn: Fehler werden
ignoriert und stoppen lässt sich auch nichts.
- Aktivierung wie gehabt, nur dass wir dieses Mal den
.path
starten, nicht den.service
, denn der.service
wird ja getriggert, wenn sich die Datei geändert hat:
- systemctl daemon-reload
- systemctl enable local-webhook-watcher-restarter.path
- systemctl start local-webhook-watcher-restarter.path
Jedes Mal, wenn jetzt das Skript watcher.sh
editiert wird, wird der
zugehörige systemd-Service neu gestartet und das geänderte Skript
wird sofort aktiv. Ganz ohne Root-Rechte.
Diesen automatischen Restart brauche ich jetzt zwar nicht mehr, weil
handler.sh
ein eigenes Skript ist und für jedes Event frisch
gestartet wird, es ist aber gut zu wissen, dass das geht. Und es ist
schon fertig eingerichtet und jetzt kann es ja auch ruhig aktiv bleiben…
Der Event-Dispatcher
Das zweite Skript /home/webhook/handler.sh
ruft abhängig vom
Event-Typ die eigentliche Verarbeitung mittels weiterer Perlskripte
(die kommen in späteren Artikeln) auf:
- #!/bin/bash
- die()
- {
- echo "$@"
- exit 1
- }
- handle_issue()
- {
- ./handle_issue.pl "$1"
- }
- handle_ping()
- {
- REPO="$(jq -r .payload.repository.full_name "$1")"
- echo " got a PING for $REPO"
- }
- handle_pull_request()
- {
- ./handle_pull_request.pl "$1"
- }
- handle_push()
- {
- ./handle_push.pl "$1"
- }
- process_file()
- {
- local JSON="$1"
- local EVENT_TYPE="$(jq -r .event "$JSON")"
- echo "processing $EVENT_TYPE event from $JSON"
- case "$EVENT_TYPE" in
- issues)
- handle_issue "$JSON"
- ;;
- ping)
- handle_ping "$JSON"
- ;;
- pull_request)
- handle_pull_request "$JSON"
- ;;
- push)
- handle_push "$JSON"
- ;;
- *)
- echo " (E/$EVENT_TYPE is ignored)"
- ;;
- esac
- }
- FILE="$1"
- [ -n "$FILE" ] || die 'no JSON file given'
- [ -e "$FILE" ] || die 'JSON file does not exist'
- [ -r "$FILE" ] || die 'JSON file is not readable'
- process_file "$FILE"
Auch hier nichts besonderes (außer, dass ich den Code etwas zerfleddert habe): Erstmal wird geguckt, ob der übergebene Dateiname vorhanden und lesbar ist, danach wird anhand des Event-Typs aus der Datei entschieden, welches Skript aufgerufen wird – die einzelnen Skripte kommen in späteren Artikeln.
Um an den Event-Typ aus dem JSON zu kommen, benutze ich jq. jq ist das Schweizer Taschenmesser für JSON in Shell und kann richtig wilde Dinge, wenn man sich erstmal an die Syntax gewöhnt hat. Darin steckt schon eine halbe Programmiersprache und man kann Selektieren, Transformieren und noch viel mehr.[1]
So sieht das JSON in der Queue ungefähr aus (das baue ich mir ja selbst so zusammen):
- {
- "event": "status",
- "payload": {
- …
- },
- "delivery": "fed8f64c-2622-11a9-9dcc-b0395b1c703b",
- "received": "1549049070"
- }
Mit jq -r .event "$JSON"
gibt JSON den Event-Typ (hier: status
)
aus:
"$JSON"
ist die Datei, aus der das JSON gelesen wird..event
selektiert das Attributevent
aus dem Root-Objekt-r
gibt Rohdaten aus, sonst wäre der JSON-Stringstatus
nochmal in Anführungszeichen und die wird man in der Shell nur schwer wieder los
Das ping
-Event wird als einziges nicht an ein externes Skript
dispatcht, sondern gleich vor Ort verarbeitet. Hier nehme ich nochmal
jq, um über .payload.repository.full_name
den Namen des
angepingten Repositories aus der Payload von GitHub auszulesen.
Ansonsten wird noch ein wenig nach STDOUT geloggt und im Fehlerfall
mit einer Fehlermeldung auf STDOUT plus Returncode 1
abgebrochen –
dann greift die Fehlerverarbeitung im aufrufenden Skript watcher.sh
.
Damit ist die Empfangsseite komplett, jetzt fehlt noch die tatsächliche Verarbeitung der Events.
jq is likesed
for JSON data - you can use it to slice and filter and map and transform structured data with the same ease thatsed
,awk
,grep
and friends let you play with text.
Netz - Rettung - Recht am : Wellenreiten 02/2019