Um Software von Github zu installieren, muss man sie erst kompilieren und in ein Paketformat wie DEB oder RPM schnüren. Mit unserem verteilten System realisieren wir einen Bot, der uns benachrichtigt, wenn sich ein Repository verändert.
Versionskontrolle hat sich in der Softwareentwicklung durchgesetzt, und auch wer hobbymäßig Software entwickelt, verwendet mit sehr großer Wahrscheinlichkeit Git. Obwohl es ursprünglich für den Entwicklungsprozess des Linux-Kernels gedacht war, eignet es sich für Software jeder Größe; einen Großteil aller Projekte findet man bei Hostern wie Github oder Gitlab.
Ein Git-Repository enthält üblicherweise den Quellcode einer Anwendung, den es vor der Installation erst zu kompilieren gilt. Im Idealfall wollen wir Software aber nicht händisch installieren, sondern über den Paketmanager unserer Distribution. Deshalb schreiben wir im Folgenden ein verteiltes System, das aus Git-Repositories automatisiert Pakete baut und zur Installation bereitstellt. Dazu erstellen wir einen Bot – so nennen wir die autonomen Prozesse in dem verteilten System –, der Git-Repositories überwacht. Entsprechend seines Aufgabenbereichs soll er Watchbot heißen.
|
Verteilte Systeme in der Bash (Teil 1) |
LM 06/2022, S. 76 |
|
|
Verteilte Systeme in der Bash (Teil 2) |
LM 07/2022, S. 82 |
|
|
Framework für automatisierte Tests |
LM 08/2022, S. 80 |
|
|
Komfortables Logging für die Bash |
LM 09/2022, S. 78 |
|
|
Kommandozeilenparser im Eigenbau |
LM 10/2022, S. 72 |
|
|
Mutexe und Semaphoren in der Bash |
LM 11/2022, S. 80 |
|
|
Deadlock-freie Queues in der Bash |
LM 12/2022, S.82 |
|
|
IPC mit den Mitteln der Bash |
LM 01/2023, S.80 |
|
|
Daemons mit den Mitteln der Bash |
LM 02/2023, S.78 |
|
|
Watchbot für Github-Repositories |
LM 03/2023, S.78 |
Geschichtsstunde
Ein Git-Repository besteht aus einem oder mehreren Branches, also Entwicklungszweigen, die wiederum einen oder mehrere Commits enthalten. Ein Commit ist eine Veränderung, die von einem Entwickler an die Geschichte eines Branches angehängt wurde. Commits lassen sichnur an das Ende eines Zweigs anhängen, sofern man nicht die gesamte Geschichte des Branches umschreibt. Den letzten Commit in einem Zweig bezeichnet man als Head. Beobachtet der Bot dessen Hash, kann er leicht feststellen, ob der Branch verändert wurde.
In einem lokalen Repository befindet sich im Verzeichnis ».git/refs/heads/« (beziehungsweise »refs/heads/« für mit »–bare« erstellte Repos) für jeden Branch eine Datei, die den Hash des letzten Commits enthält. Entsprechend unkompliziert fällt eine Funktion »fetch_head_local()« aus, die den Head in einem lokalen Repository ausliest (Listing 1).
Listing 1
Head auslesen
fetch_head_local() {
local repository="$1"
local branch="$2"
local head
if ! head=$(cat "$repository/.git/refs/heads/$branch" 2>/dev/null) &&
! head=$(cat "$repository/refs/heads/$branch" 2>/dev/null); then
return 1
fi
echo "$head"
return 0
}
Bei einem Projekt auf Github wollen wir aber nicht jedes Mal das komplette Repo herunterladen, nur um einen Hash auszulesen. Das ist auch nicht nötig, denn Git bietet dazu ein HTTP-basiertes Protokoll [1] an. So lässt sich etwa unter der URL »https://github.com/Projekt/info/refs?service=git-upload-pack« eine Liste aller Heads im Repository herunterladen.
Die Schwierigkeit dabei: Wir können die Antwort des Servers nicht direkt einer Shell-Variable zuweisen, da sie Binärdaten enthält. Statt der regulären Ausdrücke der Bash müssen wir also Grep bemühen. Listing 2 zeigt, dass das nicht weiter kompliziert ist, wenn man den richtigen Ausdruck kennt. In der Antwort des Servers stehen vier Hex-Ziffern vor den 40-stelligen Hashes. Verwenden wir Grep mit Perl-kompatiblen regulären Ausdrücken, können wir es mit »\K« anweisen, nur den nachfolgenden Teil des Treffers auszugeben. In diesem Fall umfasst das den Hash sowie den Pfad des Heads. Letzterer lässt sich mit einer simplen String-Expansion vom Ende des Strings entfernen.
Listing 2
Head per HTTP auslesen
fetch_head_http() {
local repository="$1"
local branch="$2"
local re
local url
local data
re="^00[0-9a-f]{2}\\K[0-9a-f]{40} refs/heads/$branch\$"
url="$repository/info/refs?service=git-upload-pack"
if ! data=$(curl --get --silent --location "$url" 2>/dev/null |
grep -oP "$re" --binary-files=text); then
return 1
fi
echo "${data%% *}"
return 0
}
Mit diesen Funktionen können wir nun einem Branch eines Repositories einen Hash zuordnen, der seinen aktuellen Zustand beschreibt. Zudem brauchen bedarf es jedoch einer Möglichkeit, einem Branch einen Hash zuzuordnen, der seinen vorherigen Zustand beschreibt. Die Bash kennt zwar nicht viele Datentypen, aber für derartige Zuordnungen sind assoziative Arrays ideal. Wir merken uns also alle zuletzt gesehenen Hashes in dem Array. Als Index dient eine Zeichenkette bestehend aus der URL des Repositories und dem Namen des Zweigs, getrennt durch eine Raute.
Um beim Auslesen der Hashes nicht jedes Mal eine Fallunterscheidung machen zu müssen, implementieren wir bei dieser Gelegenheit noch eine Funktion »fetch_head()«. Ihr übergeben wir als einziges Argument ein Repository samt Branch entsprechend dem Schema »URL#Branch«. Enthält der String keine Raute (und somit keinen Branch), soll der Zweig »main« abgefragt werden. Die Implementierung dieser Funktion können Sie dem Begleitmaterial zu diesem Beitrag entnehmen.
Mehrfachoptionen
Repositories enthalten meist mehr als einen Zweig. Für das automatisierte Bauen von Paketen ist aber nur ein Branch interessant. Der Watchbot soll deshalb nicht alle Zweige eines Repos überwachen, sondern nur jene, die wir ihm via Befehlszeile mitgeteilt haben.
Der Bot erkennt Veränderungen, indem er in regelmäßigen Abständen den Zustand der Repositories abfragt. Je kürzer das Intervall, desto schneller erkennt er Veränderungen. Zu häufige Abfragen belasten jedoch das Netzwerk. Deshalb soll der Benutzer das Intervall über die Befehlszeile verändern können. Erkennt er eine Veränderung, soll der Bot eine PubSub-Nachricht an einen Topic senden. Dieser Topic soll sich ebenfalls per Option ändern lassen, aber eine sinnvolle Voreinstellung mitbringen.
In der Main-Funktion deklarieren wir daher die drei Optionen »–repository«, »–interval« und »–publish-to« so, wie wir es bereits im Beitrag über das Modul »opt« gesehen haben. Ein Repo muss entweder ein lokaler Pfad oder eine URL mit HTTPS-Protokoll sein, was wir mit einem einfachen regulären Ausdruck prüfen. Dabei darf der Benutzer mehrere Repositories auf der Kommandozeile angeben. Wir definieren daher ein Callback »_add_to_watchlist()«, das die übergebenen Werte an das globale Array »watchlist« anhängt.
Nachdem die Optionen erfolgreich ausgelesen wurden, bemühen wir die Funktion »inst_start()« (aus dem Modul»inst«), um eine Instanz des Bots zu starten. Der Hauptfunktion der Instanz, »watch_repos()«, geben wir die drei Optionen mit auf den Weg (Listing 3).
Listing 3
Watchbot initialisieren
main() {
declare -g watchlist
local publish_to
local -i interval
opt_add "r" "repository" "rv" "" \
"Zu ueberwachendes Repository" \
'^(/.*|https://.*)' _add_to_watchlist
opt_add "p" "publish-to" "v" "commits" \
"Topic fuer Benachrichtigungen"
opt_add "i" "interval" "v" 300 \
"Sekunden zwischen Updates" '^[0-9]+$'
if ! opt_parse "$@"; then
return 1
fi
publish_to=$(opt_get "publish-to")
interval=$(opt_get "interval")
inst_start watch_repos "$publish_to" "$interval" "${watchlist[@]}"
return 0
}
Kopf an Kopf
In der Hauptfunktion des Bots müssen wir lediglich über die Liste der zu beobachtenden Repositories iterieren und Veränderungen feststellen. Dazu deklarieren wir am Anfang der Funktion ein assoziatives Array »heads«, das den letzten Zustand aller überwachten Branches festhält.
Die Schleife fragt dann den Head eines Zweigs ab und vergleicht ihn mit dem im assoziativen Array gespeicherten Head. Da das Array beim ersten Durchlauf der Schleife noch leer ist, müssen wir auch testen, ob der Eintrag im Array gesetzt ist. Anderenfalls würde der Bot beim Start eine Veränderung in jedem Branch melden – manchmal kann das aber auch gewollt sein.
Ist der Wert im Array gesetzt und hat sich der Zustand des Branches seit dem letzten Test verändert, rufen wir die Funktion »send_notification()« auf, die eine PubSub-Nachricht versendet. Den Wert im Array aktualisieren wir aber nur dann, wenn das Versenden der Benachrichtigung erfolgreich war. Schlägt es fehl, versucht der Bot es im nächsten Durchgang erneut (Listing 4).
Listing 4
Kern des Watchbots
watch_repos() {
local topic="$1"
local interval="$2"
local watchlist=("${@:3}")
local endpoint
declare -A heads
if ! endpoint=$(ipc_endpoint_open); then
return 1
fi
while inst_running; do
local watch
log_info "Pruefe ${#watchlist[@]} Repositories
auf Updates"
for watch in "${watchlist[@]}"; do
local new_head
if ! new_head=$(fetch_head "$watch"); then
continue
fi
if [[ -n "${heads[$watch]}" ]] &&
[[ "${heads[$watch]}" != "$new_head" ]]; then
log_info "HEAD von $watch hat sich veraendert"
if ! send_notification "$endpoint" "$topic" "$watch" "$new_head"; then
log_warn "Kann nicht unter Topic $topic veroeffentlichen"
continue
fi
fi
heads["$watch"]="$new_head"
done
sleep "$interval"
done
ipc_endpoint_close "$endpoint"
return 0
}
Gedankenübertragung
Hat der Watchbot eine Veränderung an einem Zweig festgestellt, gibt er mit einer PubSub-Nachricht allen interessierten Prozessen Bescheid. Wie schon bei der Implementierung des IPC-Moduls setzen wir dabei auf JSON als Nachrichtenformat. Die Funktion »send_notification()« generiert also ein JSON-Objekt, das neben der URL des Repositories und dem Namen des Branches auch den Hash des Commits enthält.
Wir geben dem Object auch ein Feld »type« mit dem Wert »commit«. Sollten wir die Nachrichten debuggen müssen, können wir so erkennen, um welche Art von Nachricht es sich handelt (Listing 5). Die Hilfsfunktionen »watch_to_repository()« und »watch_to_branch()« bestimmen die Adresse des Repositories beziehungsweise den Namen des Branches aus einer Repository-URL, wie sie vom Benutzer auf der Befehlszeile übergeben wurde. Da die beiden Funktionen keiner Erklärung bedürfen, ist sie hier nicht abgedruckt, Sie können sie dem Begleitmaterial entnehmen.
Listing 5
Nachrichten im JSON-Format
send_notification() {
local endpoint="$1"
local topic="$2"
local watch="$3"
local ref="$4"
local repository
local branch
local msg
repository=$(watch_to_repository "$watch")
branch=$(watch_to_branch "$watch")
msg=$(printf '{"type": "commit", "repository": "%s", "branch": "%s", "ref": "%s"}\n' "$repository" "$branch" "$ref")
if ! ipc_endpoint_publish "$endpoint" "$topic" "$msg"; then
return 1
fi
return 0
}
Den Bot speichern wir nun unter dem Pfad »/usr/local/share/dbs/watchbot.sh« und erstellen einen Symlink von »/usr/local/bin/watchbot« auf das Skript. Bevor wir den Bot auf ein Repository ansetzen, brauchen wir aber auch noch ein Skript, das die Nachrichten anzeigt, die der Watchbot veröffentlicht hat. Es abonniert einen auf der Befehlszeile definierten Topic, wartet auf eine Nachricht und gibt sie auf der Standardausgabe aus. Die Funktion »main()« zeigt Listing 6; das komplette Skript finden Sie im Begleitmaterial zum Artikel.
Listing 6
Nachrichtenempfänger
main() {
local topic
local endpoint
opt_add "t" "topic" "rv" "" "The topic to subscribe to"
if ! opt_parse "$@"; then
return 1
fi
topic=$(opt_get "topic")
if ! endpoint=$(ipc_endpoint_open); then
return 1
fi
if ipc_endpoint_subscribe "$endpoint" "$topic"; then
local msg
if ! msg=$(ipc_endpoint_recv "$endpoint"); then
log_error "Konnte Nachricht nicht empfangen"
else
ipc_msg_get_data "$msg"
fi
fi
ipc_endpoint_close "$endpoint"
return 0
}
Das Testskript speichern wir mit dem Namen »readcommit.sh« an einem beliebigen Ort und rufen es mit »./readcommit.sh –topic commits« auf. Anschließend klonen wir mit »git clone« ein beliebiges Repository von Github in ein temporäres Verzeichnis und weisen den Watchbot an, das lokale Repo zu beobachten (Listing 7).
Listing 7
Testlauf
$ git clone -b stable https://github.com/m10k/toolbox /tmp/toolbox $ watchbot --repository /tmp/toolbox#stable --interval 15
Jetzt verändern wir das lokale Repository, um zu testen, ob der Watchbot die Modifikation erkennt. Die Änderung muss nicht groß sein; eine neue leere Datei genügt völlig. Im lokalen Repository führen wir die Befehle aus Listing 8 aus, um eine leere Datei zu erzeugen und an die Historie des Branches anzuhängen. Wer zum ersten Mal Git verwendet, muss möglicherweise Benutzernamen und E-Mail-Adresse konfigurieren, bevor ein Commit erzeugt werden kann. Dazu gibt Git aber eine informative Nachricht aus.
Listing 8
Watchbot-Test-Repo
$ touch leer $ git add leer $ git commit -m "Leere Datei hinzufuegen"
Der letzte der drei Befehle aus Listing 8 fügt einen neuen Commit am Ende des Zweigs ein. Da wir den Watchbot mit einem Update-Intervall von 15 Sekunden gestartet haben, sollte er die Veränderung innerhalb weniger Sekunden entdecken, sodass das Testskript den Inhalt der Benachrichtigung auf der Standardausgabe darstellt. Das Ergebnis ähnelt der Ausgabe in Abbildung 1.
An dieser Stelle wäre es schön gewesen, eine Änderung in einem Repository bei einem Hoster zu beobachten. Doch selbst bei beliebten Projekten kann es Stunden bis Tage dauern, bis ein Branch verändert wird. Wer bei Github oder Gitlab registriert ist, kann den Watchbot an einem persönlichen Projekt ausprobieren – oder dem Autor vertrauen, dass er den Bot ausreichend getestet hat.
Ausblick
Dank des Watchbots können wir nun sehr flexibel auf Veränderungen in einem Git-Repository reagieren. Als Nächstes brauchen wir einen Bot, der den Code aus dem Git-Repo in ein Debian-Paket verpackt. Darum wird es in der nächsten Folge gehen. (jcb)
Infos
- HTTP-Protokoll in Git: https://www.git-scm.com/docs/http-protocol#_smart_clients






